A contact form on a production Laravel site stopped working. Every submission returned the same response:
{"message": "Server Error"}No stack trace. No Sentry event. And storage/logs/laravel.log had not been written to in eleven days — its modification timestamp was frozen on the exact day the form broke.
That frozen timestamp is the whole story, but it took a while to read it correctly. The instinct is to treat an empty log as "nothing happened." It is usually the opposite: something happened to the logger.
The symptom that misleads you
An empty log during an active incident has three plausible explanations, and engineers usually check them in the wrong order:
- The code path never ran. Easy to disprove — the form returned a 500, so something executed.
- The log level filtered it out. Also easy — an uncaught exception logs at
error, which no sane config filters. - The logger itself failed. Rarely checked, because logging feels like infrastructure that cannot fail.
It was the third one. And it failed in the most inconvenient way available.
Root cause: two different users, one log file
On this stack, PHP runs under two identities depending on how it is invoked:
| Context | Runs as | Can write to a 664 file owned by the site user? |
|---|---|---|
CLI (php artisan, cron, deploy scripts) | the site's system user | Yes — it is the owner |
| Web requests (LSAPI / PHP-FPM pool) | nobody (uid 65534) | No — not owner, not in group |
This split is common on shared and cPanel-style hosting, and it is invisible during development because everything on a laptop runs as one user. It stays invisible in production too — right up until a file that the web process must write is created by the CLI process.
Which is exactly what happened. A maintenance task run from the shell recreated laravel.log with mode 664 and the site user as owner. From that moment, every web request that tried to log threw:
UnexpectedValueException: The stream or file ".../storage/logs/laravel.log"
could not be opened in append mode: Permission deniedWhy the exception was completely silent
The controller looked roughly like this — and this shape is everywhere in real codebases:
try {
Log::info('Contact form submitted', $payload);
ContactJob::dispatch($payload);
} catch (\Throwable $e) {
Log::error('Contact form failed: ' . $e->getMessage());
return response()->json(['message' => 'Server Error'], 500);
}Trace it with a dead logger:
Log::info(...)throwsUnexpectedValueException— permission denied.- Control jumps to the
catch. Log::error(...)throws the same exception again, this time from inside the catch block.- Nothing catches that one. It propagates to Laravel's handler, which also tries to log, which also fails.
- The client gets a bare 500 with no context anywhere.
Diagnosing it when the app cannot tell you anything
The usual reflex is php artisan tinker. That failed here for an unrelated but instructive reason: the CLI PHP binary on this server did not have the Redis extension loaded, while the web SAPI did. Any attempt to boot and handle a request from the CLI blew up on the session/cache driver before reaching the real bug.
Two environments, two extension sets, two user identities. The CLI simply could not reproduce a web request.
What worked was to stop trying to simulate the web context and instead run inside it — a temporary diagnostic script in the public directory that boots the kernel, handles the target route, and prints the exception object directly:
<?php // public/diag-temp.php — DELETE IMMEDIATELY AFTER USE
require __DIR__ . '/../vendor/autoload.php';
$app = require_once __DIR__ . '/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$request = Illuminate\Http\Request::create('/send-contact', 'POST', [
'name' => 'diag', 'email' => 'diag@example.com', 'message' => 'diag',
]);
$response = $kernel->handle($request);
var_dump($response->exception); // the thing the log never got to recordHit it with curl, read the real exception, delete the file. The permission error appeared on the first run.
The fix
Three changes, smallest first:
1. Rotate the poisoned file and recreate it writable by everyone that needs it. The old log had also grown to 280 MB, which is its own problem:
mv storage/logs/laravel.log storage/logs/laravel.log.backup-$(date +%F)
touch storage/logs/laravel.log
chmod 666 storage/logs/laravel.log2. Make Monolog create future files with the right mode. Laravel's log channels accept a permission key, and without it you inherit whatever umask happened to be in effect:
// config/logging.php
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'permission' => 0666, // survives log rotation and CLI-created files
],3. Stop the catch block from being able to throw. If the logger is the failure, the handler must degrade rather than escalate:
} catch (\Throwable $e) {
try {
Log::error('Contact form failed', ['exception' => $e]);
} catch (\Throwable $loggingFailure) {
error_log('Contact form failed: ' . $e->getMessage()); // syslog fallback
}
return response()->json(['message' => 'Server Error'], 500);
}error_log() writes to the SAPI's own error log, which is owned by the web server and therefore always writable by it. It is not elegant, but it is the one channel that survives when your application logger does not.
The second bug, found only after logging worked
With the log alive again, the very next submission produced a real stack trace — and a completely different bug that had been hiding behind the first one:
ErrorException: Undefined array key "url"A queued listener read $data['url'] from the payload. That field was optional and absent on some of the forms feeding the same endpoint. One ?? null fixed it.
This is the part worth internalising: a broken logger does not hide one bug, it hides all of them. Every failure during those eleven days collapsed into the same anonymous 500.
Two operational notes that cost extra time
- Queue workers cache your code. After editing a listener, the running worker keeps executing the old class until restarted. Kill it and let your supervisor or cron restart it, then re-run the failed jobs with
php artisan queue:retry all. - Anything the web process writes needs web-process-writable permissions. Logs, cache files, compiled views, uploaded media. If a deploy script or a shell session creates any of them, it must set ownership and mode explicitly, or the next web request inherits a file it cannot touch.
A checklist worth stealing
| Check | Command | What you want to see |
|---|---|---|
| Who does web PHP run as? | <?php echo get_current_user(), ' / ', posix_getuid(); via a temp file | The same identity your writable paths expect |
| Is the log actually being written? | ls -l --time-style=full-iso storage/logs/ | An mtime from minutes ago, not weeks |
| Can the web user append? | stat -c '%U %G %a' storage/logs/laravel.log | Mode that includes the web process |
| Does CLI match web? | php -m vs a web phpinfo() | Matching extension sets, or you cannot reproduce |
| Is the log unbounded? | du -sh storage/logs/ | Rotation configured; not hundreds of MB |
Add the monitor you wish you had
The cheapest possible detection for this class of failure is a scheduled check that the log file is still being touched. If your application logs anything at all on a normal day, an mtime older than a few hours is an incident:
# alert if laravel.log has not been written in 6 hours
find /path/to/storage/logs/laravel.log -mmin +360 \
-exec echo "WARNING: laravel.log stale" \;Wire that into whatever already pages you. Eleven days of silent failure is not a logging problem — it is a monitoring problem that a logging problem exposed.
Takeaways
- An empty log during an outage means the logger failed until proven otherwise.
- CLI PHP and web PHP are frequently different users with different extensions. Reproduce bugs in the context where they occur.
- Never let a
catchblock throw. Wrap logging calls or fall back toerror_log(). - Set
permissionexplicitly on file-based log channels. - Monitor the freshness of your log file, not just its contents.
We run this pattern across the Laravel applications we maintain, alongside a stale-log check on every host. If you are hardening a production Laravel deployment and want a second pair of eyes on the failure modes that never make it into your error tracker, our team is happy to help.
.jpg)