Skip to content

Sessions & Flash — handlers, storage, fixation, stateless APIs

TL;DR — Symfony abstrait les sessions PHP via SessionInterface + SessionStorageInterface + SessionHandlerInterface. Tu peux changer le storage (file, Redis, PDO) sans toucher le code. Les flash messages sont un cas particulier de session (one-shot, auto-cleared après lecture). Pour les APIs stateless (JWT, OAuth tokens), désactive totalement la session via framework.stateless: true. La session fixation est mitigée auto par Symfony Security (migrate sur login), mais sache pourquoi.

🧠 Mental model — ASCII diagram + analogie

   Request                                                     Response
      │                                                            ▲
      ▼                                                            │
   ┌────────────────────────────────────────────────────────────┐
   │ SessionListener (kernel.request)                            │
   │   - Attach Session to Request via factory (LAZY)            │
   │   - No cookie read until $session->get() is called          │
   └────────────────────────────────────────────────────────────┘

      ▼ controller calls $request->getSession()
   ┌────────────────────────────────────────────────────────────┐
   │ Session(SessionStorageInterface $storage)                   │
   │   ├─ NativeSessionStorage     (PHP native, files)           │
   │   ├─ PhpBridgeSessionStorage  (use PHP's session_start)     │
   │   ├─ MockArraySessionStorage  (tests, in-memory)            │
   │   └─ MockFileSessionStorage   (tests, file-based)           │
   │                                                             │
   │   uses SessionHandlerInterface (where data is persisted):   │
   │   ├─ StrictSessionHandler  (default, files)                 │
   │   ├─ RedisSessionHandler                                    │
   │   ├─ PdoSessionHandler                                      │
   │   └─ Custom (Memcached, DynamoDB, etc.)                     │
   │                                                             │
   │   Bags:                                                     │
   │   ├─ AttributeBag                                           │
   │   ├─ FlashBag                                               │
   │   └─ MetadataBag (csrf, last used, etc.)                    │
   └────────────────────────────────────────────────────────────┘

      ▼ end of request
   AbstractSessionListener (kernel.response)
      - $session->save()
      - Set-Cookie header if session was started

Analogie : Session = casier numéroté à la consigne d'une gare. Le client a une clé (cookie session ID), le serveur a les casiers (storage). Le contenu du casier (data) est sérialisé/désérialisé à chaque requête. Le flash = un post-it dans le casier qui s'auto-détruit dès qu'on le lit.

Comment un staff engineer raisonne sur la session

Trois invariants à garder en tête, parce qu'ils expliquent 90 % des bugs de session en prod :

  1. La session est un état serveur partagé indexé par un cookie. Tout ce qui touche au cookie touche au cache HTTP, au CSRF, au CORS, au RGPD. Une session démarrée = une réponse non cacheable par un CDN (le Set-Cookie + Cache-Control: private que Symfony ajoute). C'est pourquoi le lazy n'est pas un détail de perf : c'est la frontière entre « cette page est servie par le CDN » et « cette page tape ton PHP à chaque hit ».

  2. Le verrou de session sérialise les requêtes concurrentes d'un même utilisateur. PHP pose un write lock exclusif sur l'entrée de session pendant toute la durée de la requête (du start() au save()). Deux requêtes du même navigateur (ex. une page + 4 appels XHR en parallèle) se mettent en file d'attente. Symptôme classique : une page qui lance des fetch parallèles devient mystérieusement séquentielle. La parade : session.write_close() tôt (Symfony : $session->save()) dès que tu n'écris plus, ou marquer les endpoints read-only _stateless: true.

  3. Ce qui entre en session doit en ressortir identique après (dé)sérialisation. Le storage fait serialize()/unserialize(). Une entité Doctrine, une Closure, une ressource (PDO, stream) ne survivent pas — au mieux un proxy détaché qui explose au premier accès lazy, au pire un __PHP_Incomplete_Class. Règle : scalars et DTOs readonly only.

kernel.request   SessionListener pose une Session LAZY sur la Request.
                 Aucun cookie lu. Aucun lock posé. La réponse reste cacheable.


controller       $request->getSession()->get('x')
                 → 1er accès → storage->start()
                 → lecture du cookie APPSESSID, acquisition du LOCK, unserialize


kernel.response  AbstractSessionListener::onKernelResponse
                 SI la session a été démarrée :
                   - $session->save()  → serialize + write + release lock
                   - Set-Cookie: APPSESSID=… (si nouvel ID ou migrate)
                   - Cache-Control: private, must-revalidate   ← tue le cache CDN
                 SINON : rien. Réponse cacheable intacte.

Le point qui surprend : lire la session suffit à la « démarrer » et donc à dégrader la cacheabilité. app.flashes dans un layout Twig partagé est le coupable n°1 d'un site « pourquoi mon CDN ne cache plus rien ». Solution : ne rendre le bloc flash que sur les pages authentifiées, ou via un ESI/fragment non caché.

🛠️ Code minimal — session redis + flash + stateless

yaml
# config/packages/framework.yaml
framework:
    session:
        # handler_id pointe vers un SERVICE (pas une classe nue) — voir services.yaml.
        # En pratique on passe un DSN, c'est le plus simple :
        handler_id: '%env(REDIS_DSN)%'   # ex: redis://:pass@cache:6379/0
        cookie_secure: auto      # secure cookie si HTTPS (détecté via X-Forwarded-Proto si trusted proxy)
        cookie_samesite: lax     # ou 'strict' pour anti-CSRF agressif
        cookie_httponly: true    # JS ne peut pas lire le cookie (anti-XSS-vol-de-session)
        cookie_lifetime: 0        # 0 = session cookie (détruit à la fermeture du navigateur)
        gc_maxlifetime: 1800      # 30 min idle expire (côté serveur — indépendant du cookie)
        name: APPSESSID           # nom du cookie ; évite le PHPSESSID par défaut (fingerprinting)
        # Pour forcer un storage mock en test :
        # storage_factory_id: session.storage.factory.mock_file

handler_id accepte trois formes, à ne pas confondre :

ValeurEffet
null (ou absent)Handler PHP natif par défaut (php.ini session.save_handler) — fichiers.
Un DSN (redis://…, pdo+pgsql://…, memcached://…)Symfony instancie le bon handler automatiquement. À privilégier.
Un id de service (ex. app.session.handler)Tu fournis ton propre handler. À utiliser pour wrapping (Marshalling, custom).

Erreur classique : mettre la classe RedisSessionHandler comme handler_id. Ce n'est pas un service auto-instanciable (son constructeur exige un \Redis déjà connecté). Si tu veux le handler explicite plutôt qu'un DSN, déclare le service toi-même :

yaml
# config/services.yaml
services:
    # Le bundle snc/redis ou un factory propre — PAS de "calls: connect" fragile
    # (un échec de connect au boot du conteneur casse TOUTE l'app, même les routes
    #  qui n'utilisent pas la session). Préfère un factory paresseux.
    app.redis:
        class: Redis
        factory: ['App\Cache\RedisFactory', 'create']
        arguments: ['%env(REDIS_DSN)%']

    Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler:
        arguments:
            $redis: '@app.redis'
            $options:
                prefix: 'sess_'
                ttl: 1800       # le TTL Redis prend le relais du GC PHP — pas de cron nécessaire
php
// src/Cache/RedisFactory.php — connexion lazy + lecture du DSN
final class RedisFactory
{
    public static function create(string $dsn): \Redis
    {
        // RedisAdapter sait parser un DSN complet (auth, db, tls, sentinel…)
        return \Symfony\Component\Cache\Adapter\RedisAdapter::createConnection($dsn);
    }
}
php
// src/Controller/AuthController.php
use Symfony\Component\HttpFoundation\RequestStack;

#[Route('/login', name: 'login', methods: ['GET', 'POST'])]
public function login(Request $request, RequestStack $stack): Response
{
    // Session lazy : pas encore touchée
    if ($request->isMethod('POST')) {
        // Auth handled by firewall, but here's manual flash:
        $request->getSession()->getFlashBag()->add('success', 'Welcome back!');
        return $this->redirectToRoute('home');
    }

    return $this->render('auth/login.html.twig');
}

#[Route('/profile', name: 'profile')]
public function profile(Request $request): Response
{
    $session = $request->getSession();
    $session->set('last_profile_view', new \DateTimeImmutable());

    // Equivalent en service via RequestStack
    // $session = $this->requestStack->getSession();

    return $this->render('profile.html.twig', [
        'lastView' => $session->get('last_profile_view'),
    ]);
}

Flash dans Twig :

twig
{# Iterate all flash types #}
{% for label, messages in app.flashes %}
    {% for msg in messages %}
        <div class="alert alert-{{ label }}">{{ msg|trans }}</div>
    {% endfor %}
{% endfor %}

{# Or specific type #}
{% for msg in app.flashes('success') %}
    <div class="toast toast-success">{{ msg }}</div>
{% endfor %}

Stateless API config :

yaml
# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true       # No session at all on /api
            jwt: ~                # Or other token auth
        main:
            pattern: ^/
            lazy: true            # Session only if user touched
            form_login: ~
            logout: ~
yaml
# Route-level stateless flag
api_endpoint:
    path: /api/products
    defaults:
        _stateless: true

Migration explicite (anti-fixation) :

php
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

#[AsEventListener(event: 'security.interactive_login')]
public function onLogin(InteractiveLoginEvent $event): void
{
    $session = $event->getRequest()->getSession();
    $session->migrate(true); // regenerate ID + destroy old (auto fait par Symfony, mais explicite)
}

🎯 Patterns courants

  1. Storage Redis pour scalabilité horizontale — plusieurs serveurs PHP partagent le même Redis → sticky sessions inutiles. Default file-based ne scale pas en cluster.
  2. stateless: true sur firewall API — pas de session générée, pas de Set-Cookie. Indispensable pour APIs publiques (CORS, mobile clients).
  3. Lazy sessionlazy: true sur firewall → session démarrée uniquement si tu fais getSession(). Évite Set-Cookie inutile sur pages cacheables (CDN-friendly).
  4. Flash messages typedsuccess, error, warning, info par convention. Map en CSS classes Bootstrap/Tailwind.
  5. PdoSessionHandler — store sessions en DB (table dédiée). Idéal si tu veux query "qui est en ligne ?" ou audit cross-server. Plus lent que Redis.
  6. Session metadata$session->getMetadataBag()->getCreated(), getLastUsed() pour détection inactivité, logout auto, etc.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

  • 5.4 : SessionInterface deprecated direct injection → use RequestStack::getSession(). Request::getSession() toujours OK.
  • 6.0 : Session n'est plus un service global. Tu dois passer par RequestStack. framework.session.storage_factory_id remplace l'ancien storage_id direct.
  • 6.1 : framework.session.cookie_samesite: strict|lax|none officialisé. Defaults lax plus sécurisé.
  • 6.2 : amélioration RedisSessionHandler avec Sentinel/Cluster. MarshallingSessionHandler pour chiffrer at-rest.
  • 6.3 : IdentityTranslator pour flash messages plus performant. Removal de plusieurs deprecated session methods.
  • 6.4 LTS : framework.session.cookie_secure: auto par défaut. Cookies SameSite=Lax par défaut. Configuration via attribute #[Stateless] arrive.
  • 7.0 : suppression SessionInterface deprecation → throw si tu l'utilises encore. Suppression NativeSessionHandler legacy. Use Storage abstractions only.
  • 7.1+ : amélioration de la sérialisation des bags (perf), support natif MarshallingSessionHandler pour compression + encryption.

⚠️ Pitfalls

  1. Session démarrée trop tôt — un middleware/listener qui touche getSession() sans raison → cookie envoyé sur des pages publiques → CDN ne cache plus. Toujours lazy: true.
  2. session_start() natif — bypass Symfony, double-start = warning. Ne JAMAIS appeler session_start() direct.
  3. Données non-sérialisables — stocker une Doctrine entity en session = problème (proxies, lazy load après détachement). Toujours stocker des scalars ou DTOs simples.
  4. Session fixation — sans migrate(true) au login, un attaquant peut imposer un session ID. Symfony Security fait le migrate auto, mais si tu auth manuellement, tu dois le faire.
  5. Cookie samesite=none sans secure — bloqué par les browsers modernes. Toujours secure: auto + samesite: lax minimum.
  6. GC PHP sessions natifs aléatoiressession.gc_probability par défaut 1/1000. Sessions expirées peuvent traîner. En prod, faire un cron : php bin/console session:gc ou Redis TTL auto.
  7. Flash messages perdus après redirect — le flash est consommé à la lecture. Si ton template Twig l'affiche, puis tu refresh (autre requête), il est perdu. Normal mais source de bugs UI.
  8. Worker mode + session globale — en FrankenPHP/RoadRunner, $_SESSION superglobal peut leaker entre requêtes si tu y accèdes direct. Toujours passer par RequestStack::getSession().

🧪 Testing

php
// tests/Functional/FlashMessageTest.php
<?php
namespace App\Tests\Functional;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class FlashMessageTest extends WebTestCase
{
    public function testFlashIsDisplayedAfterRedirect(): void
    {
        $client = static::createClient();
        $client->followRedirects();

        $crawler = $client->request('POST', '/login', [
            '_username' => '[email protected]',
            '_password' => 'password',
        ]);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('.alert-success', 'Welcome back');
    }

    public function testSessionDataPersists(): void
    {
        $client = static::createClient();
        $client->request('GET', '/profile');
        // Cookie session ID stored client-side automatically

        $client->request('GET', '/profile');
        // Second request reuses session
        self::assertResponseIsSuccessful();
    }
}

Test stateless API :

php
public function testApiHasNoSessionCookie(): void
{
    $client = static::createClient();
    $client->request('GET', '/api/products');

    self::assertResponseIsSuccessful();
    // Stateless firewall → no Set-Cookie
    self::assertNull($client->getResponse()->headers->get('Set-Cookie'));
}

Test session manually in service :

php
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;

$session = new Session(new MockArraySessionStorage());
$session->start();
$session->getFlashBag()->add('info', 'Hello');
$messages = $session->getFlashBag()->get('info');
self::assertSame(['Hello'], $messages);
self::assertEmpty($session->getFlashBag()->get('info')); // consumed

Debug CLI :

bash
php bin/console debug:config framework session
php bin/console debug:container session

🎬 Cas d'usage concrets

Scénario 1 — Cabinet juridique : portail client avec session longue + 2FA step-up

Contexte : un cabinet d'avocats fiscaliste donne accès à ses clients (grandes entreprises CAC 40) à un portail sécurisé où ils consultent dossiers, factures, échanges chiffrés avec leur avocat. La session doit : (1) durer 8h (journée de travail), (2) survivre à un changement d'IP (mobile + bureau), (3) demander une re-auth 2FA pour les actions sensibles (signature, virement provision), (4) être révocable instantanément par un admin si compromission.

Session handler : Redis avec namespace legal_session:{client_org}:{sessionId}. TTL 8h glissant. Un attribut auth_strength est stocké en session (weak après mot de passe, strong après 2FA). Un voter IsStronglyAuthenticated vérifie ce niveau pour les actions sensibles ; sinon, redirect vers /auth/step-up qui demande TOTP, et upgrade auth_strength=strong pour 15 min seulement. Un endpoint admin /admin/sessions/{userId}/kill fait redis del legal_session:*:{userId}:* — déconnexion immédiate.

Bénéfice : conformité ANSSI niveau "renforcé", aucune compromission durable possible (révocation instantanée), et UX préservée car la 2FA ne se déclenche qu'aux moments critiques.

Scénario 2 — E-commerce mode (Sézane-like) : panier persistant cross-device

Contexte : 60% des clientes Sézane mettent un produit au panier sur mobile puis finalisent sur desktop le soir. Une "cart" purement session-based perd les items au changement d'appareil. La solution : panier persistant en base, lié au customer ID si logguée, à un cookie anonyme _cart_token sinon.

Architecture : un service CartManager lit depuis Redis (cache) avec fallback Postgres (source de vérité). Si user logguée → clé cart:user:{customerId}. Si anonyme → clé cart:anon:{cartToken} (cookie HttpOnly, 30 jours). Lors du login d'une anonyme, merge des deux paniers (le nouveau "écrase" mais log les conflicts pour analytics). Les flash messages ("Article ajouté", "Promo appliquée") sont stockés en session classique car éphémères et liés à la page.

Pour les anonymes mobiles qui passent en desktop, un QR code "scanner mon panier" génère un token éphémère (5 min, single-use) qui lie le cookie mobile au cookie desktop. Mesure : +12% de conversion sur le quarter qui a suivi la mise en place.

Scénario 3 — Gestion locative (Foncia-like) : portail syndic avec session par bâtiment

Contexte : un syndic gère 1 200 copropriétés. Un gestionnaire jongle entre 80 bâtiments dans sa journée. La session doit : (1) mémoriser le bâtiment actif (sinon il faut le re-sélectionner à chaque page), (2) cacher les données fréquemment lues (liste copropriétaires) en session pour économiser des queries, (3) gérer une mini-bascule "imitate user" pour le support qui se met dans la peau d'un copropriétaire pour debug.

Session : current_building_id, recent_buildings (LIFO de 10), imitating_user_id. Quand le gestionnaire clique sur un bâtiment, le BuildingContextSubscriber (sur kernel.request) charge le bâtiment, vérifie les droits, set un service BuildingContext request-scoped. Le mode "imitate" est protégé par flag ROLE_SUPPORT_AGENT, audit-logué intégralement (qui a impersonné qui, durée, actions effectuées).

Les flashs sont utilisés pour les opérations CRUD ("Charges trimestrielles publiées", "Procès-verbal AG enregistré") avec types success, warning, error rendus avec icônes différentes dans le thème.

🛠️ Exemple end-to-end

Use case : portail copropriétaires d'un syndic — switch entre plusieurs bâtiments, panier d'opérations en cours (devis à valider, votes AG en attente), flash messages, et système de "step-up" pour actions sensibles (vote pondéré, mandat).

php
// src/Property/Context/BuildingContext.php
<?php
declare(strict_types=1);

namespace App\Property\Context;

use App\Property\Entity\Building;

enum SessionStrength: string
{
    case Anonymous = 'anonymous';
    case Authenticated = 'authenticated';
    case StepUp = 'step_up';
}

final class BuildingContext
{
    private ?Building $current = null;

    public function set(Building $building): void
    {
        $this->current = $building;
    }

    public function current(): ?Building
    {
        return $this->current;
    }

    public function requireCurrent(): Building
    {
        if ($this->current === null) {
            throw new \LogicException('No building selected in current session');
        }

        return $this->current;
    }
}
php
// src/Property/EventSubscriber/BuildingContextSubscriber.php
<?php
declare(strict_types=1);

namespace App\Property\EventSubscriber;

use App\Property\Context\BuildingContext;
use App\Property\Repository\BuildingRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final readonly class BuildingContextSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private BuildingContext $context,
        private BuildingRepository $buildings,
        private Security $security,
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::REQUEST => ['onRequest', 4]];
    }

    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest() || !$event->getRequest()->hasSession()) {
            return;
        }
        $session = $event->getRequest()->getSession();
        $buildingId = $session->get('current_building_id');
        if ($buildingId === null) {
            return;
        }
        $building = $this->buildings->find($buildingId);
        if ($building === null) {
            return;
        }
        if (!$this->security->isGranted('VIEW', $building)) {
            $session->remove('current_building_id');
            return;
        }
        $this->context->set($building);
    }
}
php
// src/Controller/Syndic/BuildingSwitchController.php
<?php
declare(strict_types=1);

namespace App\Controller\Syndic;

use App\Property\Repository\BuildingRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_SYNDIC_AGENT')]
final class BuildingSwitchController extends AbstractController
{
    public function __construct(private readonly BuildingRepository $buildings) {}

    #[Route('/syndic/buildings/{id}/switch', name: 'syndic_building_switch', methods: ['POST'])]
    public function switch(string $id, Request $request): RedirectResponse
    {
        $building = $this->buildings->find($id);
        if ($building === null) {
            throw $this->createNotFoundException();
        }
        $this->denyAccessUnlessGranted('VIEW', $building);

        $session = $request->getSession();
        $session->set('current_building_id', $id);

        $recent = $session->get('recent_buildings', []);
        $recent = array_values(array_unique(array_merge([$id], $recent)));
        $session->set('recent_buildings', \array_slice($recent, 0, 10));

        $this->addFlash('success', "Bâtiment {$building->getName()} sélectionné.");

        return $this->redirectToRoute('syndic_dashboard');
    }
}
php
// src/Controller/Syndic/AgVoteController.php
<?php
declare(strict_types=1);

namespace App\Controller\Syndic;

use App\Property\Context\BuildingContext;
use App\Property\Service\AssemblyVoteRecorder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_COPROPRIETAIRE')]
final class AgVoteController extends AbstractController
{
    public function __construct(
        private readonly BuildingContext $context,
        private readonly AssemblyVoteRecorder $votes,
    ) {}

    #[Route('/ag/{resolutionId}/vote', name: 'ag_vote', methods: ['POST'])]
    public function vote(string $resolutionId, Request $request): RedirectResponse
    {
        $session = $request->getSession();
        $strength = $session->get('auth_strength');

        if ($strength !== 'step_up') {
            $session->set('step_up_redirect', $request->getUri());
            $this->addFlash('warning', 'Authentification renforcée requise pour voter.');

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

        $vote = $request->request->get('choice');
        if (!in_array($vote, ['pour', 'contre', 'abstention'], true)) {
            $this->addFlash('error', 'Choix de vote invalide.');
            return $this->redirectToRoute('ag_show', ['id' => $resolutionId]);
        }

        $this->votes->record(
            building: $this->context->requireCurrent(),
            resolutionId: $resolutionId,
            voter: $this->getUser(),
            choice: $vote,
        );
        $this->addFlash('success', 'Vote enregistré.');

        return $this->redirectToRoute('ag_show', ['id' => $resolutionId]);
    }
}
yaml
# config/packages/framework.yaml
framework:
    session:
        handler_id: '%env(REDIS_DSN)%'   # REDIS_DSN contient déjà le schéma redis://… (cf. exemple plus haut)
        cookie_secure: auto
        cookie_samesite: 'lax'
        cookie_httponly: true
        gc_maxlifetime: 28800   # 8h
        name: 'SYNDICSESSID'

Le copropriétaire arrive sur le portail, sélectionne son bâtiment (stocké en session, validé sur chaque request via subscriber). Pour voter à une AG, le système exige une re-auth step_up (TOTP envoyé par SMS) — vérifié via auth_strength en session, valide 15 min. Tout est tracé : flash messages confirment chaque action, les votes sont auditables, et un kill-switch admin peut révoquer toute session compromise.


🏭 En production — perf, sécurité, observabilité, scale

Choix du storage : la matrice de décision

CritèreFile (default)RedisPDO (Postgres/MySQL)Memcached
Multi-serveur PHP❌ (sticky sessions obligatoires)
Latence lecture/écriture~ms (disque local)< 1 ms2–10 ms< 1 ms
TTL / GCcron session:gcnatif (EXPIRE)cron ou colonne lifetimenatif (LRU)
« Qui est en ligne ? » / auditdifficile (scan)✅ (table queryable)
Durabilité (survit reboot)❌ par défaut (RDB/AOF sinon)
Éviction sous pression mémoiren/aconfigurable (noeviction recommandé !)n/aLRU silencieuse → logout massif

Le piège Memcached/Redis en mode allkeys-lru : sous pression mémoire, le store évince des sessions actives → vague de déconnexions inexpliquées. Pour un store de sessions, configure Redis en maxmemory-policy noeviction (ou volatile-lru sur une instance dédiée aux clés à TTL) et dédie une instance distincte du cache applicatif. Mélanger cache et sessions sur la même instance = un flush de cache qui déconnecte tout le monde.

Locking & latence — le tueur de p99

Le write lock (point 2 du mental model) est invisible en dev (mono-utilisateur) et brutal en prod. Une page SPA qui lance 6 XHR avec session active : les 6 se sérialisent, ta p99 explose. Diagnostic et parades :

php
// Endpoint read-only : libère le lock immédiatement après lecture.
public function dashboard(Request $request): Response
{
    $session = $request->getSession();
    $userPrefs = $session->get('prefs', []);
    $session->save();           // ← write_close : relâche le lock MAINTENANT
    // ... reste du traitement long (DB, API externe) sans bloquer les requêtes sœurs
    return $this->render('dashboard.html.twig', ['prefs' => $userPrefs]);
}
  • Endpoints purement lecture (polling, suggestions) → #[Route(..., defaults: ['_stateless' => true])] : zéro lock.
  • RedisSessionHandler utilise un lock applicatif (SETNX + spin) ; règle son lock et le timeout pour éviter qu'un worker mort tienne le lock 30 s.

Sécurité — la checklist non-négociable

MenaceMitigation Symfony
Session fixationmigrate(true) au login. Auto par security (firewall) ; manuel si tu authentifies à la main.
Vol de cookie (XSS)cookie_httponly: true (JS ne lit pas le cookie).
Vol de cookie (MITM)cookie_secure: auto + HSTS. Jamais de session sur HTTP en prod.
CSRFcookie_samesite: lax (bloque les POST cross-site) + token CSRF sur les forms sensibles. lax ≠ protection complète (les GET cross-site passent).
Session hijack persistantMetadataBag : invalide si getLastUsed() trop vieux ; régénère l'ID périodiquement (migrate toutes les N minutes).
Données sensibles at-restMarshallingSessionHandler avec SodiumMarshaller → chiffrement de la payload dans Redis/DB.
Révocation immédiateclé Redis avec pattern par user (sess:user:{id}:*) → DEL côté admin = logout instantané (cf. scénario cabinet juridique).

Chiffrement at-rest des sessions (RGPD, données de santé/finance) :

yaml
# config/services.yaml
services:
    Symfony\Component\Cache\Marshaller\SodiumMarshaller:
        decorates: cache.default_marshaller
        arguments:
            - ['%env(base64:SESSION_ENCRYPTION_KEY)%']   # rotation : liste de clés, la 1re chiffre
            - '@.inner'

    Symfony\Component\HttpFoundation\Session\Storage\Handler\MarshallingSessionHandler:
        decorates: session.handler
        arguments: ['@.inner', '@Symfony\Component\Cache\Marshaller\SodiumMarshaller']

La liste de clés permet la rotation sans déconnexion : la première clé chiffre, toutes déchiffrent. Tu génères une clé via sodium_crypto_secretbox_keygen() encodée base64.

Observabilité — ce que tu veux voir en prod

  • Taille de payload de session : une session qui gonfle (panier entier, résultats de recherche cachés à tort) ralentit chaque requête (serialize+réseau Redis). Logge strlen(serialize($session->all())) en dev ; alerte si > quelques Ko.
  • Taux de création de sessions : un pic de Set-Cookie neuves = soit un bot, soit une page publique qui démarre la session par erreur (régression de lazy). Compte les sessions Redis (DBSIZE sur l'instance dédiée).
  • Lock contention : RedisSessionHandler expose des erreurs de lock ; instrumente un LoggerInterface autour du handler pour mesurer les attentes de lock (signal de p99 dégradée).
  • GC / TTL : sur file storage, surveille le volume du dossier sessions/. Sur Redis, le TTL fait le ménage ; vérifie qu'aucune clé n'est créée sans TTL (PERSIST accidentel).

Mode worker (FrankenPHP, RoadRunner, Swoole)

En long-running worker, le process PHP ne meurt pas entre les requêtes. Risques :

  • $_SESSION superglobal leak d'une requête à l'autre → toujours RequestStack::getSession(), jamais $_SESSION.
  • Un handler avec une connexion Redis persistante doit gérer la reconnexion (le socket peut mourir entre deux requêtes).
  • Le kernel.reset doit vider tout état request-scoped (ton BuildingContext de l'exemple est conçu pour ça : il est reset entre requêtes par le conteneur).

🔁 Quand utiliser / éviter

  • Session classique : apps web avec login, panier, multi-step forms, état UI. Évite pour APIs publiques (CORS, scaling).
  • Stateless + JWT/token : APIs mobiles, SPAs externes, microservices. Évite pour back-office (sessions plus simples, CSRF-protected).
  • Redis session handler : ≥ 2 serveurs PHP, ou besoin de TTL fin. Évite pour mono-serveur dev (file-based suffit).
  • Flash messages : feedback post-action (form submit, button click). Évite pour notifications persistantes (use DB + bell icon).
  • PdoSessionHandler : audit sessions, "online users" feature. Évite si Redis dispo (10× plus rapide).

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Monte un projet Symfony 7.x (symfony new --webapp) et garde Redis sous la main (docker run -p 6379:6379 redis).

1. Flash typé end-to-end (échauffement)

Objectif : afficher des flash success/warning/error qui survivent à un redirect, rendus avec une classe CSS par type. Indice/Solution : $this->addFlash('success', '...') dans le controller (POST → redirect), puis dans le layout Twig, boucle sur app.flashes pour générer une <div class="alert alert-..."> par type :

twig
{% for type, msgs in app.flashes %}
    {% for msg in msgs %}
        <div class="alert alert-{{ type }}">{{ msg }}</div>
    {% endfor %}
{% endfor %}

Vérifie qu'un second refresh n'affiche plus le message (consommé à la lecture).

2. Lazy session vs cacheabilité (mesure)

Objectif : prouver qu'une session démarrée tue le cache HTTP, et qu'un layout flash le déclenche par accident. Indice/Solution : crée une route publique /pricing. Inspecte les headers (curl -I) : pas de Set-Cookie, pas de Cache-Control: private. Ajoute le bloc app.flashes dans le layout partagé → re-curl → tu vois apparaître Set-Cookie + private. Corrige en n'incluant le bloc flash que sur les pages authentifiées (ou via un fragment). Conclusion à écrire : lire la session = la démarrer.

3. Step-up auth en session (production-grade)

Objectif : un voter qui exige auth_strength === 'step_up' (validité 15 min via timestamp en session) pour une action sensible, sinon redirect vers /auth/step-up. Indice/Solution : stocke ['level' => 'step_up', 'at' => time()]. Voter IsStronglyAuthenticated : refuse si absent ou time() - at > 900. Après TOTP validé, set le niveau + redirect vers step_up_redirect mémorisé. Test fonctionnel : action refusée sans step-up, acceptée après, re-refusée 16 min plus tard (mock l'horloge via ClockInterface).

4. Storage Redis + chiffrement at-rest + révocation (production-grade)

Objectif : sessions en Redis, payload chiffrée (SodiumMarshaller), et un endpoint admin qui déconnecte un user instantanément. Indice/Solution : handler_id = DSN Redis ; décore avec MarshallingSessionHandler + SodiumMarshaller. Pour la révocation, préfixe les clés par user (sess:user:{id}:) et expose DEL sur ce pattern. Vérifie dans redis-cli que GET sess_<id> renvoie du binaire illisible. Bonus : rotation de clé (2 clés dans la liste) sans déconnecter personne.

5. Casser : la contention de lock (break-then-fix)

Objectif : reproduire puis éliminer la sérialisation des requêtes due au write lock. Indice/Solution : un endpoint /slow qui $session->get(...) puis sleep(2). Lance 5 requêtes parallèles (xargs -P5 curl) → temps total ~10 s (sérialisé par le lock). Ajoute $session->save() juste après la lecture → temps total ~2 s. Mesure les deux. Variante : marque l'endpoint _stateless: true et compare.

6. Casser : entité Doctrine en session (break-then-fix)

Objectif : observer le __PHP_Incomplete_Class / proxy détaché, puis corriger via DTO. Indice/Solution : $session->set('user', $userEntity) puis, dans une requête suivante, $session->get('user')->getName() → explosion (proxy non hydraté / classe incomplète). Corrige : stocke un readonly class UserSnapshot (scalars only) ou juste l'id + re-fetch. Écris la règle : la session ne contient que des données auto-suffisantes.

🎤 En entretien

Q: Pourquoi lazy: true sur un firewall, et qu'est-ce que ça change concrètement sur le réseau ? R senior : Sans lazy, le firewall démarre la session à chaque requête pour vérifier l'authentification → Set-Cookie + Cache-Control: private systématique → le CDN ne cache plus rien. Avec lazy, la session ne démarre qu'au premier accès réel ; les pages publiques restent cacheables. C'est une décision d'archi de cache, pas un micro-optimisation.

Q: Tu lances 6 fetch parallèles depuis une SPA et la p99 explose. Pourquoi, et comment tu corriges ? R senior : PHP pose un write lock exclusif sur l'entrée de session pour toute la durée de la requête. Les 6 requêtes du même cookie se sérialisent au lieu de s'exécuter en parallèle. Fix : $session->save() (write_close) dès que l'écriture est finie sur chaque endpoint, ou marquer les endpoints read-only _stateless: true pour ne jamais prendre le lock.

Q: Comment Symfony empêche la session fixation, et dans quel cas dois-tu t'en occuper toi-même ? R senior : Le firewall appelle migrate(true) au login → régénère l'ID de session et détruit l'ancien, donc un ID imposé par l'attaquant avant login devient invalide. Tu dois le faire manuellement uniquement si tu authentifies hors du système de sécurité (login custom, échange de token manuel). Sans migration, un attaquant qui fixe l'ID avant la connexion hérite de la session authentifiée.

Q: Tu vois une vague de déconnexions aléatoires en prod avec des sessions en Redis. Hypothèses ? R senior : Premier suspect : Redis en maxmemory-policy allkeys-lru partagé avec le cache applicatif → sous pression mémoire, des sessions actives sont évincées. Fix : instance Redis dédiée aux sessions en noeviction (ou volatile-lru). Autres pistes : TTL trop court vs gc_maxlifetime, un flush de cache accidentel, ou un lock timeout qui invalide des sessions sous contention.

🔗 Liens

Bibliothèque tech perso — Achref