The workers were dying silently. Caddy just swallowed the evidence.

I was confident going into this. FrankenPHP had been getting serious attention in the Laravel community. The benchmarks looked genuinely good. It ships as a single binary. You get HTTP/3, worker mode, automatic HTTPS via Caddy and you do not have to configure a separate web server. For a production Laravel app I maintain for a water utility client, I decided it was time to stop running nginx + PHP-FPM like it was 2018.
The migration took half a day. Everything passed staging. I deployed on a Friday afternoon.
Yes. I know.
It Worked Until It Did Not
The first three days were clean. Response times dropped. Memory looked stable. I felt smart.
Then the on-call alert fired at 2 AM on day four. Not a PHP alert. Not an application error. A simple HTTP check reporting 502s on the health endpoint. By the time I opened my laptop, the site had recovered on its own. I checked the logs. Nothing meaningful. I assumed a traffic spike. I went back to sleep.
It happened again two days later. Then twice in one day.
The pattern was consistent: workers would start returning 502s, last anywhere from 20 seconds to two minutes, then self-recover. No restart. No intervention. Just resumed like nothing happened.
That self-recovery detail is what made this so hard to chase. In a traditional PHP-FPM setup, when a worker dies, something restarts it and you have a timestamp, a reason, a signal. With Octane running FrankenPHP in worker mode, the recovery felt almost automatic because Caddy was silently spinning up replacement workers behind the scenes. The failure was real. The trace was gone.
Three Dead Ends in Sequence
Dead end one: PHP memory limits.
My first instinct was memory exhaustion. Worker mode keeps PHP alive across requests, which means any memory leak compounds over time. I added memory_get_peak_usage() logging to the request lifecycle. Peak usage was hovering around 28MB per request on a 256MB limit. Not the culprit.
Dead end two: Caddy configuration.
I went through the Caddy config Octane generates and compared it against FrankenPHP’s own documentation. I tightened max_conns, adjusted timeouts, re-read the frankenphp directive options. Everything looked correct. The crashes kept happening.
Dead end three: The database connection pool.
I spent an embarrassing amount of time convinced this was a MySQL connection issue. The app uses a connection pool via a persistent connection strategy and I knew Octane had documented caveats around database connections needing to be reset between requests. I added DB::reconnect() calls, watched the metrics and felt nothing change. Two days wasted.
Finding the Actual Error Logs
This is the part that should have come first but did not, because the log location is not obvious and nobody in the FrankenPHP ecosystem writes about it plainly.
When FrankenPHP workers crash at the PHP level, the error does not automatically flow into your Laravel storage/logs directory. The worker is gone before your exception handler can run. The output goes to stderr, which Caddy captures and routes to its own structured log output. By default, Caddy writes its logs to stdout and stderr in JSON format and unless you have explicitly piped that output somewhere, it disappears.
I was running Octane with a supervisor process. My supervisord.conf had stderr_logfile pointed at a path I had set up but never actively monitored because Laravel's own logs had always been sufficient before.
When I finally opened that file, the error was sitting right there, repeated across every crash window:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 20480 bytes) in /var/www/html/vendor/...Not 256MB. The worker process itself had a different memory_limit than what I thought. The php.ini value I had set was correct but FrankenPHP was picking up a different configuration file because of how the binary resolves its PHP configuration path.
It was using the CLI php.ini, not the one I had modified.
The Actual Root Cause (And The Part I Am Still Not Fully Satisfied With)
Once I had the real memory limit confirmed and raised, the crashes dropped from multiple times per day to zero for eleven days. I thought it was solved.
Then it happened again.
This time I caught it faster. The supervisor log showed the same exhaustion error but the memory limit was now correct. Usage had climbed to 512MB on a process that started at under 30MB. Something was accumulating.
I added per-request memory logging across the full request lifecycle and started watching a staging environment under load. After about 400 requests to a specific endpoint, memory would start climbing at roughly 180KB per request instead of the baseline fluctuation I expected.
The endpoint was running a PDF generation job that used a third-party package internally maintaining a static cache property on its main class. In FPM, this never matters. Every request is a fresh process. In Octane worker mode, that static property lives across the entire lifetime of the worker. The cache was never pruned. After enough requests, it just ate the worker.
The fix for that part was straightforward: wrap the call in an Octane request lifecycle listener that resets the static property after each request using the Octane::tick() and request terminated hooks.
// In your AppServiceProvider boot() method
use Laravel\Octane\Facades\Octane;
Octane::listen('requestTerminated', function () {
SomePackage::resetStaticState();
});But here is the honest part: I still do not fully trust this is the complete picture. The first crash pattern, before I corrected the memory_limit issue, happened on a different endpoint entirely. I never reproduced it cleanly enough to confirm whether that was also a static state issue or whether FrankenPHP had a different behaviour under the specific PHP version I was running at the time (8.2.10). I upgraded to 8.3 during the remediation work and cannot isolate which change mattered more.
What I Changed Permanently
Beyond the memory limit fix and the static state reset, a few things are now standard in how I deploy Octane with FrankenPHP.
Set max-requests explicitly.
php artisan octane:start --server=frankenphp --workers=4 --max-requests=500The --max-requests flag tells Octane to gracefully recycle a worker after it has handled N requests. It does not eliminate memory leak problems but it puts a ceiling on how bad they can get. The default, if you do not set this, is 500 in recent Octane versions, but explicitly setting it makes your intention clear and survives future default changes.
Pipe supervisor stderr to a real log file you actually check.
[program:octane]
stderr_logfile=/var/log/supervisor/octane-stderr.log
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=5This is obvious in retrospect. Do it before you deploy.
Test with OCTANE_WORKERS set to 1 first.
When you are debugging Octane issues, single-worker mode eliminates the possibility that you are seeing a race condition or a shared-state problem being masked by which worker handles which request. Run with one worker, generate load, watch memory. Then scale up.
Audit every third-party package for static properties before running under any persistent PHP process model.
This applies to Swoole and RoadRunner as well, not just FrankenPHP. Grep your vendor directory:
grep -rn "private static\|protected static\|public static" vendor/ \
--include="*.php" | grep -v "test\|Test\|spec" | grep -i "cache\|store\|instance"You will find things that will concern you. Whether they are actually a problem depends on how that state is used, but at least you know where to look when something starts leaking.
What FrankenPHP Gets Right That Made This Worth Solving
I want to be honest about this too: after fixing these issues, the setup is genuinely better than what I had before. The single-binary deployment is real and it matters when you are managing infrastructure solo. The performance difference versus PHP-FPM on this specific application is measurable. HTTP/3 support was a one-line configuration change rather than an nginx research project.
The documentation for FrankenPHP is still thin at the production edge. Maintainer and the contributors have done serious work on the core project, but the operational failure patterns that only show up after days of real traffic are not written down anywhere yet. That is not a criticism of the project. It is just where things are right now.
If you are running FrankenPHP in production and things feel fine, do yourself a favour: check where your worker stderr is going before the 2 AM alert does it for you.


