# RFC: Request lifecycle hooks for SAPIs with a worker mode

- Status: Under discussion
- Authors: Kévin Dunglas, kevin@dunglas.fr
- Date: 2026-11-09
- Version: 0.1
- Implementation: https://github.com/dunglas/php-src/tree/request-boundary-hooks
- Original: https://wiki.php.net/rfc/request_lifecycle_hooks
- Page: https://externals.io/rfc/request_lifecycle_hooks
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

## Votes

### Add the request lifecycle hooks and the related engine functions as outlined in the RFC? (closed)

Yes: 0 · No: 0 · Abstain: 0

## Discussions on the mailing list

- [RFC proposal: worker mode primitives for SAPIs](https://externals.io/message/122027.md) — started on 2023-12-23, 49 messages
- [Request lifecycle hooks for SAPIs that serve multiple requests per script (like FrankenPHP in worker mode)](https://externals.io/message/132960.md) — started on 2026-10-29, 8 messages
- [\[RFC\] Request lifecycle hooks for SAPIs with a worker mode](https://externals.io/message/133044.md) — started on 2026-11-09, 1 messages

---

## Introduction

[FrankenPHP](https://frankenphp.dev) and some other SAPIs (like [Turbine](https://github.com/turbine-php/turbine) and [bext](https://bext.dev)) have optional support for a ["worker mode"](https://frankenphp.dev/docs/worker/). The main idea behind these worker modes is to use long-running scripts, started when the web server starts, to handle multiple HTTP requests in a loop.

This allows keeping states in memory across requests, such as the Composer autoloader or framework services, instead of re-computing them at the beginning of each new request. This also allows maintaining persistent connections to remote services across requests.

Worker mode dramatically improves performance, at the cost of no longer isolating state between requests and losing the memory leak tolerance provided by the traditional “fire and forget” model. This mode has become increasingly popular since the release of FrankenPHP, and is now natively supported by a large part of the ecosystem (Symfony, Laravel, Yii...).

Currently, the engine and extensions have no knowledge of this, and have no way to detect when a “real” HTTP request starts or ends. As expected, they are only aware of the start (and end) of the main, long-running script. RINIT and RSHUTDOWN run once per script execution, so they only run when the worker script starts and ends.

FrankenPHP (and other SAPIs with a similar mode) reset “logical HTTP request” related data by [poking into the engine and first-party extensions](https://github.com/php/frankenphp/blob/7f50be9d86261bf7cff9b051ab4fb572e95020ed/frankenphp.c#L400-L646). This approach is fragile, and the internal code resetting these states can change without warning in a new PHP version. Each omission results in a cross-request leak or a memory safety bug. FrankenPHP fixed several of them this year:

- a [session ID leaking to other clients](https://github.com/php/frankenphp/security/advisories/GHSA-v3ph-cgqh-r8p5) when ext/session is a shared module, because FrankenPHP can only reset the session globals when it is built against the session headers;
- a [use-after-free](https://github.com/php/frankenphp/security/advisories/GHSA-cm3q-jw2q-9rh5) of `php://temp` streams, because nothing releases `SG(request_info).request_body` between worker requests and FrankenPHP's own cleanup freed streams it didn't own;
- a [double free](https://github.com/php/frankenphp/security/advisories/GHSA-h764-4wh7-m7j2) because `sapi_deactivate()` frees `SG(request_info).content_type_dup` and `current_user` without clearing them ([fixed for PHP 8.6](https://github.com/php/php-src/pull/22974)).

Every worker SAPI copies its own subset of `php_request_shutdown()` and `php_request_startup()`, and each subset misses different things:

- the [worker API of ext-php-rs](https://github.com/extphprs/ext-php-rs/blob/33ae68257daa3c4b4159bf595d65e3166f97877d/src/embed/worker.c), which Rust SAPIs build on, ends and restarts the output layer and the SAPI, and arms the superglobals again;
- [Turbine](https://github.com/turbine-php/turbine/blob/4ae7bf48ab8564fc4477c1709a0cbea062cf7a4d/crates/turbine-php-sys/src/turbine_worker_lifecycle.c#L48-L127) does the same and writes the session;
- [bext](https://github.com/bext-stack/bext-php/blob/bf3ada98c76768dcca28e74d45294d8196fff22e/sapi/bext_php_sapi.c#L213-L280) only rebuilds the superglobals and resets the response status.

Third-party extensions detect the boundaries themselves, each in its own way. Xdebug ([debugger](https://github.com/xdebug/xdebug/blob/64007df3a0925808022fb87b6c6f06febf058ee2/src/debugger/frankenphp.c#L39-L135), [base](https://github.com/xdebug/xdebug/blob/64007df3a0925808022fb87b6c6f06febf058ee2/src/base/base.c#L741-L751)) and the [Datadog profiler](https://github.com/DataDog/dd-trace-php/pull/3169) replace `sapi_module.activate` and `sapi_module.deactivate` when the SAPI is FrankenPHP. Datadog's [tracer](https://github.com/DataDog/dd-trace-php/blob/36043dd95db49ac459d3b66998ed5593a56a907e/src/DDTrace/Integrations/Frankenphp/FrankenphpIntegration.php#L31-L132) and [AppSec](https://github.com/DataDog/dd-trace-php/blob/36043dd95db49ac459d3b66998ed5593a56a907e/appsec/src/extension/request_lifecycle.c#L72-L97), [Tideways](https://support.tideways.com/documentation/setup/installation/frankenphp.html), and New Relic in open pull requests ([\#1306](https://github.com/newrelic/newrelic-php-agent/pull/1306), [\#1328](https://github.com/newrelic/newrelic-php-agent/pull/1328)), hook `frankenphp_handle_request()`. [Blackfire's Symfony bridge](https://github.com/blackfireio/php-sdk/blob/66e590415e9ec0fe3b573c98699f28e157502baf/src/Blackfire/Bridge/Symfony/FrankenPHPProfiler.php#L32-L104) and [Sentry](https://github.com/getsentry/sentry-php/blob/3df29e461b84626332418234cad437cfa485c07e/CHANGELOG.md#L328-L350) wrap each request with start and stop calls in userland. None of them supports the other worker SAPIs.

Calling RSHUTDOWN and RINIT between worker requests doesn't work: these handlers mix state that needs resetting with state the script keeps. For instance, the standard extension's RSHUTDOWN resets the URL rewriter, but also destroys `register_tick_function()` callbacks and user stream filters. The session extension's RSHUTDOWN writes and closes the session, but also frees the save handlers registered at boot. Only each extension's maintainers know which of their globals belong to which kind.

## Proposal

This RFC adds two optional module hooks, called by the SAPI between two logical requests of the same script, and the engine functions SAPIs use at these boundaries. Core extensions implement the hooks first; third-party extensions can adopt them at their pace. SAPIs that don't call these functions see no behavior change.

### What gets reset

When opting into worker mode, users opt into shared state. Not sharing some state unrelated to the HTTP request would be surprising, so the rule for using these hooks is narrow: reset only the state built from the incoming request or tied to its response. The superglobals (except `$_ENV`), the request body, the session, and the output state, including the URL rewriter used by `output_add_rewrite_var()`, fall under it.

Other state doesn't depend on the incoming HTTP request, so it's shared between “logical” requests, as in a long-running CLI script: `mt_rand()`, `strtok()`, `http_get_last_response_headers()`, the libxml errors, `error_get_last()`, `putenv()` changes, the stat cache. Like any other state shared in worker mode, userland can clear it when needed with the existing functions (`http_clear_last_response_headers()`, `libxml_clear_errors()`, `error_clear_last()`...).

New code should avoid adding global state. When it holds state built from the request, it should release it in `request_end`.

The hooks must not free anything still reachable from userland: objects, resources, registered callbacks, user stream wrappers and filters, autoloaders. Settings changed with `ini_set()` and the default timezone are kept, as worker runtimes document today. Process-wide state (`umask()`, `setlocale()`) is out of scope: it can't be made per-request in threaded SAPIs.

### Module hooks

- `request_end`: release the state tied to the finished request, but keep everything still reachable from userland. Output buffers are flushed and headers are sent when it runs.
- `request_begin`: set up the next request once its headers and superglobals are available (`session.auto_start`, Xdebug's triggers).

`zend_module_entry` gets two trailing fields, `request_begin_func` (`INIT_FUNC_ARGS`) and `request_end_func` (`SHUTDOWN_FUNC_ARGS`). Extensions declare them with `PHP_RBEGIN_FUNCTION()` and `PHP_REND_FUNCTION()`, and set them with `STANDARD_MODULE_PROPERTIES_REQUEST_HOOKS(begin, end)`. `STANDARD_MODULE_PROPERTIES_EX` sets both to `NULL`, so existing extensions compile unchanged. Like RINIT and RSHUTDOWN, the hooks live in the module entry: zend\_extensions that need them, like Xdebug, already register one.

With these hooks, debuggers, profilers and APMs can tell the “main” request (the worker script, from RINIT to RSHUTDOWN) apart from the logical requests it serves. Xdebug, for instance, could offer both: debug the code that boots the application and runs between logical requests, or debug a single logical request, started by the triggers of that request. It's already possible today, but hacky. SAPIs without a worker mode don't call the hooks: there, RINIT and RSHUTDOWN still mark both the main and the logical request.

```c
PHP_RINIT_FUNCTION(myprof)
{
	/* the main request starts: in worker mode, the worker script boots */
	return SUCCESS;
}
 
PHP_RBEGIN_FUNCTION(myprof)
{
	/* a logical request starts: read its triggers from $_GET or $_COOKIE */
	return SUCCESS;
}
 
PHP_REND_FUNCTION(myprof)
{
	/* the response is sent: send the profile of this logical request */
	return SUCCESS;
}
```

```c
PHP_REND_FUNCTION(myext)
{
	/* flush and release the data collected for the finished request */
	return SUCCESS;
}
 
zend_module_entry myext_module_entry = {
	STANDARD_MODULE_HEADER,
	"myext",
	NULL,
	PHP_MINIT(myext),
	NULL,
	NULL,
	NULL,
	NULL,
	PHP_MYEXT_VERSION,
	NO_MODULE_GLOBALS,
	NULL,
	STANDARD_MODULE_PROPERTIES_REQUEST_HOOKS(NULL, PHP_REND(myext))
};
```

### Engine functions

`php_request_end()` and `php_request_begin()` run the output and SAPI steps that worker SAPIs copy today from `php_request_shutdown()` and `php_request_startup()`, import the superglobals of the new request, set the execution timer, and call the hooks.

- `php_request_end()` follows the order of `php_request_shutdown()` without ending the script: flush the output buffers, stop the execution timer, call `request_end_func` in reverse module order, deactivate the output layer, run `sapi_deactivate()` and release the request body.
- `php_request_begin()` releases the superglobals of the finished request, activates the output layer and the SAPI, starts the execution timer and the default output handler, imports the superglobals of the new request, then calls `request_begin_func` in module order. The SAPI fills `SG(request_info)` and `SG(server_context)` before calling it.

`php_request_begin()` imports the superglobals itself: every worker SAPI does this step today, and the `request_begin` hooks need them. `$_ENV` comes from the process, not the request, so the script keeps it and its changes, as FrankenPHP does today. The other superglobals the script already uses are imported again; the others are armed for just-in-time creation.

The script still reads the superglobals of the finished request after `php_request_end()`, as [worker runtimes document today](https://frankenphp.dev/docs/worker/): code running between two requests, like a framework's terminate step, often needs them. `php_request_begin()` releases them, `$_SESSION` included, before the output of the next request is active, so the destructors of the objects stored in them never write to the next client's response. Data a destructor puts back into a superglobal is released too. SAPIs whose script loops in userland, like FrankenPHP, can call `php_release_request_globals()` when the script asks for the next request, before waiting for it. The destructors then run while the worker is idle instead of delaying the next request, the resources held by the finished request are freed right away instead of when the next request arrives, and an exception thrown by a destructor reaches the script before a request is accepted.

Releasing the request body takes two small changes: `php://input` streams hold a reference to the body, and freeing the body, for instance when the script closes it, clears `SG(request_info).request_body`.

A worker SAPI then looks like this:

```c
while (next_request()) {
	/* fill SG(request_info) and SG(server_context) */
	php_request_begin();
	/* call the userland handler */
	php_request_end();
}
```

### Core extensions

| Extension | `request_end` | `request_begin` |
|---|---|---|
| standard | resets the URL rewriter |  |
| session | writes and closes the session, resets its id, data and status; keeps the save handlers | `session.auto_start` |
| filter | frees its copies of the request input |  |

Without the URL rewriter reset, `output_add_rewrite_var()` never restarts its output handler after the first request, and its variables accumulate. Without the filter reset, its copies of the request input leak on each request: `sapi_activate()` drops them without freeing them.

## Interaction with the Scope RFC

The [Scope RFC](https://externals.io/rfc/scope) and this proposal solve different problems.

The Scope RFC gives an identity to requests running concurrently in the same process. It doesn't provide a point where a request ends: a scope lives as long as something references it, which can be long after the request is finished. It also only covers the superglobals, the session, the default locale and the default timezone.

This proposal is a small internal refactoring that lets each extension release its own request state. It has no impact on userland code and requires no change to it. Worker modes are already widely used and will remain even if the Scope RFC passes, at least for backward compatibility.

Scope will also have to deal with what this proposal solves: letting extensions detect when the response has been sent, and resetting the state tied to the request (sessions, as explained in the Scope RFC, output buffers, the URL rewriter...).

The two can be combined: once Scope is accepted and implemented, `php_request_begin()` will create a new scope, and SAPIs will get it for free. When the HTTP response is finished, SAPIs call `php_request_end()`, so extensions have a hook to do what they want when a request terminates, even if the scope survives because it is still referenced.

For instance, with FrankenPHP:

```php
<?php
$scope = null;
while (frankenphp_handle_request(function () use (&$scope) {
    // php_request_begin() has been called by the SAPI: this callback runs in a new scope
    $scope = Scope::getCurrent(); // leaked on purpose
})) {
    // php_request_end() has been called by the SAPI: the session is written
    // and extensions flushed their data, even though $scope is still alive
}
```

The userland and engine lifetimes stay in sync because the SAPI drives both. Both proposals work well together, and each can ship at its own pace, regardless of the result of the other's vote.

## Which SAPIs can use it

- SAPIs that serve the requests of a worker one after the other (FrankenPHP, Turbine, bext, the ext-php-rs worker API) can replace their copies with `php_request_end()` and `php_request_begin()`. As with `php_request_startup()`, they fill `SG(request_info)` and set `SG(server_context)` before calling it.
- Runtimes that run a userland loop on the CLI SAPI (RoadRunner, Swoole, Workerman) don't own the SAPI, so they can't call these functions.
- SAPIs that run one script per request (FPM, Apache, NGINX Unit) don't need the hooks and see no change.

## Backward Incompatible Changes

None. SAPIs that don't call the new functions see no behavior change, and existing extensions compile unchanged.

## Proposed PHP Version(s)

PHP 8.7.

## RFC Impact

### To SAPIs

See above. In FrankenPHP, this removes the copy of the session reset code, the URL rewriter reset, the RSHUTDOWN/RINIT allow-list, and most of the copies of `php_request_shutdown()` and `php_request_startup()`.

### To Existing Extensions

Opt-in. Extensions that handle worker modes today (Xdebug, Datadog, Tideways, New Relic...) get one abstraction that works with every SAPI that has a worker mode, instead of special-casing FrankenPHP. Moving to it also gives them support for the other worker SAPIs, like the Rust ones, at the same time.

### To the Ecosystem

None for userland code.

## Open Issues

None.

## Future Scope

None.

## Voting Choices

Add the request lifecycle hooks and the related engine functions: yes/no, 2/3 majority.

**Vote: Add the request lifecycle hooks and the related engine functions as outlined in the RFC?** — Yes: 0 · No: 0 · Abstain: 0 (closed)

## Patches and Tests

Patch for master, with the standard, session and filter hooks: <https://github.com/dunglas/php-src/tree/request-boundary-hooks>

FrankenPHP using the hooks: <https://github.com/php/frankenphp/compare/main...experiment/php-request-boundary-hooks>

## References

- [Pre-RFC discussion](https://news-web.php.net/php.internals/132960)
- [Previous, broader proposal](https://externals.io/message/122027)
- [Scope RFC](https://externals.io/rfc/scope)
