# 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.

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](https://laravel.com/docs/13.x/logging) and the [Context docs](https://laravel.com/docs/13.x/context) 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:

```dotenv
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:

```dotenv
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:

```php
// 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=info` is a good default: you keep "order paid" and "import finished", and lose the debug noise of packages.
- `warning` if your logs are large and you only read them when something broke.
- `debug` in 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):

```php
// 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 artisan` as `root` at 00:01, PHP-FPM as `www-data` can'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' => 0664` on the channel with a shared group.
- **Queue workers keep the old file.** If you rotate `single` with `logrotate` instead, 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. Use `copytruncate` in 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`:

```php
'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:

```php
Log::info('Order {order} paid', ['order' => 1042, 'amount_cents' => 4900]);
```

now writes one JSON object per line:

```json
{"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:

1. **Per call**: the array you pass, as above.
2. **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.
3. **Everywhere, including queued jobs**: the `Context` facade, since Laravel 11.

`Context` is the one to use. Add it in a middleware:

```php
<?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`):

```json
{"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 `Authorization` headers.
- 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](https://laravel.com/docs/13.x/logging#tailing-log-messages-using-pail) tails the logs with filters, for any channel (it needs `pcntl`):

```bash
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](/docs/laravel/) adds a `lampwick` log channel; put it in your stack next to the files:

```dotenv
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](/docs/issues/), which do alert; an agent can read logs too, with `listLogs` over [MCP](/docs/mcp/). The [error tracking page](/features/error-tracking/) 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](/docs/laravel/). For logs from queued jobs that fail, see [Laravel queue monitoring](/guides/laravel-failed-jobs/).
