From 943e4b6f994829acc90c98c8dea07cb358c81db0 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 23 Aug 2026 13:37:23 -0400 Subject: [PATCH 1/2] Negotiate error handler content type on every request The ErrorHandler cached the content type negotiated from the first request's Accept header and reused it for the lifetime of the handler instance. Applications sharing one handler across requests (long-running worker runtimes such as Swoole, RoadRunner or Laravel Octane) kept serving error responses in the first request's format regardless of later clients' Accept headers. Recompute the content type from each request unless forceContentType() was called, which keeps its existing override semantics. --- Slim/Handlers/ErrorHandler.php | 5 ++- tests/Handlers/ErrorHandlerTest.php | 58 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Slim/Handlers/ErrorHandler.php b/Slim/Handlers/ErrorHandler.php index a6b99ceac..aeab75730 100644 --- a/Slim/Handlers/ErrorHandler.php +++ b/Slim/Handlers/ErrorHandler.php @@ -78,6 +78,8 @@ class ErrorHandler implements ErrorHandlerInterface protected ?string $contentType = null; + protected bool $isContentTypeForced = false; + protected ?string $method = null; protected ServerRequestInterface $request; @@ -125,7 +127,7 @@ public function __invoke( $this->exception = $exception; $this->method = $request->getMethod(); $this->statusCode = $this->determineStatusCode(); - if ($this->contentType === null) { + if (!$this->isContentTypeForced) { $this->contentType = $this->determineContentType($request); } @@ -143,6 +145,7 @@ public function __invoke( */ public function forceContentType(?string $contentType): void { + $this->isContentTypeForced = true; $this->contentType = $contentType; } diff --git a/tests/Handlers/ErrorHandlerTest.php b/tests/Handlers/ErrorHandlerTest.php index 1cd6bcc89..9b72f98db 100644 --- a/tests/Handlers/ErrorHandlerTest.php +++ b/tests/Handlers/ErrorHandlerTest.php @@ -118,6 +118,64 @@ public function testForceContentType() $this->assertSame(['application/json'], $response->getHeader('Content-Type')); } + /** + * Test that the content type is negotiated from the Accept header on each + * request instead of being cached from the first invocation. + */ + public function testContentTypeIsNegotiatedOnEachRequest() + { + $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory()); + + $jsonRequest = $this + ->createServerRequest('/not-defined', 'GET') + ->withHeader('Accept', 'application/json'); + + $htmlRequest = $this + ->createServerRequest('/not-defined', 'GET') + ->withHeader('Accept', 'text/html'); + + $exception = new HttpNotFoundException($jsonRequest); + + /** @var ResponseInterface $response */ + $response = $handler->__invoke($jsonRequest, $exception, false, false, false); + $this->assertSame(['application/json'], $response->getHeader('Content-Type')); + + $exception = new HttpNotFoundException($htmlRequest); + + /** @var ResponseInterface $response */ + $response = $handler->__invoke($htmlRequest, $exception, false, false, false); + $this->assertSame(['text/html'], $response->getHeader('Content-Type')); + } + + /** + * Test that a forced content type still wins over per-request negotiation. + */ + public function testForcedContentTypeWinsOverSubsequentNegotiation() + { + $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory()); + $handler->forceContentType('application/json'); + + $jsonRequest = $this + ->createServerRequest('/not-defined', 'GET') + ->withHeader('Accept', 'application/json'); + + $xmlRequest = $this + ->createServerRequest('/not-defined', 'GET') + ->withHeader('Accept', 'application/xml'); + + $exception = new HttpNotFoundException($jsonRequest); + + /** @var ResponseInterface $response */ + $response = $handler->__invoke($jsonRequest, $exception, false, false, false); + $this->assertSame(['application/json'], $response->getHeader('Content-Type')); + + $exception = new HttpNotFoundException($xmlRequest); + + /** @var ResponseInterface $response */ + $response = $handler->__invoke($xmlRequest, $exception, false, false, false); + $this->assertSame(['application/json'], $response->getHeader('Content-Type')); + } + public function testHalfValidContentType() { $request = $this From 3bce00dba8a1bff3c078872bc613dcf1760beaa2 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 25 Aug 2026 07:23:32 -0400 Subject: [PATCH 2/2] Restore negotiation when forceContentType(null) is called On 4.x, null is the unforced sentinel, so forceContentType(null) re-enables Accept-header negotiation. Keep that: a null argument clears $isContentTypeForced instead of pinning negotiation off. --- Slim/Handlers/ErrorHandler.php | 4 ++-- tests/Handlers/ErrorHandlerTest.php | 37 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Slim/Handlers/ErrorHandler.php b/Slim/Handlers/ErrorHandler.php index aeab75730..95fa4c6d3 100644 --- a/Slim/Handlers/ErrorHandler.php +++ b/Slim/Handlers/ErrorHandler.php @@ -141,11 +141,11 @@ public function __invoke( /** * Force the content type for all error handler responses. * - * @param string|null $contentType The content type + * @param string|null $contentType The content type. Null restores Accept-header negotiation. */ public function forceContentType(?string $contentType): void { - $this->isContentTypeForced = true; + $this->isContentTypeForced = $contentType !== null; $this->contentType = $contentType; } diff --git a/tests/Handlers/ErrorHandlerTest.php b/tests/Handlers/ErrorHandlerTest.php index 9b72f98db..515673c78 100644 --- a/tests/Handlers/ErrorHandlerTest.php +++ b/tests/Handlers/ErrorHandlerTest.php @@ -176,6 +176,43 @@ public function testForcedContentTypeWinsOverSubsequentNegotiation() $this->assertSame(['application/json'], $response->getHeader('Content-Type')); } + /** + * Test that forceContentType(null) restores Accept-header negotiation. + */ + public function testForceContentTypeNullRestoresNegotiation() + { + $handler = new ErrorHandler($this->getCallableResolver(), $this->getResponseFactory()); + $handler->forceContentType('application/json'); + + $xmlRequest = $this + ->createServerRequest('/not-defined', 'GET') + ->withHeader('Accept', 'application/xml'); + + $htmlRequest = $this + ->createServerRequest('/not-defined', 'GET') + ->withHeader('Accept', 'text/html'); + + $exception = new HttpNotFoundException($xmlRequest); + + /** @var ResponseInterface $response */ + $response = $handler->__invoke($xmlRequest, $exception, false, false, false); + $this->assertSame(['application/json'], $response->getHeader('Content-Type')); + + $handler->forceContentType(null); + + $exception = new HttpNotFoundException($xmlRequest); + + /** @var ResponseInterface $response */ + $response = $handler->__invoke($xmlRequest, $exception, false, false, false); + $this->assertSame(['application/xml'], $response->getHeader('Content-Type')); + + $exception = new HttpNotFoundException($htmlRequest); + + /** @var ResponseInterface $response */ + $response = $handler->__invoke($htmlRequest, $exception, false, false, false); + $this->assertSame(['text/html'], $response->getHeader('Content-Type')); + } + public function testHalfValidContentType() { $request = $this