Warum meine Laravel-Startseite kein Cookie setzt
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:
- Der erste Besucher ruft
/kontaktauf, die Antwort samt CSRF-Token landet im Cache. - Der zweite Besucher bekommt dieselbe Seite mit demselben Token — der aber zur Session des ersten gehört.
- 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:
->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:
$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:
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:
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.