From 7531f54b7e70bd91456cc0ece393e7669748b6c0 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sun, 5 Jul 2026 21:00:36 +0100 Subject: [PATCH 1/2] fix(session): age flash values at the start of the request Closes #1734. --- .../src/Session/ManageSessionMiddleware.php | 6 +++- packages/http/src/Session/Session.php | 19 +++++++--- .../Controllers/FlashViewController.php | 34 ++++++++++++++++++ .../Fixtures/Controllers/flash-view.view.php | 12 +++++++ tests/Integration/Http/FlashInViewTest.php | 36 +++++++++++++++++++ tests/Integration/Http/FormSessionTest.php | 12 ++++--- tests/Integration/Http/SessionTest.php | 18 ++++++++++ 7 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/Controllers/FlashViewController.php create mode 100644 tests/Fixtures/Controllers/flash-view.view.php create mode 100644 tests/Integration/Http/FlashInViewTest.php diff --git a/packages/http/src/Session/ManageSessionMiddleware.php b/packages/http/src/Session/ManageSessionMiddleware.php index bb4876c556..5b7e31aea3 100644 --- a/packages/http/src/Session/ManageSessionMiddleware.php +++ b/packages/http/src/Session/ManageSessionMiddleware.php @@ -24,10 +24,14 @@ public function __construct( public function __invoke(Request $request, HttpMiddlewareCallable $next): Response { + // Flash values are aged at the start of the request, before the response + // body is rendered, so a value flashed on the previous request stays + // readable this request and is expired on the next one. + $this->session->cleanup(); + try { return $next($request); } finally { - $this->session->cleanup(); $this->sessionManager->save($this->session); match ($this->config->cleanupStrategy) { diff --git a/packages/http/src/Session/Session.php b/packages/http/src/Session/Session.php index bcd5d2d160..3385ac01d5 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -65,11 +65,9 @@ public function reflash(): void */ public function get(string|UnitEnum $key, mixed $default = null): mixed { - $key = Str\parse($key); - $value = $this->data[$key] ?? $default; + $value = $this->data[Str\parse($key)] ?? $default; if ($value instanceof FlashValue) { - $this->expiredKeys[$key] = $key; $value = $value->value; } @@ -110,13 +108,26 @@ public function remove(string|UnitEnum $key): void } /** - * Cleans up expired session values. + * Expires flash values by one request. Values flashed on the previous request + * are removed; values flashed since are marked to expire on the next request. + * + * This runs once, at the start of the request, so that flash values remain + * readable while the request is handled - including while the response body + * (e.g. a view) is being rendered - regardless of whether they are read. */ public function cleanup(): void { foreach ($this->expiredKeys as $key) { $this->remove($key); } + + $this->expiredKeys = []; + + foreach ($this->data as $key => $value) { + if ($value instanceof FlashValue) { + $this->expiredKeys[$key] = $key; + } + } } /** diff --git a/tests/Fixtures/Controllers/FlashViewController.php b/tests/Fixtures/Controllers/FlashViewController.php new file mode 100644 index 0000000000..f00efe7ec2 --- /dev/null +++ b/tests/Fixtures/Controllers/FlashViewController.php @@ -0,0 +1,34 @@ +session->flash('message', 'flashed-message'); + + return new Redirect('/flash-view'); + } + + #[Get('/flash-view')] + public function show(): View + { + return view('./flash-view.view.php'); + } +} diff --git a/tests/Fixtures/Controllers/flash-view.view.php b/tests/Fixtures/Controllers/flash-view.view.php new file mode 100644 index 0000000000..75f2e61c20 --- /dev/null +++ b/tests/Fixtures/Controllers/flash-view.view.php @@ -0,0 +1,12 @@ +get('message'); +?> + +
{{ $message }}
diff --git a/tests/Integration/Http/FlashInViewTest.php b/tests/Integration/Http/FlashInViewTest.php new file mode 100644 index 0000000000..0ae9e9b506 --- /dev/null +++ b/tests/Integration/Http/FlashInViewTest.php @@ -0,0 +1,36 @@ +http + ->post('/flash-view') + ->assertRedirect('/flash-view'); + + // The redirected request reads the flash value while rendering the view. + $this->http + ->get('/flash-view') + ->assertOk() + ->assertSee('flashed-message'); + + // The following request no longer sees it: it was aged out at the start of + // this request, even though it was only ever read while rendering the view. + $this->http + ->get('/flash-view') + ->assertOk() + ->assertNotSee('flashed-message'); + } +} diff --git a/tests/Integration/Http/FormSessionTest.php b/tests/Integration/Http/FormSessionTest.php index 788fc00691..d103b4d0f4 100644 --- a/tests/Integration/Http/FormSessionTest.php +++ b/tests/Integration/Http/FormSessionTest.php @@ -166,10 +166,12 @@ public function errors_are_flashed_and_cleared_after_next_request(): void // First access - errors exist $this->assertTrue($this->formSession->hasErrors()); - // Simulate cleanup after request + // Start of the next request: still available to display. $this->session->cleanup(); + $this->assertTrue($this->formSession->hasErrors()); - // Second access - errors cleared + // Start of the request after that: cleared. + $this->session->cleanup(); $this->assertFalse($this->formSession->hasErrors()); } @@ -183,10 +185,12 @@ public function values_are_flashed_and_cleared_after_next_request(): void // First access - values exist $this->assertEquals('John Doe', $this->formSession->getOriginalValueFor('name')); - // Simulate cleanup after request + // Start of the next request: still available to display. $this->session->cleanup(); + $this->assertEquals('John Doe', $this->formSession->getOriginalValueFor('name')); - // Second access - values cleared + // Start of the request after that: cleared. + $this->session->cleanup(); $this->assertEquals('', $this->formSession->getOriginalValueFor('name')); } } diff --git a/tests/Integration/Http/SessionTest.php b/tests/Integration/Http/SessionTest.php index 3ee186f965..36177073e9 100644 --- a/tests/Integration/Http/SessionTest.php +++ b/tests/Integration/Http/SessionTest.php @@ -74,7 +74,25 @@ public function flash(): void $this->session->flash('message', 'success'); $this->assertEquals('success', $this->session->get('message')); + // Start of the next request: the value is still available (e.g. to render). $this->session->cleanup(); + $this->assertEquals('success', $this->session->get('message')); + + // Start of the request after that: it has expired. + $this->session->cleanup(); + $this->assertNull($this->session->get('message')); + } + + #[Test] + public function flash_value_expires_even_when_never_read(): void + { + $this->session->flash('message', 'success'); + + // Aging is independent of reads: two request boundaries expire the value + // without it ever being retrieved. + $this->session->cleanup(); + $this->session->cleanup(); + $this->assertNull($this->session->get('message')); } From 27c5ee6a2f0161574d292cfa79507fa999fe6697 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sun, 5 Jul 2026 21:28:17 +0100 Subject: [PATCH 2/2] Style --- packages/http/src/Session/Session.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/http/src/Session/Session.php b/packages/http/src/Session/Session.php index 3385ac01d5..c555c9f781 100644 --- a/packages/http/src/Session/Session.php +++ b/packages/http/src/Session/Session.php @@ -124,9 +124,11 @@ public function cleanup(): void $this->expiredKeys = []; foreach ($this->data as $key => $value) { - if ($value instanceof FlashValue) { - $this->expiredKeys[$key] = $key; + if (! $value instanceof FlashValue) { + continue; } + + $this->expiredKeys[$key] = $key; } }