From 97cfc2fa05731efb4aea1b49ed2ab0fc59ece740 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 10:22:25 +0100 Subject: [PATCH 1/4] Prevent EmitterException when output buffer has content If headers have not been sent but the output buffer is not empty, Laminas SapiEmitter throws an EmitterException. This commonly happens in WordPress when plugins (like Gravity Forms) leak output early. We now detect this scenario, clean the buffer, and echo its content before calling SapiEmitter, ensuring all previous output is preserved while allowing the PSR-7 response to be emitted correctly. --- src/Http/ResponseEmitter.php | 6 +++ tests/Unit/Http/ResponseEmitterTest.php | 55 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php index 53d443e..af0bf26 100644 --- a/src/Http/ResponseEmitter.php +++ b/src/Http/ResponseEmitter.php @@ -23,6 +23,12 @@ public function emit(ResponseInterface $response) return; } + // If the output buffer has content, SapiEmitter will throw an exception. + // We can circumvent this by cleaning the buffer and echoing the content. + if (ob_get_level() > 0 && ob_get_length() > 0) { + echo ob_get_clean(); + } + (new SapiEmitter())->emit($response); } } diff --git a/tests/Unit/Http/ResponseEmitterTest.php b/tests/Unit/Http/ResponseEmitterTest.php index 1d2cc25..ef3cd3e 100644 --- a/tests/Unit/Http/ResponseEmitterTest.php +++ b/tests/Unit/Http/ResponseEmitterTest.php @@ -84,4 +84,59 @@ public function emit_should_use_sapi_emitter_when_headers_not_sent(): void $headerMock->disable(); $emitterHeadersSentMock->disable(); } + + #[Test] + public function emit_should_not_throw_exception_when_output_buffer_has_content_but_headers_not_sent(): void + { + $builder = new MockBuilder(); + $builder->setNamespace('Rareloop\Lumberjack\Http') + ->setName('headers_sent') + ->setFunction(function () { + return false; + }); + $mock = $builder->build(); + $mock->enable(); + + // SapiEmitter uses the global header() function. + $headerBuilder = new MockBuilder(); + $headerBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') + ->setName('header') + ->setFunction(function () { + }); + $headerMock = $headerBuilder->build(); + $headerMock->enable(); + + // SapiEmitterTrait checks for namespaced headers_sent + $emitterHeadersSentBuilder = new MockBuilder(); + $emitterHeadersSentBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') + ->setName('headers_sent') + ->setFunction(function () { + return false; + }); + $emitterHeadersSentMock = $emitterHeadersSentBuilder->build(); + $emitterHeadersSentMock->enable(); + + $response = new TextResponse('Hello World'); + $emitter = new ResponseEmitter(); + + // Start output buffering and put some content in it + ob_start(); + echo 'Existing output'; + + // We expect no exception now + ob_start(); + $emitter->emit($response); + $output = ob_get_clean(); + + // Final output should contain both existing and new content + // The outer buffer was started before 'Existing output' + $existing = ob_get_clean(); + + $this->assertSame('Hello World', $output); + $this->assertSame('Existing output', $existing); + + $mock->disable(); + $headerMock->disable(); + $emitterHeadersSentMock->disable(); + } } From c4d61af2e31c4821526dc3bd1ec3696f5b066abb Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 11:05:34 +0100 Subject: [PATCH 2/4] Prevent headers sent exception --- src/Http/ResponseEmitter.php | 6 +++++- tests/Unit/Http/ResponseEmitterTest.php | 10 +++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php index af0bf26..9191085 100644 --- a/src/Http/ResponseEmitter.php +++ b/src/Http/ResponseEmitter.php @@ -24,9 +24,13 @@ public function emit(ResponseInterface $response) } // If the output buffer has content, SapiEmitter will throw an exception. - // We can circumvent this by cleaning the buffer and echoing the content. + // We circumvent this by stacking a new buffer, emitting into it, + // and then echoing the result. if (ob_get_level() > 0 && ob_get_length() > 0) { + ob_start(); + (new SapiEmitter())->emit($response); echo ob_get_clean(); + return; } (new SapiEmitter())->emit($response); diff --git a/tests/Unit/Http/ResponseEmitterTest.php b/tests/Unit/Http/ResponseEmitterTest.php index ef3cd3e..1bc8e49 100644 --- a/tests/Unit/Http/ResponseEmitterTest.php +++ b/tests/Unit/Http/ResponseEmitterTest.php @@ -124,16 +124,12 @@ public function emit_should_not_throw_exception_when_output_buffer_has_content_b echo 'Existing output'; // We expect no exception now - ob_start(); $emitter->emit($response); - $output = ob_get_clean(); - + // Final output should contain both existing and new content - // The outer buffer was started before 'Existing output' - $existing = ob_get_clean(); + $output = ob_get_clean(); - $this->assertSame('Hello World', $output); - $this->assertSame('Existing output', $existing); + $this->assertSame('Existing outputHello World', $output); $mock->disable(); $headerMock->disable(); From 0acea550dfb0dffa28c2ea736f9c2ae45e1096e3 Mon Sep 17 00:00:00 2001 From: Rich Standbrook Date: Wed, 20 May 2026 13:44:06 +0100 Subject: [PATCH 3/4] Refactor for clarity and tests --- src/Http/ResponseEmitter.php | 38 +++++++++++-- tests/Unit/Http/ResponseEmitterTest.php | 76 +++++++------------------ 2 files changed, 53 insertions(+), 61 deletions(-) diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php index 9191085..5ed8bf2 100644 --- a/src/Http/ResponseEmitter.php +++ b/src/Http/ResponseEmitter.php @@ -4,9 +4,17 @@ use Psr\Http\Message\ResponseInterface; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Laminas\HttpHandlerRunner\Emitter\EmitterInterface; class ResponseEmitter { + protected EmitterInterface $emitter; + + public function __construct(?EmitterInterface $emitter = null) + { + $this->emitter = $emitter ?? new SapiEmitter(); + } + /** * Emit a response. * @@ -26,13 +34,33 @@ public function emit(ResponseInterface $response) // If the output buffer has content, SapiEmitter will throw an exception. // We circumvent this by stacking a new buffer, emitting into it, // and then echoing the result. - if (ob_get_level() > 0 && ob_get_length() > 0) { - ob_start(); - (new SapiEmitter())->emit($response); - echo ob_get_clean(); + if ($this->isOutputBufferDirty()) { + $this->emitWithStackedBuffer($response); return; } - (new SapiEmitter())->emit($response); + $this->emitter->emit($response); + } + + /** + * Check if the output buffer has existing content. + */ + protected function isOutputBufferDirty(): bool + { + return ob_get_level() > 0 && ob_get_length() > 0; + } + + /** + * Emit the response into a clean buffer to bypass SapiEmitter's strict checks. + */ + protected function emitWithStackedBuffer(ResponseInterface $response): void + { + ob_start(); + + try { + $this->emitter->emit($response); + } finally { + echo ob_get_clean(); + } } } diff --git a/tests/Unit/Http/ResponseEmitterTest.php b/tests/Unit/Http/ResponseEmitterTest.php index 1bc8e49..4bcbcdb 100644 --- a/tests/Unit/Http/ResponseEmitterTest.php +++ b/tests/Unit/Http/ResponseEmitterTest.php @@ -6,14 +6,14 @@ use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\Http\ResponseEmitter; use Laminas\Diactoros\Response\TextResponse; -use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; -use PHPUnit\Framework\Attributes\PreserveGlobalState; +use Laminas\HttpHandlerRunner\Emitter\EmitterInterface; +use Mockery; use phpmock\MockBuilder; -#[RunTestsInSeparateProcesses] -#[PreserveGlobalState(false)] class ResponseEmitterTest extends TestCase { + use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; + #[Test] public function emit_should_echo_body_when_headers_sent(): void { @@ -39,7 +39,7 @@ public function emit_should_echo_body_when_headers_sent(): void } #[Test] - public function emit_should_use_sapi_emitter_when_headers_not_sent(): void + public function emit_should_use_provided_emitter_when_headers_not_sent(): void { $builder = new MockBuilder(); $builder->setNamespace('Rareloop\Lumberjack\Http') @@ -50,43 +50,19 @@ public function emit_should_use_sapi_emitter_when_headers_not_sent(): void $mock = $builder->build(); $mock->enable(); - // SapiEmitter uses the global header() function. - // We mock it in the SapiEmitter's namespace to prevent it from actually sending headers - $headerBuilder = new MockBuilder(); - $headerBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') - ->setName('header') - ->setFunction(function () { - }); - $headerMock = $headerBuilder->build(); - $headerMock->enable(); - - // SapiEmitterTrait checks for namespaced headers_sent - $emitterHeadersSentBuilder = new MockBuilder(); - $emitterHeadersSentBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') - ->setName('headers_sent') - ->setFunction(function () { - return false; - }); - $emitterHeadersSentMock = $emitterHeadersSentBuilder->build(); - $emitterHeadersSentMock->enable(); - $response = new TextResponse('Hello World'); - $emitter = new ResponseEmitter(); + $innerEmitter = Mockery::mock(EmitterInterface::class); - ob_start(); - $emitter->emit($response); - $output = ob_get_clean(); + $innerEmitter->shouldReceive('emit')->once()->with($response)->andReturn(true); - // SapiEmitter echoes the body, so we should see 'Hello World' - $this->assertSame('Hello World', $output); + $emitter = new ResponseEmitter($innerEmitter); + $emitter->emit($response); $mock->disable(); - $headerMock->disable(); - $emitterHeadersSentMock->disable(); } #[Test] - public function emit_should_not_throw_exception_when_output_buffer_has_content_but_headers_not_sent(): void + public function emit_should_use_stacked_buffer_when_output_buffer_has_content_but_headers_not_sent(): void { $builder = new MockBuilder(); $builder->setNamespace('Rareloop\Lumberjack\Http') @@ -97,27 +73,17 @@ public function emit_should_not_throw_exception_when_output_buffer_has_content_b $mock = $builder->build(); $mock->enable(); - // SapiEmitter uses the global header() function. - $headerBuilder = new MockBuilder(); - $headerBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') - ->setName('header') - ->setFunction(function () { - }); - $headerMock = $headerBuilder->build(); - $headerMock->enable(); + $response = new TextResponse('Hello World'); + $innerEmitter = Mockery::mock(EmitterInterface::class); - // SapiEmitterTrait checks for namespaced headers_sent - $emitterHeadersSentBuilder = new MockBuilder(); - $emitterHeadersSentBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') - ->setName('headers_sent') - ->setFunction(function () { - return false; - }); - $emitterHeadersSentMock = $emitterHeadersSentBuilder->build(); - $emitterHeadersSentMock->enable(); + // We expect the inner emitter to be called. + // We'll make it echo something to verify it's captured by the stacked buffer + $innerEmitter->shouldReceive('emit')->once()->with($response)->andReturnUsing(function () { + echo 'Hello World'; + return true; + }); - $response = new TextResponse('Hello World'); - $emitter = new ResponseEmitter(); + $emitter = new ResponseEmitter($innerEmitter); // Start output buffering and put some content in it ob_start(); @@ -125,14 +91,12 @@ public function emit_should_not_throw_exception_when_output_buffer_has_content_b // We expect no exception now $emitter->emit($response); - + // Final output should contain both existing and new content $output = ob_get_clean(); $this->assertSame('Existing outputHello World', $output); $mock->disable(); - $headerMock->disable(); - $emitterHeadersSentMock->disable(); } } From 95bb89fd6890e99d9745946d1283125a33de2638 Mon Sep 17 00:00:00 2001 From: Rich Date: Wed, 20 May 2026 14:52:15 +0100 Subject: [PATCH 4/4] Update src/Http/ResponseEmitter.php Co-authored-by: Tom Mitchelmore --- src/Http/ResponseEmitter.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php index 5ed8bf2..f456e2f 100644 --- a/src/Http/ResponseEmitter.php +++ b/src/Http/ResponseEmitter.php @@ -8,9 +8,7 @@ class ResponseEmitter { - protected EmitterInterface $emitter; - - public function __construct(?EmitterInterface $emitter = null) + public function __construct(protected ?EmitterInterface $emitter = null) { $this->emitter = $emitter ?? new SapiEmitter(); }