Symfony 7 bundle · PHP 8.3+ · MIT

Flash messages that keep your pages
cacheable.

$this->addFlash() quietly opens the PHP session — and a page that touches the session can never be stored by Varnish or your CDN. This bundle takes the toast out of the session, so anonymous pages stay fast, parallel, and fully cacheable.

$composer require marilenarm/turbo-toast-bundle

The cost of a flash

One flash message.
Zero cache hits.

The session lock is paid once per request — whatever opens the session. On an otherwise stateless page, a flash message is often the only thing that opens it.

Symfony reacts by marking the response private, so Varnish or your CDN drops it. The session lock serializes concurrent requests, so your lazy Turbo Frames stop loading in parallel. And a session cookie gets created just to say “Item saved.”

Take the flash out of the session and the page needs no session at all. Nothing forces it uncacheable anymore.

Cacheable

Full-page cache, intact

The message travels next to the page, not inside it — carried by a stream or a cookie. Varnish and your CDN keep serving.

Parallel

Frames stop serializing

With no session lock, concurrent requests stop queuing behind each other — several lazy Turbo Frames can finally load at once.

Stateless

Stateless stays stateless

No session cookie is created just to show a notification, and that was the last thing still opening one.

Two transports, no session

The toast leaves the session by one of two doors.

Which one depends only on what your controller returns — a Turbo Stream, or a full-page redirect.

Turbo Stream

Rendered in the same response

For Turbo and AJAX flows. The toast is generated and rendered inside the very response you return, appended to the DOM, then auto-dismissed by a small Stimulus controller. No redirect, no storage at all.

return $this->toast('Item saved');

The API

Two verbs. No magic.

Use the trait in any controller. The verb you pick mirrors the response you return — nothing is inferred behind your back.

You returnYou call
a Turbo Stream responsetoast()  /  toasts()
a RedirectResponse (or any full page)deferToast() before returning
Turbo flow — one toast, one line
use MarilenaRM\TurboToastBundle\Controller\TurboToastTrait;

final class ItemController extends AbstractController
{
    use TurboToastTrait;

    #[Route('/items', methods: ['POST'])]
    public function create(): Response
    {
        // … persist
        return $this->toast('Item saved');
        // or: ->toast('Oops', 'error');
        // or: ->toast('Coming soon', 'info', delay: 8000);
    }
}
Redirect flow — deferred over a cookie
#[Route('/login', methods: ['POST'])]
public function login(): Response
{
    // … authenticate
    $this->deferToast('Welcome back!');

    return $this->redirectToRoute('dashboard');
}

// Several at once, any transport:
$this->toasts(
    new Toast('Profile updated'),
    new Toast('Email sent', 'info'),
);
▸ A taste — same CSS, same role, dismissed by a click anywhere or by waiting, exactly like the bundle. The runnable demo app ↗ exercises every flow — redirects, custom hooks, profiler:

Be honest with your profiler

This is a scalpel, not a default.

It only helps where the flash was the last thing forcing a session. Everywhere else, keep addFlash() if you like it.

✓ Where it shines

  • Anonymous, cacheable pages behind a CDN — catalogs, content sites.
  • Stateless forms using stateless CSRF.
  • Lazy-frame-heavy pages that shouldn’t serialize on a lock.
  • Anywhere a flash was the only reason a session still opened.

✗ Where it gains you nothing

  • Authenticated pages — the firewall loads the token from the session anyway.
  • Classic session-based CSRF protection.
  • Locale, cart, or anything else already living in the session.
  • The session opens regardless, so removing flashes buys you nothing.

Profile first (in Blackfire, look for session_start and serialized concurrent requests), then decide.

In the box

Built like production infrastructure.

📊

Profiler panel

Every toast emitted per request, per transport, with queued / transported / discarded counts. Zero overhead outside debug.

Accessible

The aria-live="polite" container survives Turbo Drive. Error toasts render role="alert" (interrupting); every other type gets role="status".

🛡️

XSS-safe by default

Cookie text is rendered with textContent only and treated as untrusted — no markup ever executes.

🧩

Composable streams

Include the toast partial in your own *.stream.html.twig — append a row and notify in one response.

⚙️

No config drift

DOM id, cookie name and Stimulus identifiers are injected from PHP into the markup, so YAML and JS can’t diverge.

🎨

Own the rendering

Swap the template target, or cancel the append event to hand rendering to your own toast library.

🚫

5xx-safe cookie

A request that ended in a server error discards its queued toasts — no false “success” on the next page.

Tested & hardened

PHPUnit + Vitest suites, CI on PHP 8.3 & 8.4, and a documented threat model behind every protection.

Get started

Four steps
to no session.

Symfony Flex registers the bundle for you. The rest is one Twig call and, if you like, one CSS import.

Require the package

composer require marilenarm/turbo-toast-bundle

Register the bundle

Automatic with Flex. Otherwise add it to config/bundles.php.

Drop the container in your layout

{# templates/base.html.twig, inside <body> #}
{{ turbo_toast_container() }}

Import the styles (optional)

/* assets/styles/app.css */
@import '~@marilenarm/turbo-toast/styles/toast.css';
Configuration — every value has a sane default
# config/packages/marilena_rm_turbo_toast.yaml
marilena_rm_turbo_toast:
    target: toasts            # DOM id of the container
    default_delay: 5000       # auto-dismiss (ms), 0 to disable
    cookie_name: turbo_toast   # cookie used by deferToast()
    controller_name: marilenarm/turbo-toast/toast
    stream_template: '@MarilenaRMTurboToast/toast.stream.html.twig'