From d34f16f06c7c9415496687a8813917b71d17c5fe Mon Sep 17 00:00:00 2001 From: wakqasahmed Date: Sun, 16 Aug 2026 16:22:21 +0200 Subject: [PATCH 1/3] Hydrate cascade for error views rendered outside Statamic's exception stack (#14167) Statamic's own HTTP exception classes (NotFoundHttpException, ForbiddenHttpException, UnauthorizedHttpException) hydrate the cascade via RendersHttpExceptions::contents() before rendering an Antlers error template. A generic exception thrown outside that stack (e.g. Livewire's default 404 handling, which throws Symfony's stock NotFoundHttpException) never goes through that path, so referencing a global in the error template throws a BadMethodCallException. Register a renderable callback on the app's exception handler that hydrates the cascade and renders the error template through Statamic's View class whenever a generic HttpExceptionInterface exception has a matching errors.{status} view, regardless of which middleware stack or exception class triggered it. --- src/Providers/AppServiceProvider.php | 40 ++++++++++++++++++++++++++++ tests/FrontendTest.php | 24 +++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index ff6958a7902..b898703446c 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -150,6 +150,8 @@ public function boot() $this->registerElevatedSessionMacros(); + $this->registerCascadeHydrationForErrorViews(); + if (config('statamic.system.handle_scheduled_entries')) { $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); } @@ -370,6 +372,44 @@ private function sitesAboutCommandInfo() return $sites->count().' ('.$summary.')'; } + // Statamic's own exceptions hydrate the cascade themselves via RendersHttpExceptions::contents(). + // A generic HTTP exception thrown outside Statamic's stack (e.g. Livewire's default 404) never + // goes through that path, so the error template's globals would be unresolved. This closes that gap. + private function registerCascadeHydrationForErrorViews() + { + $handler = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class); + + if (! method_exists($handler, 'renderable')) { + return; + } + + $handler->renderable( + function (\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e, Request $request) { + if ($request->expectsJson() || Statamic::isCpRoute() || Statamic::isApiRoute()) { + return null; + } + + $status = $e->getStatusCode(); + + if (! view()->exists('errors.'.$status)) { + return null; + } + + Facades\Cascade::hydrated(function ($cascade) use ($status) { + $cascade->set('response_code', $status); + }); + + $layouts = collect(['errors.layout', 'layouts.layout', config('statamic.system.layout', 'layout'), 'statamic::blank']); + $layout = $layouts->filter()->first(fn ($layout) => view()->exists($layout)); + + return response( + $this->app->make(\Statamic\View\View::class)->template('errors.'.$status)->layout($layout)->render(), + $status + ); + } + ); + } + private function registerElevatedSessionMacros() { Request::macro('hasElevatedSession', function () { diff --git a/tests/FrontendTest.php b/tests/FrontendTest.php index ca02226c615..f2738c43139 100644 --- a/tests/FrontendTest.php +++ b/tests/FrontendTest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Route; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\Protect\ProtectorManager; @@ -17,9 +18,11 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Cascade; use Statamic\Facades\Collection; +use Statamic\Facades\GlobalSet; use Statamic\Facades\User; use Statamic\Tags\Tags; use Statamic\View\Antlers\Language\Utilities\StringUtilities; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException as SymfonyNotFoundHttpException; class FrontendTest extends TestCase { @@ -715,6 +718,27 @@ public function a_404_does_not_leak_its_response_code_into_later_requests() $this->get('/about')->assertOk()->assertSee('Page 200'); } + #[Test] + public function it_hydrates_the_cascade_for_a_404_thrown_outside_of_statamics_own_exception_stack() + { + // Simulates something like Livewire's default 404 handling, which throws Symfony's + // stock NotFoundHttpException rather than Statamic's own subclass. See GH-14167. + Route::get('/non-statamic-404', function () { + throw new SymfonyNotFoundHttpException; + }); + + $global = GlobalSet::make('site_settings')->save(); + $global->in('en')->data(['site_name' => 'Test Site'])->save(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('errors.404', 'Not found: {{ site_settings:site_name }}'); + + $this->get('/non-statamic-404') + ->assertNotFound() + ->assertSee('Not found: Test Site'); + } + #[Test] public function it_sets_the_translation_locale_based_on_site() { From d3a8425c4dbb00edf1321aec0303fd3429def0ec Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 24 Sep 2026 15:35:51 -0400 Subject: [PATCH 2/3] Fix error view cascade test so it exercises the new code path The test threw from a route, but Statamic's frontend catch-all matches every path first, so Statamic's own NotFoundHttpException was thrown and the test passed even with the feature disabled. Throw from a global middleware instead, mirroring Livewire's RequireLivewireHeaders. Co-Authored-By: Claude Opus 5.5 --- tests/FrontendTest.php | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tests/FrontendTest.php b/tests/FrontendTest.php index f2738c43139..7f6553427a6 100644 --- a/tests/FrontendTest.php +++ b/tests/FrontendTest.php @@ -5,11 +5,11 @@ use Facades\Statamic\CP\LivePreview; use Facades\Statamic\Routing\ResolveRedirect; use Facades\Tests\Factories\EntryFactory; +use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Http\Response; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Route; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\Protect\ProtectorManager; @@ -22,6 +22,7 @@ use Statamic\Facades\User; use Statamic\Tags\Tags; use Statamic\View\Antlers\Language\Utilities\StringUtilities; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException as SymfonyNotFoundHttpException; class FrontendTest extends TestCase @@ -719,13 +720,9 @@ public function a_404_does_not_leak_its_response_code_into_later_requests() } #[Test] - public function it_hydrates_the_cascade_for_a_404_thrown_outside_of_statamics_own_exception_stack() + public function it_hydrates_the_cascade_for_an_error_thrown_outside_of_statamics_own_exception_stack() { - // Simulates something like Livewire's default 404 handling, which throws Symfony's - // stock NotFoundHttpException rather than Statamic's own subclass. See GH-14167. - Route::get('/non-statamic-404', function () { - throw new SymfonyNotFoundHttpException; - }); + $this->throwFromGlobalMiddleware(new SymfonyNotFoundHttpException); $global = GlobalSet::make('site_settings')->save(); $global->in('en')->data(['site_name' => 'Test Site'])->save(); @@ -734,11 +731,32 @@ public function it_hydrates_the_cascade_for_a_404_thrown_outside_of_statamics_ow $this->viewShouldReturnRaw('layout', '{{ template_content }}'); $this->viewShouldReturnRaw('errors.404', 'Not found: {{ site_settings:site_name }}'); - $this->get('/non-statamic-404') + $this->get('/anything') ->assertNotFound() ->assertSee('Not found: Test Site'); } + // Mirrors the reported case: Livewire's RequireLivewireHeaders aborts from middleware, + // so Symfony's stock exception reaches the handler without passing through Statamic's + // own exception classes. Throwing from a route wouldn't reproduce it — Statamic's + // frontend catch-all matches every path, so its own NotFoundHttpException wins. See #14167. + private function throwFromGlobalMiddleware(HttpExceptionInterface $exception) + { + $this->app->instance('test-throwing-middleware', new class($exception) + { + public function __construct(private HttpExceptionInterface $exception) + { + } + + public function handle($request, $next) + { + throw $this->exception; + } + }); + + $this->app[HttpKernel::class]->pushMiddleware('test-throwing-middleware'); + } + #[Test] public function it_sets_the_translation_locale_based_on_site() { From 67331eb93557b45b4becbb470f1e43179ddc2080 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 24 Sep 2026 15:35:51 -0400 Subject: [PATCH 3/3] Preserve exception headers when rendering error views Headers like WWW-Authenticate, Allow, and Retry-After were dropped, unlike Laravel's own renderHttpException(). Co-Authored-By: Claude Opus 5.5 --- src/Providers/AppServiceProvider.php | 3 ++- tests/FrontendTest.php | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index b898703446c..6905fc0c33e 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -404,7 +404,8 @@ function (\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e, Req return response( $this->app->make(\Statamic\View\View::class)->template('errors.'.$status)->layout($layout)->render(), - $status + $status, + $e->getHeaders() ); } ); diff --git a/tests/FrontendTest.php b/tests/FrontendTest.php index 7f6553427a6..96cab11b05a 100644 --- a/tests/FrontendTest.php +++ b/tests/FrontendTest.php @@ -24,6 +24,7 @@ use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException as SymfonyNotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException as SymfonyUnauthorizedHttpException; class FrontendTest extends TestCase { @@ -736,6 +737,21 @@ public function it_hydrates_the_cascade_for_an_error_thrown_outside_of_statamics ->assertSee('Not found: Test Site'); } + #[Test] + public function it_keeps_the_exception_headers_when_rendering_an_error_view() + { + $this->throwFromGlobalMiddleware(new SymfonyUnauthorizedHttpException('Basic realm="Restricted"')); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('errors.401', 'Unauthorized'); + + $this->get('/anything') + ->assertUnauthorized() + ->assertSee('Unauthorized') + ->assertHeader('WWW-Authenticate', 'Basic realm="Restricted"'); + } + // Mirrors the reported case: Livewire's RequireLivewireHeaders aborts from middleware, // so Symfony's stock exception reaches the handler without passing through Statamic's // own exception classes. Throwing from a route wouldn't reproduce it — Statamic's