skip to content

In PHP, what changes when an application runs in worker mode under FrankenPHP, RoadRunner or Swoole instead of one fresh request per script under PHP-FPM?

level: middleimportance: should knowfreq 32%

answer

  1. boot once, serve many
  2. container and config stay in memory
  3. no end-of-request cleanup between requests
  4. statics and singletons survive
  5. request object or refreshed superglobals

basics

~20 s

Worker mode boots the application once per worker and then loops, handling many requests in the same PHP process. Bootstrap cost disappears from each request, but the engine's end-of-request cleanup no longer runs between them, so statics, singletons and connections carry over.

solid answer

~40 s

Under PHP-FPM every request runs the front controller from scratch: autoloader, configuration, service container and routes are rebuilt, and at the end the engine destroys globals, runs destructors and shutdown functions and restores `ini_set()` values. In worker mode a runtime such as FrankenPHP, RoadRunner or Swoole starts a PHP worker that boots once and then sits in a loop receiving requests: FrankenPHP calls your handler through `frankenphp_handle_request()`, RoadRunner and Swoole pass a request object to your code. Each request skips the bootstrap, which can cut latency noticeably for framework-heavy apps. The price is that the whole worker lifetime is one PHP request as far as the engine is concerned, so anything stored in a static property, a singleton service or a global survives into the next request, and code must reset it deliberately.

code

php · 14 lines
php
<?php
declare(strict_types=1);
// FrankenPHP worker script: boot once, then loop.
require __DIR__ . '/vendor/autoload.php';

$app = App\Kernel::boot();          // container, config, routes: once

$handler = static function () use ($app): void {
    $app->handle();                  // superglobals are fresh for this request
};

while (frankenphp_handle_request($handler)) {
    $app->resetRequestState();       // your job now, not the engine's
}

go deeper

for a junior

Recall the idea: the app boots once and one PHP worker serves many requests, instead of starting from scratch for every request.

for a middle

Explain which end-of-request steps stop happening between requests, and what that means for statics, singletons, connections and ini settings.

for a senior

Judge where the latency gain comes from, measure bootstrap share before switching, and list the leak, memory and stale-connection risks you will have to manage.

for a principal

Weigh faster requests against a new class of cross-request bugs, and decide which services are safe to move and which should stay share-nothing.

## Two ways to run the same PHP app Classic PHP deployments, typically **PHP-FPM** behind a web server, run each HTTP request as its own short PHP request: the front controller starts, the app bootstraps, the response is produced, and the engine throws everything away. **Worker mode** keeps the application in memory instead. A runtime starts a set of long-lived PHP workers; each one **boots the app once** and then handles request after request. Three runtimes dominate the PHP worker-mode landscape: - **FrankenPHP** — an application server with PHP embedded; in worker mode your worker script loops and calls `frankenphp_handle_request()` with a handler callback for each incoming request. - **RoadRunner** — an application server that manages PHP CLI worker processes and sends each request to one of them; your worker script waits for a request object, handles it and sends a response back. - **Swoole** — a PHP extension that turns a CLI script into an event-driven server; you register a request callback and it is invoked for every request. In all three, several workers run side by side and the runtime spreads incoming requests across them. Throughput comes from the number of workers; each FrankenPHP or RoadRunner worker still handles its own requests one after another, while Swoole can additionally interleave requests inside one worker with its coroutines. ## What the engine no longer does between requests When a PHP request ends, the engine runs a fixed shutdown sequence in `php_request_shutdown()`: 1. calls the functions registered with `register_shutdown_function()`; 2. calls `__destruct()` on remaining objects; 3. flushes and closes output buffers; 4. destroys the superglobals; 5. restores every ini value changed with `ini_set()`; 6. frees request-bound memory. In worker mode that sequence runs when the **worker** stops, not after each HTTP request. The runtime rebuilds only what it needs for the next request, such as fresh superglobals in FrankenPHP or a new request object in RoadRunner and Swoole. Everything else stays. ## Side by side | Aspect | PHP-FPM, per request | Worker mode | |---|---|---| | Bootstrap (autoload, container, config, routes) | every request | once per worker | | Static properties and singletons | fresh each request | carried over | | Database connections | opened per request (unless persistent) | usually kept open | | `ini_set()`, locale, error handlers | reset at request end | persist until changed | | Memory growth | discarded after each request | accumulates across requests | | A fatal error or `exit` | ends one request | ends the worker, which must be replaced | | How code reads the request | superglobals | runtime-specific: refreshed superglobals or a request object | ## What you gain - **Lower latency and CPU per request**, because the bootstrap work, often a large share of a framework request, is paid once. - **Warm in-memory state**: compiled routes, a built container and open connections are ready immediately. - **Room for long-lived features** some runtimes offer on top, such as WebSockets or background tasks in Swoole. ## What you take on - **State leaks**: a value cached for one user in a static property can be served to the next user. - **Memory growth**: anything appended to a long-lived array grows without bound, so workers are usually recycled after a number of requests. - **Stale resources**: a database connection opened at boot can be closed by the server hours later. - **Different built-in behaviour**: `exit`, shutdown functions and `ini_set()` no longer map to one HTTP request. ## When the classic model is simpler Per-request execution is the reason PHP code rarely worries about leaks: every request starts clean. For small apps, apps where the database dominates response time, or legacy code full of globals and statics, the gain from worker mode may not repay the audit it needs. A fresh process per request is also a safety property: one request can never observe another's data, however careless the code. For framework-heavy APIs with fast queries, where bootstrap is a large slice of each request, worker mode is often worth it, provided the code is written, or reset, for a process that never forgets.

  • Does worker mode make PHP code run concurrently inside one worker?
    Not by itself. A FrankenPHP or RoadRunner worker handles one request at a time; throughput comes from running many workers side by side. Swoole can interleave requests inside one worker with its coroutines, which adds the extra hazard of two requests sharing state at the same moment, not only one after the other.
  • Why can a single fatal error cost more in worker mode than under PHP-FPM?
    Under FPM a fatal error ends one request and the next request starts fresh anyway. In worker mode it ends the whole worker, so the runtime must start a new one and pay the boot again; a bug that fatals on a common path turns into constant worker restarts that erase the performance gain and can drop in-flight requests.

PHP-FPM is a hotel room cleaned after every guest; worker mode is a furnished office where one visitor after another sits at the same desk. Visitors start faster, but whatever the last one left on the desk is still there unless someone clears it.

saying these in an interview costs you the question

  • Worker mode is just PHP-FPM with more child processes
  • The engine still resets statics after every HTTP request
  • Worker mode only helps if the database is the bottleneck
  • Superglobals work identically in every worker-mode runtime
  • Worker mode means requests run in parallel inside one worker