# Request lifecycle hooks for SAPIs that serve multiple requests per script (like FrankenPHP in worker mode)

- Thread: https://externals.io/message/132960
- Original archive: https://news-web.php.net/php.internals/132960
- Started by kevin@dunglas.fr on 2026-10-29T17:47:58+00:00
- Messages: 5
- Reply by email to internals@lists.php.net
- 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

---

## #132960 — kevin@dunglas.fr — 2026-10-29T17:47:58+00:00

URL: https://externals.io/message/132960#132960

Hello internals,

As you may know, FrankenPHP and some other SAPIs (like the
experimental ones written in Rust) have optional support for a "worker
mode". 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 Symfony and Laravel 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. FrankenPHP (and other SAPIs with a similar mode)
reset "real 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 when ext/session is a shared
module, because FrankenPHP can only reset the session globals when it
is built against the session headers:
https://github.com/php/frankenphp/security/advisories/GHSA-v3ph-cgqh-r8p5
- a use-after-free 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:
https://github.com/php/frankenphp/security/advisories/GHSA-cm3q-jw2q-9rh5
- a double free 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):
https://github.com/php/frankenphp/security/advisories/GHSA-h764-4wh7-m7j2
- `http_get_last_response_headers()` returning headers fetched by a
previous request:
https://github.com/php/frankenphp/security/advisories/GHSA-pmvg-8483-mw84
- `libxml_get_errors()` returning errors of a previous request, with
parse-error fragments and server paths:
https://github.com/php/frankenphp/security/advisories/GHSA-4gfw-9v29-gp9h

Third-party extensions detect the boundaries themselves, each in its
own way. Xdebug and the Datadog profiler replace sapi_module.activate
and sapi_module.deactivate when the SAPI is FrankenPHP. Datadog's
tracer and AppSec, and New Relic in an open pull request, hook
frankenphp_handle_request(). Blackfire's Symfony bridge, Tideways and
Sentry wrap each request with start and stop calls in userland.

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 releases the
 `strtok()` state, 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. The libxml extension's RSHUTDOWN drops the external entity
loader. Only each extension's maintainers know which of their globals
belong to which kind.

I'd like to add two optional module hooks, called by the SAPI between
two logical requests of the same script:

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

Two engine functions, php_request_end() and php_request_begin(), would
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 (except $_ENV, which comes from the
process), set the execution timer, and call the hooks.

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. Core extensions would implement the
hooks first; third-party extensions could adopt them at their pace.
SAPIs that don't call these functions see no behavior change.

I drafted a working patch for master, with the standard and session
hooks: https://github.com/dunglas/php-src/tree/request-boundary-hooks.
FrankenPHP doesn't build against master yet, so I tested it with a
patched version of FrankenPHP and it works.

As this is an internal API with no userland or BC impact, an RFC isn't
strictly needed.

I made a similar but broader proposal almost 3 years ago
(https://externals.io/message/122027). I think time has shown that
worker modes are here to stay, and this less intrusive proposal could
fix several maintenance issues affecting the whole ecosystem.

So, I would like your opinion on this before working more on it.

Best,
-- 
Kévin Dunglas

https://dunglas.dev

---

## #132961 — Pierre — 2026-10-29T18:01:57+00:00

URL: https://externals.io/message/132960#132961 · In reply to: #132960

Le 29/10/2026 à 18:47, Kévin Dunglas a écrit :
> Hello internals,

Hello,

I see the point and I feel it's indeed at some point becomes a 
requirement for long running servers, yet to be honest this not my area 
of expertise, but shouldn't the need be covered by the Scope RFC 
https://wiki.php.net/rfc/scope ?

The chosen path is different, but the goal seems to be the same in the end.

Regards,

Pierre

---

## #132962 — David Maye — 2026-10-29T18:20:51+00:00

URL: https://externals.io/message/132960#132962 · In reply to: #132960



---

## #132968 — kevin@dunglas.fr — 2026-10-29T22:52:40+00:00

URL: https://externals.io/message/132960#132968 · In reply to: #132961

Hello Pierre,

> but shouldn't the need be covered by the Scope RFC https://wiki.php.net/rfc/scope

Not really, they 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: as
the RFC says, a scope doesn't finish when run() returns. It also only
covers the superglobals, the session, the default locale and the
default timezone.

My proposal is a small internal refactoring that adds two optional
hooks, called between two requests served one after the other by the
same script. They let each extension release its own request state,
which today worker SAPIs reset by poking into the extensions' globals.
The bugs we fixed in FrankenPHP were in state the Scope RFC doesn't
cover: `strtok()`, the last HTTP response headers, libxml errors, the
request body.

It's a quality of life improvement for the existing ecosystem (SAPIs
with a worker mode and the extensions supporting them). Unlike the
Scope RFC, 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.

The two proposals are complementary. If both are accepted, the parts
touching the superglobals and the session would need to work together,
but nothing conflicts.

Best


>
> Le 29/10/2026 à 18:47, Kévin Dunglas a écrit :
> > […]
>
> Hello,
>
> I see the point and I feel it's indeed at some point becomes a
> requirement for long running servers, yet to be honest this not my area
> of expertise, but shouldn't the need be covered by the Scope RFC
> https://wiki.php.net/rfc/scope ?
>
> The chosen path is different, but the goal seems to be the same in the end.
>
> Regards,
>
> Pierre

---

## #132969 — kevin@dunglas.fr — 2026-10-29T22:53:03+00:00

URL: https://externals.io/message/132960#132969 · In reply to: #132962

Hi David,

> Doesn't PHP with ZTS (Zend Thread Safety) offers the same capabilities?

No, it's a different thing. ZTS gives each thread its own copy of the
engine and extension globals, so several threads can run requests in
parallel in the same process. But in both models, RINIT and RSHUTDOWN
run once per script execution.

In worker mode, a thread runs one script that handles many requests in
a loop, so RINIT and RSHUTDOWN only run when that script starts and
ends. ZTS doesn't change that.  By the way, FrankenPHP always uses
ZTS, because it's written in Go, and Go relies heavily on threads
(goroutines rely on system threads under the hood).

Best


>
> Hello Kévin,
>
> I have a question. Doesn't PHP with ZTS (Zend Thread Safety) offers the same capabilities?
>
> I remember I read something about it. What you described reminded me of it. Although as I'm not an expert in the area I can't tell.
>
> The following link is from the PHP internal book for PHP lifecycle. (Although it says php7, I understand that it works the same for PHP8).
> https://www.phpinternalsbook.com/php7/extensions_design/php_lifecycle.html
>
> I sent a screenshot of the page in attachment.
>
> I'm curious if this has to do with the question.
>
> Kind regards,
> David Maye
>
>
> 
>
> Hello internals,
>
> As you may know, FrankenPHP and some other SAPIs (like the
> experimental ones written in Rust) have optional support for a "worker
> mode". 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 Symfony and Laravel 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. FrankenPHP (and other SAPIs with a similar mode)
> reset "real 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 when ext/session is a shared
> module, because FrankenPHP can only reset the session globals when it
> is built against the session headers:
> https://github.com/php/frankenphp/security/advisories/GHSA-v3ph-cgqh-r8p5
> - a use-after-free 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:
> https://github.com/php/frankenphp/security/advisories/GHSA-cm3q-jw2q-9rh5
> - a double free 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):
> https://github.com/php/frankenphp/security/advisories/GHSA-h764-4wh7-m7j2
> - `http_get_last_response_headers()` returning headers fetched by a
> previous request:
> https://github.com/php/frankenphp/security/advisories/GHSA-pmvg-8483-mw84
> - `libxml_get_errors()` returning errors of a previous request, with
> parse-error fragments and server paths:
> https://github.com/php/frankenphp/security/advisories/GHSA-4gfw-9v29-gp9h
>
> Third-party extensions detect the boundaries themselves, each in its
> own way. Xdebug and the Datadog profiler replace sapi_module.activate
> and sapi_module.deactivate when the SAPI is FrankenPHP. Datadog's
> tracer and AppSec, and New Relic in an open pull request, hook
> frankenphp_handle_request(). Blackfire's Symfony bridge, Tideways and
> Sentry wrap each request with start and stop calls in userland.
>
> 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 releases the
> `strtok()` state, 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. The libxml extension's RSHUTDOWN drops the external entity
> loader. Only each extension's maintainers know which of their globals
> belong to which kind.
>
> I'd like to add two optional module hooks, called by the SAPI between
> two logical requests of the same script:
>
> - request_end: release the state tied to the finished request, but
> keep everything still reachable from userland;
> - request_begin: set up the next request once its headers and
> superglobals are available (session.auto_start, Xdebug's triggers).
>
> Two engine functions, php_request_end() and php_request_begin(), would
> 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 (except $_ENV, which comes from the
> process), set the execution timer, and call the hooks.
>
> 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. Core extensions would implement the
> hooks first; third-party extensions could adopt them at their pace.
> SAPIs that don't call these functions see no behavior change.
>
> I drafted a working patch for master, with the standard and session
> hooks: https://github.com/dunglas/php-src/tree/request-boundary-hooks.
> FrankenPHP doesn't build against master yet, so I tested it with a
> patched version of FrankenPHP and it works.
>
> As this is an internal API with no userland or BC impact, an RFC isn't
> strictly needed.
>
> I made a similar but broader proposal almost 3 years ago
> (https://externals.io/message/122027). I think time has shown that
> worker modes are here to stay, and this less intrusive proposal could
> fix several maintenance issues affecting the whole ecosystem.
>
> So, I would like your opinion on this before working more on it.
>
> Best,
> --
> Kévin Dunglas
>
> https://dunglas.dev
