Diese Website setzt auf Inhaltsseiten kein einziges Cookie. Das ist keine Einstellung, die man einmal umlegt, sondern eine Entscheidung gegen Laravels Standardverhalten — und auf dem Weg dorthin kam ein Fehler ans Licht, der das Kontaktformular ungeschützt gelassen hätte.

Was Laravel ab Werk über jede Route legt

In Laravel 13 werden die Routen in bootstrap/app.php registriert. Der übliche Weg ist withRouting(web: __DIR__.'/../routes/web.php'). Was dabei leicht übersehen wird: Das Framework legt die Middleware-Gruppe web automatisch über jede Route dieser Datei. Die Gruppe enthält unter anderem:

Middleware Wirkung
EncryptCookies verschlüsselt ausgehende Cookies
AddQueuedCookiesToResponse hängt vorgemerkte Cookies an die Antwort
StartSession startet eine Session und setzt das Session-Cookie
ShareErrorsFromSession stellt Validierungsfehler aus der Session bereit
CSRF-Schutz prüft den Token und setzt XSRF-TOKEN

Für eine Anwendung mit Login ist das genau richtig. Für eine Website, die fast nur aus Inhaltsseiten besteht, bedeutet es: Auch die Startseite setzt laravel-session und XSRF-TOKEN. Damit ist die Antwort nicht mehr cachebar, und die Zusage „keine Cookies, deshalb kein Banner" ist nicht haltbar.

Warum Full-Page-Cache und Formular sich ausschließen

Die ursprüngliche Anforderung war ein Full-Page-Cache über alle GET-Antworten — und gleichzeitig ein Kontaktformular mit CSRF-Token, Honeypot und Zeitfalle. Beides zusammen funktioniert nicht:

  1. Der erste Besucher ruft /kontakt auf, die Antwort samt CSRF-Token landet im Cache.
  2. Der zweite Besucher bekommt dieselbe Seite mit demselben Token — der aber zur Session des ersten gehört.
  3. Beim Absenden antwortet Laravel mit HTTP 419.

Das Tückische daran: Kein Monitoring schlägt an. Die Seiten laden fehlerfrei, nur der einzige Konversionspfad ist tot.

Die Lösung: Cache-Zonen statt einer Ausschlussliste

Statt einer Liste von Pfaden, die vom Cache ausgenommen sind, gehört hier jede Route zu einer Zone, und die Zone bringt ihre Middleware mit. Zuerst die Registrierung ohne implizite Gruppe:

PHP · bootstrap/app.php
->withRouting(
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
    then: function (): void {
        Route::group([], base_path('routes/web.php'));
    },
)

then: lädt die Datei, ohne web darüberzulegen. Die Gruppen definiere ich selbst — Session und CSRF ausschließlich dort, wo ein Formular steht:

PHP · bootstrap/app.php
$middleware->group('public', [
    ValidatePostSize::class,
    SetLocale::class,
    SecurityHeaders::class,
    SubstituteBindings::class,
]);

$middleware->group('form', [
    EncryptCookies::class,
    AddQueuedCookiesToResponse::class,
    StartSession::class,
    ShareErrorsFromSession::class,
    VerifyCsrfToken::class,
    ValidatePostSize::class,
    SetLocale::class,
    SecurityHeaders::class,
    SubstituteBindings::class,
]);

In routes/web.php steht die Zone dann an der Route, nicht in einer Konfigurationsdatei daneben:

PHP · routes/web.php
Route::middleware('public')->group(function (): void {
    Route::get('/', [PageController::class, 'home'])->name('home');
    // …
});

Route::middleware('form')->group(function (): void {
    Route::get('/kontakt', [ContactController::class, 'show'])->name('contact');
    Route::post('/kontakt', [ContactController::class, 'store'])->name('contact.store');
});

Der Fehler, den erst ein Test gefunden hat

In der ersten Fassung der Gruppe form fehlte die CSRF-Middleware. Das Formular hätte funktioniert — nur eben ohne jeden Schutz. Kein Fehler, keine Warnung.

Aufgefallen ist es, weil ein Browser-Test die tatsächlich gesetzten Cookies der Kontaktseite liest und dort XSRF-TOKEN erwartet. Der Token fehlte. Ein Test, der nur prüft, ob das Formular abschickbar ist, wäre grün geblieben.

Wie ein Test die Zonen dauerhaft absichert

Die Aufteilung ist nur so viel wert wie der Moment, in dem jemand eine neue Route anlegt. Deshalb iteriert ein Pest-Test über alle registrierten Routen und bricht ab, sobald eine keiner oder mehreren Zonen angehört:

PHP · tests/Feature/Http/CacheZoneTest.php
it('ordnet jede Route genau einer Zone zu', function () {
    $zones = ['public', 'form', 'endpoint'];

    $unzoned = collect(Route::getRoutes()->getRoutes())
        ->reject(fn ($route) => in_array($route->uri(), ['up', 'storage/{path}'], true))
        ->filter(fn ($route) => count(array_intersect($zones, $route->gatherMiddleware())) !== 1)
        ->map(fn ($route) => $route->methods()[0].' /'.$route->uri())
        ->values()
        ->all();

    expect($unzoned)->toBe([]);
});

Zwei weitere Tests prüfen das Ergebnis dort, wo es zählt: Die Startseite darf keinen Set-Cookie-Header senden, die Kontaktseite muss es. Der Unterschied zwischen den Zonen ist damit messbar und nicht nur dokumentiert.

Was bleibt

Laravels Standardgruppe ist ein guter Standard für Anwendungen. Für eine Website mit wenigen Formularen ist sie zu großzügig — und der Preis dafür ist unsichtbar: ein Cookie auf jeder Seite, eine Antwort, die kein Cache mehr halten darf, und im schlimmsten Fall ein Formular, das mit einem eingefrorenen Token jeden Besucher abweist.

Die ganze Architektur dieser Website beschreibt die Fallstudie zu alexander-seidler.com. Welche Kompetenzen dahinterstehen, steht unter Kompetenzen.

Quellen: Laravel 13 — Middleware-Gruppen · Laravel 13 — CSRF-Schutz

Häufige Fragen zum Artikel

Setzt Laravel auf jeder Seite ein Session-Cookie?

Ja, sobald eine Route in der Middleware-Gruppe web liegt. Routen aus routes/web.php landen dort automatisch, wenn die Datei über withRouting(web: …) registriert wird. Die Gruppe enthält StartSession, und damit bekommt jede Antwort ein Session-Cookie, auch eine Startseite ohne Formular.

Kann man eine Laravel-Seite mit Formular vollständig cachen?

Nicht die Seite mit dem Formular. Ihr CSRF-Token gehört zu genau einer Session. Landet die Antwort im Full-Page-Cache, bekommt jeder weitere Besucher denselben Token und beim Absenden HTTP 419. Die Lösung ist eine eigene, ungecachte Zone nur für das Formular, alles andere bleibt cachebar.

Wie verhindert man, dass eine neue Route in der falschen Zone landet?

Mit einem Test, der über alle registrierten Routen iteriert und fehlschlägt, sobald eine Route nicht genau einer Zone angehört. Damit ist der vergessene Eintrag kein stiller Fehler mehr, sondern ein roter Build, bevor die Änderung überhaupt ausgeliefert wird.

Verwandte Artikel