diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index b30e1d23b6a..b2c51286467 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -137,6 +137,8 @@ public function boot() $this->registerElevatedSessionMacros(); + $this->registerCascadeHydrationForErrorViews(); + if (config('statamic.system.handle_scheduled_entries')) { $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); } @@ -349,6 +351,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 6c648253bca..5f6cffc0498 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 { @@ -698,6 +701,27 @@ public function the_404_page_is_treated_like_a_template() // todo: test cascade vars are in the debugbar } + #[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() {