No body. No logs. Not even APP_DEBUG=true. Four packages caused it.

June 3. Every POST, PUT and DELETE returns HTTP 500. Not an error page. Not a Laravel exception screen. An empty body, Content-Length: 0, in about four milliseconds, with nothing logged anywhere. Not in storage/logs. Not on Docker stdout. APP_DEBUG=true: still nothing. display_errors=On: still nothing.
GET requests return 200. The app is alive. Writes are dying silently.
If you have ever hit Livewire’s white screen of death, you know the particular frustration of an error that hides itself. This had that same energy, except quieter: no screen, no trace, just a 500 that swallowed itself whole.
What Changed?
A routine composer update and bun update. These are the packages that moved:
Working (preview environment before the bump):
- Inertia.js v3.0.6
- Symfony http-foundation 8.0.8
- Laravel 13.6
- Octane 2.17.2
Broken (production after the bump):
- Inertia.js v3.3.0
- Symfony http-foundation 8.1.0
- Laravel 13.13.0
- Octane 2.17.4
- FrankenPHP 1.12.2, PHP 8.4.21 (unchanged)
None of these changes is wrong on its own. All four had to land together to produce this failure. That combination matters: it is why neither pinning one package nor reverting one dependency would have been a clean fix.
The preview environment ran the old combination throughout and never crashed. The same JSON writes that emptied production returned clean 419s on preview. Both ran FrankenPHP 1.12.2. That comparison was useful later for ruling out the base image version before we had any other evidence.
The Isolation Matrix
Before writing a single hypothesis, we stripped away every network layer and varied one input at a time. All tests ran directly against the app container with curl, bypassing Cloudflare and the reverse proxy entirely.
GET /admin/users -> 200 The app is up. Routing works. Not a total crash. The worker is running.
POST/DELETE, no body, no content-type -> 302 redirect The HTTP method is not the trigger. A write with no content type passes through fine, hits the redirect.
POST/DELETE, Content-Type: application/x-www-form-urlencoded -> 419 Laravel bootstraps fully. The CSRF middleware runs and rejects correctly. The whole framework stack works fine for form-encoded writes.
POST/DELETE, Content-Type: application/json + body -> 500, empty Here. This specific path crashes every time, on any route, before any application code runs.
Unauthenticated JSON POST /login (no CSRF token) -> 500, empty It crashes even without authentication. This is happening in framework territory, during request marshalling, not inside any route or middleware we wrote.
The matrix pointed at one specific trigger: Content-Type: application/json with a body. Form-encoded uses $_POST (parsed by the SAPI, never touching php://input). JSON forces a php://input read. That observation mattered for the first hypothesis, which turned out to be wrong.
Then we ran the test that changed everything: start a FrankenPHP process from the same container image, but in classic per-request mode instead of the Octane worker.
docker exec -d <app> sh -c \
"frankenphp php-server --root /app/public --listen 127.0.0.1:8080"Identical JSON POST to classic mode: 419. The Octane worker: 500 size=0. Classic: 419 size=69.
Same image. Same environment. Same application code. The crash was in Octane FrankenPHP worker mode only, not in any dependency or application logic.
This result narrowed the investigation immediately. The next step was reading Octane worker source, not bisecting dependencies or filing issues against the app. Worker mode was the differentiator, so worker mode was where the answer lived.
Three Dead Ends
FrankenPHP version. The update had pulled in FrankenPHP 1.12.3. We pinned back to 1.12.2 and rebuilt. Still 500. Ruled out.
The confounded Octane hot-swap. We tried hot-swapping Octane 2.17.2 vendor files into the running container and reloading workers. It appeared to work: someone was able to delete two records through the browser during this window. So we pinned laravel/octane to 2.17.2 in composer.json, did a clean image rebuild and redeployed. The 500 came back. Same Octane version, baked versus hot-swapped, different results. The apparent fix was a confound: opcache state, build context or JIT recompilation timing. One green data point under uncontrolled conditions cost us an hour.
The request_parse_body hypothesis. Before we had any stack trace, research pointed at PHP 8.4's request_parse_body() function, which can consume php://input for PUT and PATCH with a JSON body. The theory fit the black-box matrix almost perfectly. GET requests skip body parsing entirely. Form-encoded uses $_POST and never touches php://input. JSON forces a php://input read every time. Every observable symptom matched.
It was completely wrong. There is no request_parse_body call anywhere in the real stack trace.
We also attached gdb with --cap-add=SYS_PTRACE and fired the failing request. No SIGSEGV. The worker process survived. Not a native crash: a PHP-level fatal being swallowed somewhere inside the worker, with nothing to show for it.
Finally. The Real Exception.
Inside Octane’s FrankenPHP worker, the request loop looks like this:
// vendor/laravel/octane/bin/frankenphp-worker.php
$handleRequest = static function () use ($worker, $frankenPhpClient, $debugMode) {
try {
[$request, $context] = $frankenPhpClient->marshalRequest(new RequestContext()); // line 49
$worker->handle($request, $context);
} catch (Throwable $e) {
if ($worker) {
report($e); // line 54: re-fatals on missing config; nothing gets logged
}
$response = new Response(
$debugMode === 'true' ? (string) $e : 'Internal Server Error',
500, ['Content-Type' => 'text/plain']
); // never reached when report() itself throws
$response->send();
}
};When marshalRequest() throws, the catch block calls report($e). If report() also throws, the (string) $e debug branch below it is never reached. No debug output. No response body. Just an empty 500.
The fix: in a throwaway debug container, comment out report($e). The catch block falls through to (string) $e, and with APP_DEBUG enabled the original exception prints in the response body.
NET=$(docker network ls --format '{{.Name}}' | grep -i 'internal' | head -1)
cat > /tmp/zz-debug.ini <<'INI'
display_errors=On
display_startup_errors=On
error_reporting=E_ALL
INI
docker run -d --name app-dbg --network "$NET" \
--env-file .env -e APP_DEBUG=true \
-v /tmp/zz-debug.ini:/usr/local/etc/php/conf.d/zz-debug.ini:ro \
<prod-image> \
php artisan octane:start --server=frankenphp \
--host=0.0.0.0 --port=80 --workers=1 --max-requests=500
docker exec app-dbg sed -i 's/report(\$e);/\/* disabled *\//' \
vendor/laravel/octane/bin/frankenphp-worker.php
docker restart app-dbg && sleep 9
docker exec app-dbg sh -c \
"curl -s -X POST http://127.0.0.1:80/login \
-H 'Content-Type: application/json' \
-H 'X-Requested-With: XMLHttpRequest' \
-d '{}'"
docker rm -f app-dbgAfter hours of empty responses, the response body finally printed something: 24 frames of stack trace. The original exception was visible for the first time.
The Root Cause
Illuminate\Http\Request::createFromBase() [Request.php:549]
if ($newRequest->isJson()) {
$newRequest->request = $newRequest->json(); // direct write, JSON requests only
}
symfony/http-foundation/Request.php:132
trigger_deprecation('symfony/http-foundation', '8.1',
'Directly setting the $request property is deprecated...')
HandleExceptions::handleDeprecationError() [HandleExceptions.php:74]
LogManager->channel('deprecations')
Container->make('config')
BindingResolutionException: Target class [config] does not exist
worker catch: report($e) [frankenphp-worker.php:54]
report() calls make('config') again, fails identically
uncaught PHP fatal, empty 500, nothing loggedThe four pieces:
Inertia v3.3.0 changed write requests from application/x-www-form-urlencoded to application/json. Correct behaviour for the new API. It also activated a branch in createFromBase() that had never been exercised with this dependency combination.
Laravel’s createFromBase() sets $newRequest->request by direct property write, but only when isJson() returns true. GET and form-encoded requests skip this line entirely. JSON requests hit it on every call.
Symfony 8.1 added a trigger_deprecation() call when that property is written directly, via PHP 8.4 property hooks. On 8.0.x the same assignment was silent.
Octane FrankenPHP worker mode is where the benign deprecation turns fatal. In classic mode config is already bound when createFromBase() runs, so the deprecation is handled and the request continues. In the worker, createFromBase() fires inside marshalRequest(), before $worker->handle() sets up the request scope. At that point config is not bound. An E_USER_DEPRECATED cascades into a BindingResolutionException, report() re-fails on the same missing binding, and the result is an empty 500 with nothing logged.
From Mitigation to Upstream Fix
Classic mode, same day. Override the app service command in the compose file:
command: ["frankenphp", "php-server", "--root", "/app/public", "--listen", ":80", "--no-compress"]Committed and deployed through CI so it survived future redeploys. Classic mode re-bootstraps Laravel on each request (slower, no worker memory reuse) but safe when session, queue and cache are all external drivers. We did not pin Symfony back to 8.0.x; that would have re-exposed CVE-2026–48736, CVE-2026–46644 and CVE-2026–48784.
2026–06–03: Issue filed. We filed laravel/octane#1133 that day with the verbatim stack traces and root-cause chain. The maintainer closed it the same day as a duplicate of laravel/framework#60365, an issue filed independently a few hours earlier with the same analysis. Two teams hitting the same crash in the same window was strong validation.
The maintainer’s pushback. On #60365, crynobone initially said the problem was “already handled” by shouldIgnoreDeprecationErrors(). Tracing the installed source showed why that guard misses the worker path:
// HandleExceptions::handleDeprecationError()
if ($this->shouldIgnoreDeprecationErrors()) { // line 91, keys on hasBeenBootstrapped()
return;
}
try {
$logger = static::$app->make(LogManager::class); // line 96, guarded
} catch (Exception) {
return;
}
$this->ensureDeprecationLoggerIsConfigured(); // line 100, not guarded, touches $app['config']
$options = static::$app['config']->get('...') ?? []; // line 102, not guarded
with($logger->channel('deprecations'), function ($log) ...); // line 105, crash hereshouldIgnoreDeprecationErrors() keys on hasBeenBootstrapped(). Under an Octane worker the app bootstraps once at startup and stays bootstrapped: hasBeenBootstrapped() is always true and the guard never fires. The try { make(LogManager) } catch only wraps LogManager resolution, which succeeds. The crash happens at line 105 where channel('deprecations') calls $app['config'] without any guard.
Another developer independently confirmed the same crash on a Swoole worker (phpswoole/swoole:6.2.1-php8.5) the same day, confirming this is framework-level, not FrankenPHP-specific.
We had the guard-gap analysis ready to post but held off waiting for a deterministic repro. Unnecessary: crynobone implemented the exact same guard from his own reading of the code.
2026–06–04: PR #60376 merged. “[12.x] Ensure config is bound before trying to log deprecation notice" adds one line:
if (! static::$app->bound('config')) {
return;
}Forward-merged to 13.x. Released the same day as laravel/framework v13.14.0 (and v12.61.1).
Verify Before Flipping Back
We did not restore worker mode on the day of the fix. We built the updated image, spun a throwaway worker container and ran the same matrix from the start of the investigation:
GET /up -> 200
POST /login, Content-Type: application/json -> 419 size=69 Previously: 500 size=0. This is the one that confirmed the fix.
POST /login, form-encoded -> 419 size=6592 (control, unchanged)
DELETE /login, application/json -> 405 with body, clean
Docker logs: no Target class [config] exceptions. The throwaway container passed every check before we touched production. Worker mode was restored via the normal CI pipeline on 2026-06-08, five days after the incident.
The most dangerous bugs are the ones that break the error reporter you would use to find them. Disable report($e), run the request in a throwaway container and read the response body. The empty 500 had something to say the whole time.


