marilenarm/turbo-toast-bundle

Flash messages,
minus the session.

Symfony's addFlash() starts a session behind your back, and with it come a lock, a PHPSESSID cookie and Cache-Control: private. Turbo Toast ships the same notifications over a Turbo Stream or a short-lived cookie instead, so your pages stay HTTP-cacheable. The toasts on this page mirror the bundle's controller: same markup, same ARIA roles, text-only rendering. Click something.

no session · no PHPSESSID · HTTP-cacheable
GETlocalhost:8000/

Tasks

Add a task or delete a row: you get the same toasts the demo app sends back.

  • Write the release notes
  • Review the pull request
  • Deploy the docs 🔒 locked
The session tax

One flash message, and your page stops being cacheable.

The moment a session starts, Symfony marks the response private and drops a PHPSESSID cookie, so no CDN or reverse proxy will ever serve that page from cache. Here's the same landing page, both ways.

with addFlash() starts a session
$ curl -sI https://app.example/
HTTP/1.1 200 OK
Set-Cookie: PHPSESSID=8f3a2b…; path=/; HttpOnly
Cache-Control: max-age=0, private, must-revalidate
Vary: Cookie
# uncacheable — every hit reaches PHP
with Turbo Toast no session
$ curl -sI https://app.example/
HTTP/1.1 200 OK
# Set-Cookie: PHPSESSID — never set
Cache-Control: public, max-age=3600
# 
# cacheable — your CDN serves it

↳ Doesn't deferToast() force private too? Only on the redirect itself, and shared caches don't store a 302 by default anyway. The real difference is scope: addFlash() plants a persistent PHPSESSID that keeps the landing page, and every page after it, private as well. The turbo_toast cookie is single-use: read on the next load, then deleted. Since the toast is injected client-side instead of being baked into the HTML, the page a cache stores is the same for every user and stays public.

Two transports, one API

Pick the transport that fits the request.

An AJAX or Turbo flow carries the toast in the stream it already returns, while a classic full-page redirect hands it off through a tiny cookie. The helper you call follows the response you return.

AJAX / Turbo flow

Over a Turbo Stream

The toast is appended to the <turbo-stream> response the action already returns. No redirect, URL unchanged.

// one line — returns a Turbo Stream response
return $this->toast('Task added');

// several at once, with types & a per-toast delay
return $this->toasts(
    new Toast('Task deleted', 'warning'),
    new Toast('Undo is not implemented', 'info', 8000),
);
Full-page redirect

Over a cookie

A short-lived, non-session cookie carries the toast across the redirect, then clears itself before rendering, so a back/forward navigation won't replay it.

// queue it, then redirect however you like
$this->deferToast('Profile saved');

return $this->redirectToRoute('app_home');

// the redirect is forced Cache-Control: private,
// so the cookie never lands in a shared cache.

That's the whole bundle. Here's when to reach for each helper, and when Symfony's built-in addFlash() is still the right call:

Reach for which?
toast() · toasts()
Turbo Stream

An AJAX or Turbo request already returning a stream: the toast joins it, with no navigation.

no session stays public ✓
deferToast()
Cookie

A full-page redirect where you want to stay session-free: cached site, stateless firewall, no shared session store.

no session stays public ✓
addFlash()
Session · Symfony default

A full-page redirect when a session already exists anyway. Simpler, server-rendered, no JS.

starts a session forced private ✗
Hardened by default

Built to stay harmless, even when the cookie is forged.

The cookie is unsigned on purpose, so anyone can edit it in DevTools. The bundle makes sure a toast still can't run markup, and that none announces success on a request that failed.

A11y

Screen-reader aware

Error toasts get role="alert" to interrupt; the rest use role="status" inside an aria-live container.

</>

XSS-inert rendering

Messages land through textContent only, and the type is filtered to [\w-]. Forged markup renders as plain text.

KB

Cookie-size budget

Browsers truncate or reject an oversized Set-Cookie, so past a cautious ~3.8 KB the trailing toasts are dropped until the cookie fits. A single toast over budget sets no cookie at all.

5xx

Discards on failure

If the request ends in a 5xx, queued toasts are drained and dropped, so no “Saved!” shows up after a crash.

!

Non-Turbo guard

Calling toast() outside a Turbo request throws an actionable LogicException pointing you at deferToast().

log

Its own log channel

Dropped and discarded toasts are recorded on a turbo_toast Monolog channel, visible in the profiler.

Live proof

Forge a malicious cookie. Watch it render as text.

Here's what an edited turbo_toast cookie looks like. This button writes exactly this payload, then renders it the way the bundle does:

[{ "message": "<img src=x onerror=alert(1)> forged!", "type": "success" }]

Because the container uses textContent, the <img> never executes: no alert, just inert characters. Try it:

Observability

Every toast, traced in the profiler.

In dev, a Turbo Toast panel counts both transports separately. A discarded toast turns the toolbar red, so you notice when one silently didn't ship.

Turbo Toast 3 rendered 1 queued 1 discarded

Rendered as Turbo Streams

toast('Task added')✓
toasts(warning, info)✓ ✓

Deferred through the cookie

queued2
transported1
discarded (5xx / oversized)1
Run the demo

Clone it and check the cookies yourself.

The demo pulls the bundle from a sibling checkout through a Composer path repository, so both clones have to sit side by side.

# the demo and the bundle live side by side
$ git clone …/turbo-toast-demo turbo-toast-demo
$ git clone …/turbo-toast-bundle TurboToastBundle

$ cd turbo-toast-demo
$ composer install
$ php bin/console asset-map:compile
$ php -S localhost:8000 -t public

# …or the whole thing containerised
$ docker compose up --build

What to look for

Open DevTools → Application → Cookies and click through any flow: no PHPSESSID ever shows up. The only cookie you'll see is the short-lived turbo_toast on a redirect, and it's gone as soon as the toast renders.