Laravel Logging — the complete guide

Laravel's logging system is built on top of Monolog, a battle-tested PHP logging library. The idea is simple: you write a log message, Laravel routes it to one or more channels, and each channel decides what to do with it — write it to a file, push it to Slack, send it to a cloud service, etc.

All logging configuration lives in config/logging.php. The most important key is default, which you control via the LOG_CHANNEL (or LOG_STACK) environment variable in your .env file.

// config/logging.php
'default' => env('LOG_CHANNEL', 'stack'),

Writing a log entry is as simple as:

use Illuminate\Support\Facades\Log;

Log::info('User logged in', ['user_id' => $user->id]);
Log::error('Payment failed', ['order_id' => $order->id, 'reason' => $e->getMessage()]);

Or using the global helper:

logger('Something happened');
logger()->error('Something broke', ['context' => 'here']);

Log levels

Laravel follows the PSR-3 log level standard. Levels are ordered by severity — when you set a minimum level, only messages at that level or higher are recorded.

Level Method When to use
emergency Log::emergency() System is completely unusable
alert Log::alert() Needs immediate action (e.g. DB down)
critical Log::critical() Critical conditions, component unavailable
error Log::error() Runtime errors that don't require immediate action
warning Log::warning() Exceptional occurrences that aren't errors
notice Log::notice() Normal but significant events
info Log::info() Interesting events, user actions
debug Log::debug() Detailed diagnostic information

Severity order (highest to lowest): emergency > alert > critical > error > warning > notice > info > debug


Built-in channels

Laravel ships with several channels out of the box. You can use them as-is or extend them.

Channel Driver What it does
stack stack Aggregates multiple channels into one
single single Writes to a single file (storage/logs/laravel.log)
daily daily Rotates log files daily, keeps 14 days by default
slack slack Sends log messages to a Slack webhook
papertrail monolog Streams logs to Papertrail via UDP/TCP
stderr monolog Writes to stderr — useful in Docker/CI
syslog syslog Writes to the system syslog
errorlog errorlog Writes using PHP's error_log()
null monolog Discards all log messages — handy in testing

LOG_STACK

When you use the stack channel as your default, the LOG_STACK environment variable controls which channels are bundled into that stack. This is the most flexible setup for production.

# .env

# Use only the daily file channel
LOG_STACK=daily

# Write to daily files AND push critical errors to Slack
LOG_STACK=daily,slack

# Use a single file (simplest, good for small projects)
LOG_STACK=single

The stack channel in config/logging.php picks up LOG_STACK like this:

// config/logging.php
'stack' => [
    'driver'            => 'stack',
    'channels'          => explode(',', env('LOG_STACK', 'single')),
    'ignore_exceptions' => false,
],

A few practical combinations:

  • Local development: LOG_STACK=single — one file, no rotation noise
  • Staging: LOG_STACK=daily — daily rotation so you can check specific days
  • Production: LOG_STACK=daily,slack — file for history, Slack for immediate alerts on errors
  • Docker / containers: LOG_STACK=stderr — writes to stdout/stderr, picked up by the container log driver

LOG_LEVEL

LOG_LEVEL sets the minimum severity that a channel will record. Messages below this level are silently dropped.

# .env

# Only record errors and above (error, critical, alert, emergency)
LOG_LEVEL=error

# Record everything including debug output
LOG_LEVEL=debug

# Good balance for production
LOG_LEVEL=warning

This is applied per channel in config/logging.php:

'daily' => [
    'driver' => 'daily',
    'path'   => storage_path('logs/laravel.log'),
    'level'  => env('LOG_LEVEL', 'debug'),
    'days'   => 14,
],

Setting LOG_LEVEL=debug in production will generate a lot of noise and eat disk space fast. Use warning or error on production servers and only drop to debug when actively investigating an issue.


Creating a custom channel

There are two common approaches: adding a new named channel to config/logging.php, or using a Monolog handler directly.

1. A dedicated channel for a specific feature

Useful when you want to keep, say, payment logs completely separate from your main app log:

// config/logging.php
'channels' => [

    // ... existing channels ...

    'payments' => [
        'driver' => 'daily',
        'path'   => storage_path('logs/payments.log'),
        'level'  => 'debug',
        'days'   => 30,
    ],

],

Then in your code:

Log::channel('payments')->info('Charge succeeded', [
    'amount'     => $charge->amount,
    'customer'   => $charge->customer,
]);

2. A custom Monolog handler

When you need something beyond the built-in drivers, use the monolog driver and pass any Monolog handler:

// config/logging.php
'gelf' => [
    'driver'  => 'monolog',
    'handler' => \Monolog\Handler\GelfHandler::class,
    'with'    => [
        'publisher' => new \Gelf\Publisher(
            new \Gelf\Transport\UdpTransport('logs.example.com', 12201)
        ),
    ],
],

3. A fully custom channel via a factory

For total control, use the custom driver and point it to a factory class:

// config/logging.php
'custom-channel' => [
    'driver' => 'custom',
    'via'    => \App\Logging\CreateCustomLogger::class,
],
// app/Logging/CreateCustomLogger.php
namespace App\Logging;

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;

class CreateCustomLogger
{
    public function __invoke(array $config): Logger
    {
        $handler = new StreamHandler(storage_path('logs/custom.log'));
        $handler->setFormatter(new JsonFormatter());

        return new Logger('custom', [$handler]);
    }
}

Stacking channels

The stack driver lets you combine multiple channels so a single Log::info() call writes to all of them simultaneously. You can define multiple named stacks for different environments or use cases.

// config/logging.php
'production' => [
    'driver'            => 'stack',
    'channels'          => ['daily', 'slack'],
    'ignore_exceptions' => false,
],

'slack' => [
    'driver'   => 'slack',
    'url'      => env('LOG_SLACK_WEBHOOK_URL'),
    'username' => 'Laravel Log',
    'emoji'    => ':boom:',
    'level'    => env('LOG_LEVEL', 'critical'), // Only push critical and above to Slack
],

The ignore_exceptions flag controls what happens if one channel in the stack fails (e.g. Slack is unreachable). Set it to true if you don't want a logging failure to break your application.

You can also log to a specific channel or stack on the fly:

// Log to a specific channel, ignoring the default
Log::channel('slack')->critical('Database unreachable');

// Log to multiple channels at once
Log::stack(['daily', 'slack'])->error('Something went wrong');

Tips & tricks

Add context to every log message in a request

Use Log::withContext() in a middleware to attach user or request context to every log entry automatically:

// app/Http/Middleware/LogContext.php
public function handle(Request $request, Closure $next): Response
{
    if ($user = $request->user()) {
        Log::withContext([
            'user_id' => $user->id,
            'email'   => $user->email,
        ]);
    }

    return $next($request);
}

Share context for the entire request lifecycle

Log::shareContext() goes one step further and shares context across all channels, including those created via Log::channel():

Log::shareContext([
    'request_id' => (string) Str::uuid(),
]);

Tap into Monolog to add processors

Monolog processors let you add extra data to every log record automatically. Use the tap key to customize a channel without switching drivers:

// config/logging.php
'daily' => [
    'driver' => 'daily',
    'path'   => storage_path('logs/laravel.log'),
    'level'  => env('LOG_LEVEL', 'debug'),
    'days'   => 14,
    'tap'    => [\App\Logging\AddEnvironmentData::class],
],
// app/Logging/AddEnvironmentData.php
namespace App\Logging;

use Illuminate\Log\Logger;
use Monolog\Processor\WebProcessor;

class AddEnvironmentData
{
    public function __invoke(Logger $logger): void
    {
        foreach ($logger->getHandlers() as $handler) {
            $handler->pushProcessor(new WebProcessor());
        }
    }
}

Use a separate channel for slow queries

// app/Providers/AppServiceProvider.php
DB::listen(function (QueryExecuted $query) {
    if ($query->time > 1000) { // ms
        Log::channel('slow-queries')->warning('Slow query detected', [
            'sql'      => $query->sql,
            'bindings' => $query->bindings,
            'time_ms'  => $query->time,
        ]);
    }
});

Suppress logs in tests

Set LOG_CHANNEL=null in phpunit.xml or your testing .env.testing to discard all log output during test runs:

<!-- phpunit.xml -->
<env name="LOG_CHANNEL" value="null"/>

Troubleshooting

Nothing is being logged

  • Check that LOG_LEVEL isn't set too high. If it's error, Log::info() and Log::debug() will be silently dropped.
  • Make sure storage/logs is writable: chmod -R 775 storage/logs
  • Verify your LOG_CHANNEL / LOG_STACK value actually exists as a key in the channels array in config/logging.php.
  • Run php artisan config:clear — a cached config won't pick up .env changes.

Logs end up in the wrong file

When using the stack channel, double-check which channels are included. LOG_STACK takes a comma-separated list — no spaces around commas:

# Correct
LOG_STACK=daily,slack

# Wrong — the space will cause the channel name to be " slack" (not found)
LOG_STACK=daily, slack

Slack notifications not arriving

  • Verify LOG_SLACK_WEBHOOK_URL is set and the webhook is still active in your Slack app settings.
  • The default Slack channel level is critical. Lower it to error or warning if you're testing:
    'slack' => [
        'driver' => 'slack',
        'url'    => env('LOG_SLACK_WEBHOOK_URL'),
        'level'  => 'error', // was 'critical'
    ],
  • Make sure your server can reach Slack's API — check firewall rules and ignore_exceptions to surface any connection errors.

Daily log files not rotating

  • The daily driver creates a new file each day — files are named laravel-YYYY-MM-DD.log. If you only see one file, the app may not have been running on subsequent days.
  • The days option controls retention, not rotation. Rotation happens automatically on each new day.
  • If you're on a shared host or NFS mount, check that file locking works correctly — Monolog uses StreamHandler which acquires file locks.

Log file grows without limit

You're probably using the single driver in production. Switch to daily and set a reasonable days value, or configure log rotation at the OS level with logrotate.

Seeing "failed to open stream: Permission denied"

Your web server user (typically www-data) doesn't have write access to storage/logs. Fix it with:

chown -R www-data:www-data storage/logs
chmod -R 775 storage/logs

Context data is missing from log entries

Make sure you pass context as the second argument — it must be an associative array:

// Correct
Log::info('Order created', ['order_id' => $order->id]);

// Wrong — this passes a string, not context
Log::info('Order created', $order->id);