From 055aa5be95ba353adb34240d1d5a45d28ef34680 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Wed, 25 Mar 2026 15:10:15 +0000 Subject: [PATCH] Serve exception pages as Markdown via Accept header When a client sends `Accept: text/markdown`, the exception handler now returns the same rich markdown payload used by the error page copy button. Precedence follows the existing pattern: JSON > Markdown > HTML. Debug-off and custom ExceptionRenderer scenarios fall back to a minimal `# Server Error` document to avoid leaking internals. --- .../Foundation/Exceptions/Handler.php | 80 +++++++++++++++++- .../Exceptions/Renderer/Renderer.php | 39 +++++++-- .../Foundation/Exceptions/RendererTest.php | 84 +++++++++++++++++++ 3 files changed, 195 insertions(+), 8 deletions(-) diff --git a/src/Illuminate/Foundation/Exceptions/Handler.php b/src/Illuminate/Foundation/Exceptions/Handler.php index a98f78181edb..bc13eb932db7 100644 --- a/src/Illuminate/Foundation/Exceptions/Handler.php +++ b/src/Illuminate/Foundation/Exceptions/Handler.php @@ -121,6 +121,13 @@ class Handler implements ExceptionHandlerContract */ protected $shouldRenderJsonWhenCallback; + /** + * The callback that determines if the exception handler response should be Markdown. + * + * @var callable|null + */ + protected $shouldRenderMarkdownWhenCallback; + /** * The callback that prepares responses to be returned to the browser. * @@ -736,9 +743,11 @@ protected function renderViaCallbacks($request, Throwable $e) */ protected function renderExceptionResponse($request, Throwable $e) { - return $this->shouldReturnJson($request, $e) - ? $this->prepareJsonResponse($request, $e) - : $this->prepareResponse($request, $e); + return match (true) { + $this->shouldReturnJson($request, $e) => $this->prepareJsonResponse($request, $e), + $this->shouldReturnMarkdown($request, $e) => $this->prepareMarkdownResponse($request, $e), + default => $this->prepareResponse($request, $e), + }; } /** @@ -829,6 +838,71 @@ public function shouldRenderJsonWhen($callback) return $this; } + /** + * Determine if the exception handler response should be Markdown. + * + * @param \Illuminate\Http\Request $request + * @param \Throwable $e + * @return bool + */ + protected function shouldReturnMarkdown($request, Throwable $e) + { + return $this->shouldRenderMarkdownWhenCallback + ? call_user_func($this->shouldRenderMarkdownWhenCallback, $request, $e) + : $request->wantsMarkdown(); + } + + /** + * Register the callable that determines if the exception handler response should be Markdown. + * + * @param callable(\Illuminate\Http\Request $request, \Throwable): bool $callback + * @return $this + */ + public function shouldRenderMarkdownWhen($callback) + { + $this->shouldRenderMarkdownWhenCallback = $callback; + + return $this; + } + + /** + * Prepare a Markdown response for the given exception. + * + * @param \Illuminate\Http\Request $request + * @param \Throwable $e + * @return \Illuminate\Http\Response + */ + protected function prepareMarkdownResponse($request, Throwable $e) + { + $status = $this->isHttpException($e) ? $e->getStatusCode() : 500; + $headers = $this->isHttpException($e) ? $e->getHeaders() : []; + + return response($this->renderExceptionAsMarkdown($e), $status, array_merge($headers, [ + 'Content-Type' => 'text/markdown; charset=UTF-8', + ]))->withException($e); + } + + /** + * Render the given exception as a markdown string. + * + * @param \Throwable $e + * @return string + */ + protected function renderExceptionAsMarkdown(Throwable $e) + { + if ( + config('app.debug') + && $this->container->bound(Renderer::class) + && ! app()->has(ExceptionRenderer::class) + ) { + return $this->container->make(Renderer::class)->renderMarkdown(request(), $e); + } + + $message = $this->isHttpException($e) ? $e->getMessage() : 'Server Error'; + + return "# {$message}\n"; + } + /** * Prepare a response for the given exception. * diff --git a/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php b/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php index cc464f3f9fbe..c4a2af68d018 100644 --- a/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php +++ b/src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php @@ -84,11 +84,7 @@ public function __construct( */ public function render(Request $request, Throwable $throwable) { - $flattenException = $this->bladeMapper->map( - $this->htmlErrorRenderer->render($throwable), - ); - - $exception = new Exception($flattenException, $request, $this->listener, $this->basePath); + $exception = $this->resolveException($request, $throwable); $exceptionAsMarkdown = $this->viewFactory->make('laravel-exceptions-renderer::markdown', [ 'exception' => $exception, @@ -100,6 +96,39 @@ public function render(Request $request, Throwable $throwable) ])->render(); } + /** + * Render the given exception as a markdown string. + * + * @param \Illuminate\Http\Request $request + * @param \Throwable $throwable + * @return string + */ + public function renderMarkdown(Request $request, Throwable $throwable) + { + $exception = $this->resolveException($request, $throwable); + + return $this->viewFactory->make('laravel-exceptions-renderer::markdown', [ + 'exception' => $exception, + ])->render(); + } + + /** + * Resolve the exception into a renderable exception instance. + * + * @param \Illuminate\Http\Request $request + * @param \Throwable $throwable + * @return \Illuminate\Foundation\Exceptions\Renderer\Exception + */ + protected function resolveException(Request $request, Throwable $throwable) + { + return new Exception( + $this->bladeMapper->map($this->htmlErrorRenderer->render($throwable)), + $request, + $this->listener, + $this->basePath, + ); + } + /** * Get the renderer's CSS content. * diff --git a/tests/Integration/Foundation/Exceptions/RendererTest.php b/tests/Integration/Foundation/Exceptions/RendererTest.php index 6f9aea09ff98..b8f077df3c32 100644 --- a/tests/Integration/Foundation/Exceptions/RendererTest.php +++ b/tests/Integration/Foundation/Exceptions/RendererTest.php @@ -174,4 +174,88 @@ public function testItExcludesDecorativeAsciiArtInNonBrowserContexts() ->assertSee('Bad route!') ->assertDontSee('viewBox="0 0 1268 308"', false); } + + #[WithConfig('app.debug', true)] + public function testItCanRenderExceptionAsMarkdown() + { + $response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown']); + + $response->assertInternalServerError(); + $response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8'); + $response->assertSee('RuntimeException'); + $response->assertSee('Bad route!'); + $response->assertSee('## Stack Trace'); + } + + #[WithConfig('app.debug', true)] + public function testItCanRenderExceptionAsMarkdownWithCharsetParameter() + { + $response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown; charset=utf-8']); + + $response->assertInternalServerError(); + $response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8'); + $response->assertSee('## Stack Trace'); + } + + #[WithConfig('app.debug', true)] + public function testJsonTakesPrecedenceOverMarkdown() + { + $response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'application/json, text/markdown']); + + $response->assertInternalServerError(); + $response->assertHeader('Content-Type', 'application/json'); + $response->assertJsonStructure(['message', 'exception']); + } + + #[WithConfig('app.debug', true)] + public function testMarkdownTakesPrecedenceOverJson() + { + $response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown, application/json']); + + $response->assertInternalServerError(); + $response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8'); + $response->assertSee('## Stack Trace'); + } + + #[WithConfig('app.debug', true)] + public function testHtmlTakesPrecedenceOverMarkdown() + { + $response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/html, text/markdown']); + + $response->assertInternalServerError(); + $this->assertStringContainsString('text/html', $response->headers->get('Content-Type')); + } + + #[WithConfig('app.debug', false)] + public function testMarkdownResponseDoesNotLeakDebugInfoWhenDebugOff() + { + $response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown']); + + $response->assertInternalServerError(); + $response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8'); + $response->assertDontSee('## Stack Trace'); + $response->assertDontSee('Bad route!'); + $response->assertSee('Server Error'); + } + + #[WithConfig('app.debug', true)] + public function testMarkdownFallsBackWhenCustomExceptionRendererRegistered() + { + $this->app->singleton(ExceptionRenderer::class, function () { + return new class() implements ExceptionRenderer + { + public function render($throwable) + { + return response('Custom Exception Renderer: '.$throwable->getMessage(), 500); + } + }; + }); + + $response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown']); + + $response->assertInternalServerError(); + $response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8'); + $response->assertDontSee('## Stack Trace'); + $response->assertSee('Server Error'); + } }