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.
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
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.
$ 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
$ 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.
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.
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), );
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:
An AJAX or Turbo request already returning a stream: the toast joins it, with no navigation.
A full-page redirect where you want to stay session-free: cached site, stateless firewall, no shared session store.
A full-page redirect when a session already exists anyway. Simpler, server-rendered, no JS.
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.
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.
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.
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().
Its own log channel
Dropped and discarded toasts are recorded on a turbo_toast Monolog channel, visible in the profiler.
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:
Because the container uses textContent, the <img> never executes: no alert, just inert characters. Try it:
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.
Rendered as Turbo Streams
Deferred through the cookie
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.