marilenarm/turbo-toast-bundle · v0.2.0

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.

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

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
The session tax

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.

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, 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.

Two transports, one API

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.

AJAX / Turbo flow

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),
);
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.

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:

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

An AJAX or Turbo request already returning a stream. The toast rides along, 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

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.

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

Over a conservative ~3.8 KB, no cookie is set at all — a missing toast beats a truncated Set-Cookie.

5xx

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().

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.

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:

[{ "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 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.