diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 16fa7bc1b0f5..40b2fe064a9a 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -297,6 +297,8 @@ public function run(?RouteCollectionInterface $routes = null, bool $returnRespon $this->sendResponse(); + Events::trigger('post_response'); + return null; } diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index b3c0c88da326..3b052a8304e4 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -385,9 +385,34 @@ public function send() $this->sendCookies(); $this->sendBody(); + $this->finishResponse(); + return $this; } + private function finishResponse(): void + { + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } elseif (function_exists('litespeed_finish_request')) { + litespeed_finish_request(); + } elseif (! in_array(PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { + $this->closeOutputBuffers(); + flush(); + } + } + + private function closeOutputBuffers(): void + { + $status = ob_get_status(true); + $level = count($status); + $flags = PHP_OUTPUT_HANDLER_REMOVABLE | PHP_OUTPUT_HANDLER_FLUSHABLE; + + while ($level-- > 0 && ($s = $status[$level]) && ($s['del'] ?? (! isset($s['flags']) || ($s['flags'] & $flags) === $flags))) { + ob_end_flush(); + } + } + /** * Decides whether {@see ContentSecurityPolicy::finalize()} should run for * this response. Keeping the CSP class unloaded on requests that do not diff --git a/tests/_support/HTTP/Responses/ResponseWithPostSendFlag.php b/tests/_support/HTTP/Responses/ResponseWithPostSendFlag.php new file mode 100644 index 000000000000..980d1a6154bf --- /dev/null +++ b/tests/_support/HTTP/Responses/ResponseWithPostSendFlag.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\HTTP\Responses; + +use CodeIgniter\HTTP\Response; + +class ResponseWithPostSendFlag extends Response +{ + public $responseSent = false; + + /** + * Sends the output to the browser. + * + * @return $this + */ + public function send() + { + parent::send(); + + $this->responseSent = true; + + return $this; + } +} diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index 0feb97f555ca..0be17307c9ba 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\Debug\Timer; +use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Response; @@ -38,6 +39,7 @@ use PHPUnit\Framework\Attributes\WithoutErrorHandler; use Tests\Support\Filters\Customfilter; use Tests\Support\Filters\RedirectFilter; +use Tests\Support\HTTP\Responses\ResponseWithPostSendFlag; use Tests\Support\Router\Filters\TestAttributeFilter; /** @@ -1328,4 +1330,24 @@ public function testResetForWorkerModeDoesNotLoadCspWhenDisabled(): void $this->assertNull(RichRenderer::$css_nonce); $this->assertTrue(RichRenderer::$needs_pre_render); } + + public function testPostResponseTriggeredAfterSend(): void + { + $this->resetServices(); + + $response = new ResponseWithPostSendFlag(); + Services::injectMock('response', $response); + + $postResponseTriggeredAfterResponseSent = false; + + Events::on('post_response', static function () use (&$postResponseTriggeredAfterResponseSent, &$response): void { + $postResponseTriggeredAfterResponseSent = $response->responseSent; + }); + + ob_start(); + $this->codeigniter->run(); + ob_get_clean(); + + $this->assertTrue($postResponseTriggeredAfterResponseSent); + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index b03c3c4f3b23..29c89428f200 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -350,6 +350,7 @@ Others - **Filters:** Added ``RequestId`` filter for request tracing and correlation logging. The filter stores the request ID in the request context and automatically adds the ``X-Request-ID`` response header. Incoming ``X-Request-ID`` headers are used when valid. See :ref:`requestid` for details. - **Environment:** Added ``CodeIgniter\EnvironmentDetector`` class and corresponding ``environment`` service as a mockable wrapper around the ``ENVIRONMENT`` constant. Framework internals that previously compared ``ENVIRONMENT`` directly now go through this service, making environment-specific branches reachable in tests via ``Services::injectMock()``. See :ref:`environment-detector-service`. +- **Events**: Added ``post_response`` event to run after the response is sent to the client. - **Time:** Added ``Time::between()``, ``Time::min()``, and ``Time::max()`` comparison helpers. See :ref:`between `, :ref:`min `, and :ref:`max `. *************** diff --git a/user_guide_src/source/extending/events.rst b/user_guide_src/source/extending/events.rst index b628247e1dbe..2f449f31e860 100644 --- a/user_guide_src/source/extending/events.rst +++ b/user_guide_src/source/extending/events.rst @@ -99,6 +99,10 @@ invoked by **public/index.php**: * **post_controller_constructor** Called immediately after your controller is instantiated, but prior to any method calls happening. * **post_system** Called right before the final rendered page is sent to the browser, at the end of system execution, after the execution of "after" controller filters. +* **post_response** Called after the response is sent to the client. This event is useful + for performing tasks that do not need to be completed before the response is sent, such as logging or cleanup tasks. + +.. note:: The ``post_response`` event was added in v4.8.0. .. _event-points-for-cli-apps: