Flash messages,
minus the session.
Symfony's addFlash() quietly starts a session (a lock, a
PHPSESSID cookie, 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. Every toast on this page is the real bundle running. Click something.
Tasks
Add a task, or delete any row. Both fire the real toasts.
- Write the release notes
- Review the pull request
- Deploy the docs 🔒 locked
One flash message, and your page stops being cacheable.
A session isn't free. The moment one starts, Symfony marks the response
private and drops a
PHPSESSID cookie, so no shared cache,
CDN, or reverse proxy will ever serve that page. 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,
and a 302 is never stored by a shared cache anyway. The real difference is scope:
addFlash() plants a persistent
PHPSESSID that keeps the landing page
(and every page after it) private too, while
turbo_toast is a single-use cookie, consumed on the next load and gone. The toast rides that cookie and
is injected client-side, never baked into the HTML, so the page a cache actually stores is identical 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's already returning. A classic full-page redirect hands it off through a tiny cookie. Same helpers either way.
Over a Turbo Stream
The toast rides 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.
The two cards above are the bundle. Here's when to reach for each, and when Symfony's built-in addFlash() is still the right call:
An AJAX or Turbo request already returning a stream. The toast rides along, 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.
A toast never lies, and never executes.
The cookie is client-modifiable by design, and a notification must never become an attack surface or a false promise. The bundle handles the edges for you.
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
Over a conservative ~3.8 KB, no cookie is set at all — a missing toast beats a truncated Set-Cookie.
Discards on failure
If the request crashes with a 500, queued toasts are drained and dropped. Nothing claims success on a request that failed.
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.
The turbo_toast cookie is deliberately not signed, so anyone can rewrite it in DevTools.
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 see for yourself.
The bundle installs from a sibling checkout via a Composer path repository.
Then open DevTools and confirm the promise: no PHPSESSID, on any flow.
# 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
Then verify the pitch
Open DevTools → Application → Cookies. Exercise any flow on the demo.
No PHPSESSID ever appears. The only cookie you'll see is the
short-lived turbo_toast on a redirect, gone as soon as the toast renders.