DEV Community

Cover image for The Laravel 500 That Logged Nothing: When Your Web PHP Runs as a Different User
Web Pioneer
Web Pioneer

Posted on • Originally published at web-pioneer.com

The Laravel 500 That Logged Nothing: When Your Web PHP Runs as a Different User

A contact form on a production Laravel site stopped working. Every submission returned the same response:

{"message": "Server Error"}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The code path never ran. Easy to disprove — the form returned a 500, so something executed.
  2. The log level filtered it out. Also easy — an uncaught exception logs at error, which no sane config filters.
  3. 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 denied
Enter fullscreen mode Exit fullscreen mode

Why 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);
}
Enter fullscreen mode Exit fullscreen mode

Trace it with a dead logger:

  1. Log::info(...) throws UnexpectedValueException — permission denied.
  2. Control jumps to the catch.
  3. Log::error(...) throws the same exception again, this time from inside the catch block.
  4. Nothing catches that one. It propagates to Laravel's handler, which also tries to log, which also fails.
  5. The client gets a bare 500 with no context anywhere.

The general lesson: a catch block that logs is only as reliable as the logger. If logging is the thing that broke, your error handling amplifies the outage instead of reporting it — and it does so in a way that destroys the evidence.

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 record
Enter fullscreen mode Exit fullscreen mode

Hit it with curl, read the real exception, delete the file. The permission error appeared on the first run.

Handle with care: a script like this bypasses your normal routing and middleware entry point. Give it an unguessable filename, run it once, and remove it in the same session. Never leave one on a production host.

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.log
Enter fullscreen mode Exit fullscreen mode

2. 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
],
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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" \;
Enter fullscreen mode Exit fullscreen mode

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 catch block throw. Wrap logging calls or fall back to error_log().
  • Set permission explicitly 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.

FAQ

Why does Laravel return a generic 500 with no log entry at all?

Almost always because the logger itself cannot write. If Monolog cannot open the log file in append mode it throws, and if your catch block also calls Log::error() it throws a second time from inside the catch, producing an uncaught exception with no recorded trace. Check the modification time on storage/logs/laravel.log first — a frozen timestamp during an active incident points straight at file permissions.

Why does my web request behave differently from php artisan tinker?

Web PHP and CLI PHP are usually separate SAPIs. They can run as different operating-system users, load different extension sets, and read different php.ini files. On many cPanel and shared hosts the web process runs as nobody while CLI runs as the site's own user. That means CLI cannot always reproduce a web-only failure, and a file created by CLI may be unwritable by the web process.

What permissions should storage/logs/laravel.log have?

The web process must be able to append to it. Where web PHP runs as a different user from the deploy user, that in practice means mode 666 on the file and 777 on the directory, or aligning ownership so the web user's group has write access. Also set the 'permission' key on the log channel in config/logging.php so Monolog recreates the file with the correct mode after rotation.

How do I debug a Laravel 500 when logging is broken?

Run inside the web context rather than trying to simulate it. Drop a temporary PHP file into your public directory that boots the kernel, handles the target request, and dumps $response->exception, then hit it with curl and delete it immediately. Give it an unguessable name and never leave it on the server.

How can I detect this failure before users report it?

Monitor the freshness of the log file, not just its contents. A scheduled job that alerts when laravel.log has not been modified in several hours catches silent logger failures. Pair it with an external uptime check that submits a real form or hits a health endpoint, so a broken write path surfaces as an alert rather than as weeks of missing leads.


Originally published at web-pioneer.com.

Top comments (0)