Session and Flash Messages
Store per-user data across requests, show a message that survives exactly one redirect, and keep the session out of cache duty.
$request->getSession()->getFlashBag()->add(string $type, mixed $message): voidHTTP forgets. Each request arrives with no memory of the last one. A session is server-side storage keyed to a cookie the browser sends back, which lets one visitor's data survive from request to request.
Get it from the request with $request->getSession(), or declare a SessionInterface argument on the controller. Then set(), get() with a default, has(), remove(), all() and clear(). Sessions must be enabled under framework.session in config/packages/framework.yaml, which the standard skeleton does for you.
Flash messages
A flash is a message you store on one request and display on the next. It exists for the redirect-after-post pattern: handle the form, redirect, and the fresh page shows "Saved". The flash bag removes a message as soon as it is read, so it appears once.
$this->addFlash('success', 'Theme saved.') is shorthand for $request->getSession()->getFlashBag()->add('success', 'Theme saved.'). They are the same call. In Twig, app.flashes('success') reads and clears; app.session.flashbag.peek('success') reads without clearing.
A session is not a cache
Session data is per visitor, and by default it is one serialised blob loaded and written on every request that touches it. Putting a shared lookup table or a rendered fragment there multiplies it by your user count and never expires it. Touching the session also marks the response private, so HTTP caches stop serving the page. Caches belong in the cache component.
Example
// src/Controller/PreferenceController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class PreferenceController extends AbstractController
{
#[Route('/preferences/theme', name: 'app_preference_save', methods: ['POST'])]
public function save(Request $request): Response
{
$session = $request->getSession();
$theme = $request->getPayload()->getString('theme', 'light');
// Stays until the session ends or you remove it.
$session->set('theme', $theme);
// Shorthand for the commented line below it. Same flash bag, same result.
$this->addFlash('success', 'Theme saved.');
// $session->getFlashBag()->add('success', 'Theme saved.');
// Redirect after a write, so a browser reload does not resubmit.
return $this->redirectToRoute('app_preference_show');
}
#[Route('/preferences', name: 'app_preference_show', methods: ['GET'])]
public function show(Request $request): Response
{
$theme = $request->getSession()->get('theme', 'light');
return new Response(
sprintf('Theme: %s', $theme),
Response::HTTP_OK,
['Content-Type' => 'text/plain; charset=utf-8'],
);
}
}When to use it
- Confirm a saved form on the page you redirect to, without a query parameter.
- Keep a multi-step form's partial answers between requests.
- Remember a visitor's chosen filter or theme without a login.
- Warn a user who landed on step three of a flow they never started.
More examples
Printing flashes in a template
Put this in the base layout so every page can show a flash, which is what makes the clear-on-read behaviour safe. The message is printed with normal escaping; a flash often contains a name or a title that came from a form, so |raw here would turn a saved preference into a stored XSS. Use app.session.flashbag.peek() instead if a page must display a flash without consuming it.
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<body>
{# app.flashes reads the bag and empties it, so each message shows once. #}
{% for label, messages in app.flashes(['success', 'error']) %}
{% for message in messages %}
<p data-flash="{{ label }}">{{ message }}</p>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %}
</body>
</html>The session as a controller argument
Symfony resolves a SessionInterface argument the same way it resolves a Request, so you can ask for the session directly. POST /wizard/step-1 returns 302 to /wizard/step-2; arriving at step two cold returns 302 back to step one with a flash. The typed const (PHP 8.3 and later) keeps the key in one place, because a typo in a session key fails silently.
// src/Controller/WizardController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Attribute\Route;
final class WizardController extends AbstractController
{
private const string KEY = 'wizard.step1';
#[Route('/wizard/step-1', name: 'app_wizard_step1', methods: ['POST'])]
public function step1(Request $request, SessionInterface $session): Response
{
// Half-finished input, kept until the last step stores it for real.
$session->set(self::KEY, [
'company' => $request->getPayload()->getString('company'),
]);
return $this->redirectToRoute('app_wizard_step2');
}
#[Route('/wizard/step-2', name: 'app_wizard_step2', methods: ['GET'])]
public function step2(SessionInterface $session): Response
{
if (!$session->has(self::KEY)) {
$this->addFlash('error', 'Start at step one.');
return $this->redirectToRoute('app_wizard_step1');
}
// ... render step two, then remove(self::KEY) once the wizard completes
return new Response('Step 2', Response::HTTP_OK, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
}Session settings worth knowing
Shown because two of these decide whether sessions work at all behind a proxy or across subdomains, and people usually meet them during an outage. Note the pair of lifetimes: the cookie can outlive the server-side data or the reverse, and a user logged out for no apparent reason is almost always gc_maxlifetime expiring under a long cookie_lifetime.
# config/packages/framework.yaml
framework:
session:
# Only send the cookie over HTTPS when the request itself is HTTPS.
cookie_secure: auto
# Blocks the cookie on cross-site requests, which stops most CSRF replay.
cookie_samesite: lax
# Seconds of inactivity before the session may be garbage collected.
gc_maxlifetime: 7200
# Seconds the cookie lives in the browser; 0 means "until it closes".
cookie_lifetime: 0
Discussion