Laravel logging in production: a setup guide
Laravel logs debug lines to one file by default. In production: daily files or stderr, LOG_LEVEL=info, JSON, shared context, and logs off the server.
On this page
A new Laravel app logs everything, at debug level, into one file: storage/logs/laravel.log. In production you want four changes: rotate the file (or write to stderr in containers), raise LOG_LEVEL, write JSON with a request or trace ID on every line, and get the logs off the server so you can still read them when it's down.
Everything below was run on Laravel 13.42 and checked against the Laravel 13.x logging docs and the Context docs on 10 Nov 2026. Laravel 12 works the same, with one difference noted under rotation.
What a new app logs, and where
Logging lives in config/logging.php, and a fresh .env sets four values:
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_CHANNEL picks the default channel. stack is a channel that writes to a list of other channels, and LOG_STACK is that list, comma-separated. single is one file that grows forever. Every channel that reads LOG_LEVEL drops messages below that level.
So out of the box, every Log::debug() in your code and in your packages lands in one file on the web server's disk, which nobody rotates.
Pick channels: a stack for production
Laravel ships these channels in config/logging.php, ready to use by name in LOG_STACK:
| Channel | Writes to | Use it for |
|---|---|---|
single | storage/logs/laravel.log | Local development |
daily | storage/logs/laravel-YYYY-MM-DD.log, 14 files kept | One or a few servers you can SSH into |
stderr | The process's stderr | Docker, Kubernetes, Laravel Cloud: the platform collects it |
syslog, errorlog | The system log, PHP's error_log | Servers where syslog is already shipped somewhere |
slack | A Slack incoming webhook, critical and up | A few lines a person must read now |
papertrail | Papertrail over TLS | If you use Papertrail |
null | Nowhere | Turning a channel off |
A typical server setup writes to daily files and sends critical lines to Slack:
LOG_CHANNEL=stack
LOG_STACK=daily,slack
LOG_LEVEL=info
LOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
YOUR/WEBHOOK/URL is the path of a Slack incoming webhook. One trap: the default slack channel reads 'level' => env('LOG_LEVEL', 'critical'). The moment you set LOG_LEVEL=info, Slack gets every info line too. Hard-code its level:
// config/logging.php, channels
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => 'critical',
'replace_placeholders' => true,
],
To write one message to one channel, skip the stack: Log::channel('slack')->critical('Payments are failing').
Choose a log level for production
Monolog has the eight levels of RFC 5424, from most to least severe: emergency, alert, critical, error, warning, notice, info, debug. A channel logs its own level and everything above it.
LOG_LEVEL=infois a good default: you keep "order paid" and "import finished", and lose the debug noise of packages.warningif your logs are large and you only read them when something broke.debugin production only for a short investigation, then back. Debug lines often contain payloads you don't want stored.
Unhandled exceptions are logged at error by Laravel's exception handler, so they stay visible at any of these levels.
Rotate files: daily, or logrotate with copytruncate
The daily channel starts a new file each day and deletes the oldest beyond LOG_DAILY_DAYS (14 by default):
// config/logging.php in Laravel 13
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
Laravel 12's config calls the option days; Laravel 13 reads max_files and still accepts days. Laravel 13's config also has a monthly channel that keeps 3 files.
Two things go wrong with log files on servers:
- Permission denied. The first process to write a day's file owns it. If cron or a deploy script runs
php artisanasrootat 00:01, PHP-FPM aswww-datacan't write to that file for the rest of the day, and the error ends up nowhere. Run artisan commands as the web user, or set'permission' => 0664on the channel with a shared group. - Queue workers keep the old file. If you rotate
singlewithlogrotateinstead, its default moves the file away and creates a new one. Long-running processes (queue:work, Horizon, Octane) keep their handle on the moved file and write there. Usecopytruncatein the logrotate config, or restart the workers after rotation.
Write JSON
The default line format is fine to read and hard to search. Any file or stream channel takes a Monolog formatter; add one line to daily:
'daily' => [
// ...the options above
'formatter' => Monolog\Formatter\JsonFormatter::class,
],
For stderr, set it from the environment: LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter.
A log call with a placeholder:
Log::info('Order {order} paid', ['order' => 1042, 'amount_cents' => 4900]);
now writes one JSON object per line:
{"message":"Order 1042 paid","context":{"order":1042,"amount_cents":4900},"level":200,"level_name":"INFO","channel":"production","datetime":"2026-11-10T16:44:21.879609+00:00","extra":{"trace_id":"e04e1a11"}}
The {order} placeholder is filled from the context because the channel has replace_placeholders. The channel field is your APP_ENV. The extra.trace_id comes from the next section.
Add context to every line
A log line without a request ID can't be tied to the other lines of the same request. Laravel has three ways to add context, from narrow to broad:
- Per call: the array you pass, as above.
- Per channel:
Log::withContext(['request_id' => $id])adds it to every later line of the current channel.Log::shareContext([...])does the same for all channels, including ones created later. - Everywhere, including queued jobs: the
Contextfacade, since Laravel 11.
Context is the one to use. Add it in a middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class AddLogContext
{
public function handle(Request $request, Closure $next): Response
{
Context::add('trace_id', Str::uuid()->toString());
Context::add('url', $request->url());
return $next($request);
}
}
Register it in bootstrap/app.php with ->withMiddleware(fn ($middleware) => $middleware->append(\App\Http\Middleware\AddLogContext::class)). Context lands in each record's extra, not context, so you can tell it apart from what one call passed.
It also travels with the queue. A job dispatched during that request gets the same trace_id on its log lines when a worker runs it, minutes later and on another server. We dispatched a job that logs Receipt sent and ran it with queue:work (APP_ENV=production):
{"message":"Receipt sent","context":{"order":1042},"level":200,"level_name":"INFO","channel":"production","datetime":"2026-11-10T16:44:24.841978+00:00","extra":{"trace_id":"e04e1a11"}}
Data a job needs but the log shouldn't show goes in hidden context: Context::addHidden('api_key', $key) is passed to jobs and never written to a log.
What not to log
Logs get copied to more places and kept longer than your database, and more people can read them. Keep these out:
- Passwords, API keys, tokens, session IDs and full
Authorizationheaders. - Whole request bodies (
$request->all()): they contain all of the above, sooner or later. - Card numbers and other payment data; government IDs.
- Personal data you don't need to debug. A user ID is usually enough; the email address isn't needed.
Log the ID of the thing (order 1042), not the thing. Under the GDPR, personal data in logs needs a retention period like any other copy, which LOG_DAILY_DAYS gives you for files.
Read logs: on the server, and off it
On the server, Laravel Pail tails the logs with filters, for any channel (it needs pcntl):
composer require --dev laravel/pail
php artisan pail --level=error
php artisan pail --filter="QueryException"
--user=USER_ID shows only lines written while that user was logged in. Pail is a dev dependency here; on a production box without dev dependencies, tail -f storage/logs/laravel-$(date +%F).log works for daily.
Files on the web server have two limits: they disappear with the server, and you have to SSH in to read them. Before your first incident, send them somewhere else: stderr into your platform's log collection, syslog into a log service, or a hosted tool's log channel.
Logs next to the request in Lampwick
We make Lampwick. Its Laravel package adds a lampwick log channel; put it in your stack next to the files:
LOG_STACK=daily,lampwick
Logs in the project lists every entry with its level, message, context and extra (where Context data lands), with a level filter ("warning and above") and search over the message and context. Each entry links to the request, job or command it was written in, whose timeline shows that log line between its queries and exceptions. Lampwick replaces the values of keys like password, token, secret and authorization before a record leaves your server. LAMPWICK_LOG_LEVEL sets the lowest level sent, separately from LOG_LEVEL. History is 3 days on Free and 14 to 60 days on paid plans.
Lampwick doesn't alert on log lines. Exceptions become issues, which do alert; an agent can read logs too, with listLogs over MCP. The error tracking page shows how logs, exceptions and requests fit together.
Next step
Set LOG_STACK=daily,lampwick and LOG_LEVEL=info, add the context middleware, and connect your Laravel app. For logs from queued jobs that fail, see Laravel queue monitoring.