{"id":619,"date":"2026-08-26T13:03:24","date_gmt":"2026-08-26T13:03:24","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/frankenphp-worker-mode-the-static-that-outlived-my-request\/"},"modified":"2026-08-26T13:03:24","modified_gmt":"2026-08-26T13:03:24","slug":"frankenphp-worker-mode-the-static-that-outlived-my-request","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/frankenphp-worker-mode-the-static-that-outlived-my-request\/","title":{"rendered":"FrankenPHP Worker Mode and the Static That Outlived My Request"},"content":{"rendered":"<p>I moved a small internal Laravel app onto FrankenPHP on a Thursday, mostly because the deploy was one Docker command and I wanted to feel clever about it. Friday afternoon a colleague sent me a screenshot of the dashboard with the wrong company name on it. Not a wrong record. The correct record, with a company name belonging to a different tenant, sitting in a static property I wrote sometime in 2023 and had not thought about since.<\/p>\n<p>So, worker mode. It is genuinely fast, and the exact thing that makes it fast is what broke my app. The PHP process stops dying between requests. Everything the process used to clean up by simply exiting is now mine to clean up on purpose.<\/p>\n<p>I have been running it in production for a while now and I would do it again. But I want to write down what I got wrong, because none of it showed up in the benchmark posts I read first. Those posts are all requests per second. The interesting part is what leaks.<\/p>\n<h2 id=\"what-changes-when-the-process-stops-dying\">What changes when the process stops dying<\/h2>\n<p>Classic PHP is shared nothing, and that is a feature nobody talks about because it is invisible. A request arrives, PHP-FPM hands it to a worker, your script runs, the worker tears the whole thing down. Every global, every static, every accidental singleton, gone. You can write sloppy state management for years and never find out, because process death is covering for you a few hundred times a minute.<\/p>\n<p>Worker mode inverts it. FrankenPHP boots your application once and then sits in a loop waiting for requests. The <a href=\"https:\/\/frankenphp.dev\/docs\/worker\/\" rel=\"nofollow noopener\" target=\"_blank\">official worker documentation<\/a> shows the shape of it clearly if you write your own script:<\/p>\n<pre><code class=\"language-php\">&lt;?php\n\/\/ public\/index.php\nrequire __DIR__.'\/vendor\/autoload.php';\n\n$myApp = new \\App\\Kernel();\n$myApp-&gt;boot();          \/\/ once, at startup\n\n$handler = static function () use ($myApp) {\n    echo $myApp-&gt;handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);\n};\n\n$maxRequests = (int)($_SERVER['MAX_REQUESTS'] ?? 0);\nfor ($n = 0; !$maxRequests || $n &lt; $maxRequests; ++$n) {\n    $keepRunning = \\frankenphp_handle_request($handler);\n    $myApp-&gt;terminate();\n    gc_collect_cycles();\n    if (!$keepRunning) break;\n}\n<\/code><\/pre>\n<p>Look at where <code>boot()<\/code> sits. Outside the loop. That line runs once for the lifetime of the worker, and there are several workers. FrankenPHP starts two per CPU by default, so on a modest four core box you have eight independent copies of your booted application, each with its own memory, each handling requests in rotation. Whatever you stuffed into memory during request one is waiting there for request forty.<\/p>\n<p>That is the mental model I did not have on Thursday. I was thinking of it as a faster PHP-FPM. It is not a faster anything. It is a different runtime with the same syntax, which is a much stranger thing to be handed.<\/p>\n<h2 id=\"the-static-cache-that-was-correct-for-four-years\">The static cache that was correct for four years<\/h2>\n<p>Here is the shape of what bit me, cleaned up a little and with the client&rsquo;s names removed.<\/p>\n<pre><code class=\"language-php\">class TenantSettings\n{\n    private static ?array $cache = null;\n\n    public static function current(): array\n    {\n        \/\/ &quot;It's per-request anyway, why hit the DB twice&quot;\n        return self::$cache ??= DB::table('tenant_settings')\n            -&gt;where('tenant_id', auth()-&gt;user()-&gt;tenant_id)\n            -&gt;first(['name', 'currency', 'locale']);\n    }\n}\n<\/code><\/pre>\n<p>Under PHP-FPM that code is fine. Slightly lazy, but fine. <code>self::$cache<\/code> cannot survive the request because nothing survives the request. I wrote it, it passed review, it ran for four years, and it was never wrong once.<\/p>\n<p>Under worker mode it is a tenant data leak with a comment explaining why it is safe. The first user to hit worker three warms the cache. The next user on worker three gets that company&rsquo;s name rendered into their dashboard.<\/p>\n<p>The fix is boring, which is the worst part:<\/p>\n<pre><code class=\"language-php\">class TenantSettings\n{\n    public function current(): array\n    {\n        \/\/ Resolved per request from the container, which Octane flushes\n        return $this-&gt;settings ??= DB::table('tenant_settings')\n            -&gt;where('tenant_id', auth()-&gt;user()-&gt;tenant_id)\n            -&gt;first(['name', 'currency', 'locale']);\n    }\n}\n\n\/\/ AppServiceProvider\n$this-&gt;app-&gt;scoped(TenantSettings::class);\n<\/code><\/pre>\n<p>In practice I moved it to a request scoped binding and let the framework throw it away. No cleverness required. The hard part was never the fix, it was finding all seventeen of them. I grepped for <code>static $<\/code> and <code>private static<\/code> across the codebase and read every hit, and I still found two more the following month.<\/p>\n<p>If you take one thing from this post: before you switch a mature app to worker mode, budget half a day for that grep. Not because the change is dangerous, but because your codebase contains four years of decisions that quietly assumed the process was about to die.<\/p>\n<h2 id=\"the-superglobal-that-does-not-get-reset\">The superglobal that does not get reset<\/h2>\n<p>Most superglobals are handled for you. <code>$_GET<\/code>, <code>$_POST<\/code>, <code>$_COOKIE<\/code>, <code>$_FILES<\/code>, <code>$_SERVER<\/code> and <code>$_REQUEST<\/code> are rebuilt from the incoming request on every call to <code>frankenphp_handle_request()<\/code>. That is exactly what you would hope.<\/p>\n<p><code>$_ENV<\/code> is not. The docs say so plainly: modifications made to <code>$_ENV<\/code> during a request persist and stay visible to later requests on the same worker thread.<\/p>\n<p>I did not have code writing to <code>$_ENV<\/code>, so I felt smug for about ten minutes, then went and checked the packages. One did. A payment integration was setting an API mode into the environment during a webhook handler, and on PHP-FPM that was a harmless local mutation that evaporated. Under a worker it sticks around and applies to whoever comes next.<\/p>\n<p>Worth reading the <a href=\"https:\/\/www.php.net\/manual\/language.variables.superglobals.php\" rel=\"nofollow noopener\" target=\"_blank\">PHP superglobals reference<\/a> once with worker mode in mind. It reads differently when the process has a lifespan longer than one HTTP request.<\/p>\n<h2 id=\"what-octane-resets-and-where-the-line-is\">What Octane resets, and where the line is<\/h2>\n<p>If you are on Laravel, you are probably going through Octane rather than writing that loop yourself, which is the right call. Setup is short:<\/p>\n<pre><code class=\"language-bash\">composer require laravel\/octane\nphp artisan octane:install --server=frankenphp\nphp artisan octane:frankenphp --workers=4 --max-requests=500\n<\/code><\/pre>\n<p>Octane resets most framework state between requests, and it is good at it. The container gets flushed, the request instance is rebuilt, the usual services get reset. What it cannot do is know about your static properties, your global variables, or a package that decided to memoize something at class level. The FrankenPHP docs put it politely: frameworks take care of resetting most state for you, but you may still need to reset your own services.<\/p>\n<p>The option I would pay attention to first is <code>--max-requests<\/code>, which defaults to 500. It restarts a worker after that many requests, which sounds like a workaround because it is one. PHP libraries leak memory, worker mode exposes it, and a periodic restart is the pragmatic answer everyone landed on. If you set it very high to squeeze out more throughput, you are betting that nothing in your dependency tree leaks. That is a bet, not a config value. The full list of flags is in the <a href=\"https:\/\/laravel.com\/docs\/12.x\/octane\" rel=\"nofollow noopener\" target=\"_blank\">Laravel Octane documentation<\/a> and in <a href=\"https:\/\/frankenphp.dev\/docs\/laravel\/\" rel=\"nofollow noopener\" target=\"_blank\">FrankenPHP&rsquo;s Laravel guide<\/a>.<\/p>\n<h2 id=\"workers-that-crash-on-boot-fail-in-a-way-you-should-rehearse\">Workers that crash on boot fail in a way you should rehearse<\/h2>\n<p>This one I found in staging, thankfully. If a worker script exits with a non-zero code, FrankenPHP restarts it with exponential backoff. If it keeps failing fast, for example because you shipped a typo into the bootstrap path, the whole server gives up with <code>too many consecutive failures<\/code>. You can raise the threshold in the Caddyfile:<\/p>\n<pre><code class=\"language-caddyfile\">frankenphp {\n    worker {\n        max_consecutive_failures 10\n    }\n}\n<\/code><\/pre>\n<p>But raising it is usually the wrong instinct. A worker that cannot boot is a deploy you want to fail loudly and immediately, not one you want retried politely for a few minutes while users get 502s.<\/p>\n<p>The more useful thing in the same area is the graceful restart endpoint. With the admin API enabled you can recycle every worker without dropping the server:<\/p>\n<pre><code class=\"language-bash\">curl -X POST http:\/\/localhost:2019\/frankenphp\/workers\/restart\n<\/code><\/pre>\n<p>That is what I hook into deploys now. Caddy sitting underneath FrankenPHP is also why the TLS story is so quiet here, which I got into when I <a href=\"https:\/\/abrarqasim.com\/blog\/nginx-vs-caddy-2026-native-acme-changed-my-default\/\" rel=\"noopener\">switched my reverse proxy default to Caddy<\/a>. Same engine, one less moving part.<\/p>\n<h2 id=\"how-i-test-for-leaked-state-now\">How I test for leaked state now<\/h2>\n<p>The test that would have caught my bug takes about six lines and I feel foolish for not having written it earlier.<\/p>\n<p>Run a single worker with a high request ceiling, so state has room to accumulate:<\/p>\n<pre><code class=\"language-bash\">php artisan octane:frankenphp --workers=1 --max-requests=1000\n<\/code><\/pre>\n<p>Then hit the same endpoint twice as two different authenticated users and assert on the response body, not the status code. If user B sees anything belonging to user A, you have found a static. One worker is the important part. With the default worker count your requests get spread around and the bug hides, which is exactly what happened to me in staging.<\/p>\n<p>I run that pair in CI now against three endpoints that touch tenant data. It is not thorough. It has caught two real regressions, which is two more than the thorough test suite I keep meaning to write.<\/p>\n<h2 id=\"try-this-on-tuesday\">Try this on Tuesday<\/h2>\n<p>Pick your smallest production PHP app, the internal one nobody would riot about. Run <code>grep -rn \"static $\" app\/<\/code> and read every result with one question in mind: does this assume the process is about to die? You do not have to migrate anything to do this. The grep is useful on its own, and if the answer is no everywhere, your migration is going to be a pleasant afternoon.<\/p>\n<p>If the answer is yes in a few places, you have just found the actual cost of worker mode, and it is measured in refactoring hours rather than in requests per second. Most of the Laravel work I take on ends up being this kind of unglamorous surgery, and you can see the sort of thing I mean in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my project work<\/a>.<\/p>\n<p>I still think it is worth it. The app is faster, the container is simpler, and I understand my own state management better than I did a year ago, mostly because it embarrassed me in front of a colleague on a Friday.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>FrankenPHP worker mode keeps your Laravel app booted between requests. That speed is also the bug. Here is the static state I had to unlearn from PHP-FPM.<\/p>\n","protected":false},"author":2,"featured_media":618,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"FrankenPHP worker mode keeps your Laravel app booted between requests. That speed is also the bug. Here is the static state I had to unlearn from PHP-FPM.","rank_math_focus_keyword":"frankenphp","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,52],"tags":[398,56,671,53,672],"class_list":["post-619","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-php","tag-frankenphp","tag-laravel","tag-octane","tag-php","tag-worker-mode"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/619","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=619"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/619\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/618"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=619"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=619"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=619"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}