From 10533d8ab96f6b3f8123a9714b7d2dfba4076210 Mon Sep 17 00:00:00 2001 From: Witold Wasiczko Date: Wed, 19 Aug 2026 13:30:28 +0200 Subject: [PATCH 1/4] Do not initialize debug bar again on XMLHttpRequest Every AJAX response repeated renderHead() and the initialization code, so the browser built another debug bar on top of the one the main request had already created, instead of adding the AJAX request as a dataset to it. Requests carrying X-Requested-With: XMLHttpRequest now render only render(false), which php-debugbar shows as an "(ajax)" dataset of the existing bar. Force enable/disable and the non-HTML response path keep their current behaviour: a forced disable still wins for AJAX requests, and the wrapper page built for non-HTML responses is a standalone document, so it keeps its head and initialization code. Fixes #41. Takes the approach proposed by @mostafasy in #40, without the exact version constraint on maximebf/debugbar and without attaching the bar to responses that asked for it to be disabled. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +++++ src/PhpDebugBarMiddleware.php | 19 +++++++++--- test/AbstractMiddlewareRunnerTest.php | 21 +++++++++++++ test/PhpDebugBarMiddlewareTest.php | 43 ++++++++++++++++++++++++++- 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cf9b6ff..63e4e7a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,14 @@ Sometimes you want to have control when enable or disable PHP Debug Bar: We allow you to disable attaching phpdebugbar using `X-Enable-Debug-Bar: false` header, cookie or request attribute. To force enable just send request with `X-Enable-Debug-Bar` header, cookie or request attribute with `true` value. +### AJAX requests + +Requests sent with `X-Requested-With: XMLHttpRequest` are attached to the debug bar +already initialized by the main request: the middleware appends only the collected data +of the AJAX request (rendered as a `(ajax)` dataset) instead of the initialization code +and assets. Without it every AJAX response would create another debug bar on top of the +existing one. + ### PSR-17 This package isn't require any PSR-7 implementation - you need to provide it by own. Middleware require ResponseFactory and StreamFactory interfaces. [List of existing interfaces](https://packagist.org/providers/psr/http-factory-implementation). diff --git a/src/PhpDebugBarMiddleware.php b/src/PhpDebugBarMiddleware.php index 8d4a892..82d1a0f 100644 --- a/src/PhpDebugBarMiddleware.php +++ b/src/PhpDebugBarMiddleware.php @@ -62,7 +62,7 @@ public function process(ServerRequest $request, RequestHandler $handler): Respon } if ($this->isHtmlResponse($response)) { - return $this->attachDebugBarToHtmlResponse($response); + return $this->attachDebugBarToHtmlResponse($response, !$this->isXmlHttpRequest($request)); } return $this->prepareHtmlResponseWithDebugBar($response); @@ -122,10 +122,16 @@ private function prepareHtmlResponseWithDebugBar(Response $response): Response ->withAddedHeader('Content-type', 'text/html'); } - private function attachDebugBarToHtmlResponse(Response $response): Response + /** + * @param bool $initialize Render the debug bar initialization code and assets. + * Must be false for responses attached to an already + * initialized debug bar (XMLHttpRequest), otherwise a + * second debug bar is created on top of the existing one. + */ + private function attachDebugBarToHtmlResponse(Response $response, bool $initialize = true): Response { - $head = $this->debugBarRenderer->renderHead(); - $body = $this->debugBarRenderer->render(); + $head = $initialize ? $this->debugBarRenderer->renderHead() : ''; + $body = $this->debugBarRenderer->render($initialize); $responseBody = $response->getBody(); if (! $responseBody->eof() && $responseBody->isSeekable()) { @@ -205,6 +211,11 @@ private function isHtml(MessageInterface $message, string $headerName): bool return strpos($message->getHeaderLine($headerName), 'text/html') !== false; } + private function isXmlHttpRequest(ServerRequest $request): bool + { + return strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest'; + } + private function isRedirect(Response $response): bool { $statusCode = $response->getStatusCode(); diff --git a/test/AbstractMiddlewareRunnerTest.php b/test/AbstractMiddlewareRunnerTest.php index a7dd572..7723332 100644 --- a/test/AbstractMiddlewareRunnerTest.php +++ b/test/AbstractMiddlewareRunnerTest.php @@ -32,6 +32,27 @@ final public function testAppendJsIntoHtmlContent(): void $this->assertStringContainsString('"/phpdebugbar/debugbar.js"', $responseBody); } + final public function testNotAppendInitializationCodeIntoXmlHttpRequestContent(): void + { + $response = $this->dispatchApplication([ + 'REQUEST_URI' => '/hello', + 'REQUEST_METHOD' => 'GET', + 'HTTP_ACCEPT' => 'text/html', + 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', + ], [ + '/hello' => function (ServerRequestInterface $request) { + return new Response\HtmlResponse('Hello!'); + }, + ]); + + $responseBody = (string) $response->getBody(); + + $this->assertStringContainsString('Hello!', $responseBody); + $this->assertStringContainsString('phpdebugbar.addDataSet(', $responseBody); + $this->assertStringNotContainsString('var phpdebugbar = new PhpDebugBar.DebugBar();', $responseBody); + $this->assertStringNotContainsString('"/phpdebugbar/debugbar.js"', $responseBody); + } + final public function testGetStatics(): void { $response = $this->dispatchApplication([ diff --git a/test/PhpDebugBarMiddlewareTest.php b/test/PhpDebugBarMiddlewareTest.php index 423b195..b9bc14b 100644 --- a/test/PhpDebugBarMiddlewareTest.php +++ b/test/PhpDebugBarMiddlewareTest.php @@ -29,7 +29,9 @@ protected function setUp(): void $this->debugbarRenderer = $this->getMockBuilder(JavascriptRenderer::class)->disableOriginalConstructor()->getMock(); $this->debugbarRenderer->method('renderHead')->willReturn('RenderHead'); $this->debugbarRenderer->method('getBaseUrl')->willReturn('/phpdebugbar'); - $this->debugbarRenderer->method('render')->willReturn('RenderBody'); + $this->debugbarRenderer->method('render')->willReturnCallback(function (bool $initialize = true): string { + return $initialize ? 'RenderBody' : 'RenderBodyWithoutInit'; + }); $responseFactory = new ResponseFactory(); $streamFactory = new StreamFactory(); @@ -244,6 +246,45 @@ public function testForceNotAttachDebugbarIfAttributePresents(): void $this->assertSame('ResponseBody', (string) $result->getBody()); } + public function testNotRenderInitializationCodeForXmlHttpRequest(): void + { + $request = new ServerRequest([], [], null, null, 'php://input', ['Accept' => 'text/html', 'X-Requested-With' => 'XMLHttpRequest']); + $response = new Response('php://memory', 200, ['Content-Type' => 'text/html']); + $response->getBody()->write('ResponseBody'); + $requestHandler = new RequestHandlerStub($response); + + $result = $this->middleware->process($request, $requestHandler); + + $this->assertTrue($requestHandler->isCalled(), 'Request handler is not called'); + $this->assertSame($response, $result); + $this->assertSame('ResponseBodyRenderBodyWithoutInit', (string) $result->getBody()); + } + + public function testNotRenderInitializationCodeForLowercasedXmlHttpRequestHeaderValue(): void + { + $request = new ServerRequest([], [], null, null, 'php://input', ['Accept' => 'text/html', 'X-Requested-With' => 'xmlhttprequest']); + $response = new Response('php://memory', 200, ['Content-Type' => 'text/html']); + $response->getBody()->write('ResponseBody'); + $requestHandler = new RequestHandlerStub($response); + + $result = $this->middleware->process($request, $requestHandler); + + $this->assertSame('ResponseBodyRenderBodyWithoutInit', (string) $result->getBody()); + } + + public function testNotAttachDebugbarToXmlHttpRequestIfForceDisabled(): void + { + $request = new ServerRequest([], [], null, null, 'php://input', ['Accept' => 'text/html', 'X-Requested-With' => 'XMLHttpRequest', 'X-Enable-Debug-Bar' => 'false']); + $response = new Response('php://memory', 200, ['Content-Type' => 'text/html']); + $response->getBody()->write('ResponseBody'); + $requestHandler = new RequestHandlerStub($response); + + $result = $this->middleware->process($request, $requestHandler); + + $this->assertSame($response, $result); + $this->assertSame('ResponseBody', (string) $result->getBody()); + } + public function testAppendsToEndOfHtmlResponse(): void { $html = 'FooContent'; From 0eecde1e7e3886b46e34acb148ae950d24646a72 Mon Sep 17 00:00:00 2001 From: Witold Wasiczko Date: Wed, 19 Aug 2026 13:30:28 +0200 Subject: [PATCH 2/4] Register Mezzio interface services in the test container Mezzio's ApplicationFactory resolves MiddlewareFactoryInterface and RequestHandlerRunnerInterface, while the test container only knew the concrete classes, so the whole MezzioTest failed on current Mezzio versions with ServiceNotFoundException. Co-Authored-By: Claude Opus 5 (1M context) --- test/MezzioTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/MezzioTest.php b/test/MezzioTest.php index f3e340d..92abd8f 100644 --- a/test/MezzioTest.php +++ b/test/MezzioTest.php @@ -22,6 +22,7 @@ use Mezzio\Container\ServerRequestErrorResponseGeneratorFactory; use Mezzio\MiddlewareContainer; use Mezzio\MiddlewareFactory; +use Mezzio\MiddlewareFactoryInterface; use Mezzio\Response\ServerRequestErrorResponseGenerator; use Mezzio\Router\FastRouteRouter; use Mezzio\Router\FastRouteRouterFactory; @@ -34,6 +35,7 @@ use Mezzio\Router\RouterInterface; use Laminas\HttpHandlerRunner\Emitter\EmitterInterface; use Laminas\HttpHandlerRunner\RequestHandlerRunner; +use Laminas\HttpHandlerRunner\RequestHandlerRunnerInterface; use Laminas\ServiceManager\Factory\InvokableFactory; use Laminas\ServiceManager\ServiceManager; use Laminas\Stratigility\MiddlewarePipe; @@ -104,6 +106,8 @@ private function createContainer(array $server): ContainerInterface $serviceManagerConfig['factories'][DispatchMiddleware::class] = DispatchMiddlewareFactory::class; $serviceManagerConfig['factories'][ResponseFactory::class] = InvokableFactory::class; $serviceManagerConfig['factories'][StreamFactory::class] = InvokableFactory::class; + $serviceManagerConfig['aliases'][MiddlewareFactoryInterface::class] = MiddlewareFactory::class; + $serviceManagerConfig['aliases'][RequestHandlerRunnerInterface::class] = RequestHandlerRunner::class; $serviceManagerConfig['aliases'][RouterInterface::class] = FastRouteRouter::class; $serviceManagerConfig['aliases'][\Mezzio\ApplicationPipeline::class] = MiddlewarePipe::class; $serviceManagerConfig['aliases'][ResponseFactoryInterface::class] = ResponseFactory::class; From c0009853de66d8e0021f9bf58377869eea5e24d3 Mon Sep 17 00:00:00 2001 From: Witold Wasiczko Date: Wed, 19 Aug 2026 13:30:28 +0200 Subject: [PATCH 3/4] Require maximebf/debugbar ^1.18 Releases before 1.18.0 miss #[\ReturnTypeWillChange] on DebugBar's ArrayAccess methods and fatal on load under PHP 8.1+, which the package claims to support. 1.16.5 and 1.17.0 were verified to fail as well, so 1.18 is the lowest usable bound; the whole 1.x line stays allowed. Since 1.18 the DataFormatter runs through symfony/var-dumper's VarCloner, whose oldest allowed release (2.6) breaks on PHP 8.0. A require-dev floor keeps the lowest-dependency jobs resolving to a version that runs there; it does not constrain consumers of this package. Co-Authored-By: Claude Opus 5 (1M context) --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 77bcca9..974638e 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ ], "require": { "php": "^7.3 || ^8.0", - "maximebf/debugbar": "^1.4", + "maximebf/debugbar": "^1.18", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/container-implementation": "^1.0 || ^2.0", @@ -23,6 +23,7 @@ "require-dev": { "phpunit/phpunit": "^9.1.4", "mikey179/vfsstream": "^1.6.8", + "symfony/var-dumper": "^4.4.30", "slim/slim": "^3.0", "mezzio/mezzio": "^3.0", "mezzio/mezzio-fastroute": "^3.0.1", From 3199d5abd931ffc56f9d907a1b8799be3b2ae668 Mon Sep 17 00:00:00 2001 From: Witold Wasiczko Date: Wed, 19 Aug 2026 13:30:28 +0200 Subject: [PATCH 4/4] Update CI matrix and actions Run on pull requests, cover PHP 8.2 and 8.3, and stop using the retired actions/checkout@v2 and ramsey/composer-install@v1. PHP 7.3 and 7.4 leave the matrix. Composer 2.10, which setup-php installs today, only accepts laminas-diactoros 2.18.1 and newer as a provider of psr/http-factory-implementation, and those releases require PHP 8.0+, so the dev dependencies cannot be installed on 7.3/7.4 at all - with or without the changes in this branch. PHPStan moves to PHP 8.0 for the same reason. The lowest-dependency jobs stay on PHP 8.0: Slim 3.0, Pimple 3.0 and vfsStream 1.6.8 predate PHP 8.1 and fatal on load there, and raising those dev bounds is not an option either, because the first Slim 3 release that supports PHP 8.1 (3.13.0) requires PHP 8.1. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/tests.yml | 39 +++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b6a230..ece2bc5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,38 +1,57 @@ name: CI on: - - push + push: + branches: + - master + pull_request: jobs: phpstan: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 with: - php-version: 7.3 + php-version: '8.0' - name: Install dependencies with Composer - uses: ramsey/composer-install@v1 + uses: ramsey/composer-install@v3 - name: Run phpstan run: vendor/bin/phpstan analyse --level=6 src/ tests: strategy: + fail-fast: false matrix: dependencies: - highest - lowest + # PHP 7.3 and 7.4 are not built here: no laminas-diactoros release + # that Composer 2.10+ accepts as a psr/http-factory-implementation + # provider supports them, so the dev dependencies cannot even be + # installed on those versions. php-versions: - - 7.3 - - 7.4 - - 8.0 - - 8.1 + - '8.0' + - '8.1' + - '8.2' + - '8.3' + exclude: + # Lowest bounds of the dev dependencies (Slim 3.0, Pimple 3.0, + # vfsStream 1.6.8) were released before PHP 8.1 and fatal on load + # there. That is a limitation of those old releases, not of this + # library: every tested PHP version is covered by the highest jobs. + - dependencies: lowest + php-versions: '8.1' + - dependencies: lowest + php-versions: '8.2' + - dependencies: lowest + php-versions: '8.3' runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -40,7 +59,7 @@ jobs: php-version: ${{ matrix.php-versions }} - name: Install dependencies with Composer - uses: ramsey/composer-install@v1 + uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }}