illuminate/log
Laravel's logging component provides a PSR-3 compatible logger with powerful channel-based configuration, built-in Monolog integration, and handlers for single files, daily rotation, stacks, syslog, and error logs. It supports contextual logging and custom drivers for flexible application logging.
In a Laravel application this package is already installed and wired — you almost never composer require it. Start in three places:
config/logging.php — the default key and the channels array. This is the actual API surface you operate day to day..env — LOG_CHANNEL (or LOG_STACK on Laravel 11+) and LOG_LEVEL.LogManager::createSingleDriver(), createDailyDriver(), createStackDriver(), createSlackDriver(), createSyslogDriver(), createErrorlogDriver(). If a config key isn't read there, it does nothing — the docs are thinner than the code.First real use case — log an event with structured context, not string interpolation:
use Illuminate\Support\Facades\Log;
Log::info('Order placed', ['order_id' => $order->id, 'total' => $order->total]);
Then watch it: tail -f storage/logs/laravel.log, or php artisan pail for a live, filtered stream.
Standalone (non-Laravel) use is possible but awkward: the package needs illuminate/container, illuminate/config and illuminate/events to build a LogManager, or you can bypass it entirely and use the thin Logger wrapper over Monolog.
composer require illuminate/log monolog/monolog
use Illuminate\Log\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Logger as Monolog;
$logger = new Logger(new Monolog('app', [new StreamHandler('php://stderr')]));
$logger->info('Application started', ['pid' => getmypid()]);
Or bootstrap the manager when you want channel() semantics without the framework:
$app = new Illuminate\Container\Container;
$app->instance('config', new Illuminate\Config\Repository(['logging' => $config]));
$app->instance('events', new Illuminate\Events\Dispatcher($app));
$logger = (new Illuminate\Log\LogManager($app))->channel('daily');
The $config array is the same shape as config/logging.php. First use case in a framework app is always the same: one default channel, one withContext() call for a request ID, done.
Channel per concern, not one giant file. Give payments, auth, and audits their own drivers so retention, formatter, and destination can differ:
'channels' => [
'payments' => [
'driver' => 'daily',
'path' => storage_path('logs/payments.log'),
'level' => 'info',
'days' => 90,
'permission' => 0664,
'locking' => true,
],
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'with' => ['stream' => 'php://stderr'],
'level' => env('LOG_LEVEL', 'debug'),
],
],
The stderr channel above is the correct default for Docker/Kubernetes — the container runtime collects it, you get no log-rotation bugs and no writes to a read-only filesystem.
Stacks for fan-out. 'driver' => 'stack', 'channels' => ['single', 'slack'] sends everything everywhere. A stack cannot filter per member, so if you only want errors in Slack, wrap the Slack handler instead:
$handler = new FilterHandler(new SlackWebhookHandler(/* ... */), ['error', 'critical', 'alert', 'emergency']);
Request context in middleware, instead of repeating array keys in every call:
Log::withContext(['request_id' => (string) Str::uuid(), 'user_id' => auth()->id()]);
withContext() returns a new logger instance — it does not mutate the default channel. Bind it per request or resolve it from the container.
Queue/Octane context propagation (Laravel 11+) uses the Context repository, which is far better than stuffing IDs into every call:
use Illuminate\Support\Facades\Context;
Context::add('user_id', $user->id); // flat key, appears in every record's context
Context::push('steps', 'invoiced'); // accumulates into an array
Context::add('token', $token, hidden: true); // available to logs, excluded from serialization
Register dehydrate/hydrate callbacks on the Context repository for values that must survive into a queued job.
Custom drivers go in a service provider's boot() (not a deferred one — the driver must exist before the channel is first resolved):
Log::extend('audit', function ($app, array $config) {
$handler = new StreamHandler($config['path']);
return new Logger(new Monolog('audit', [$handler]), $app['events']);
});
Scrub secrets and add metadata with a Monolog processor, or with a 'tap' => [App\Logging\RedactSecrets::class] class whose __invoke($logger) receives the built driver. Keep that parameter untyped and instanceof-check it — the argument differs between drivers.
Log exceptions with the exception key so Monolog's normalizer prints the stack trace:
Log::error('Payment capture failed', ['exception' => $e, 'order_id' => $id]);
In a real Laravel app prefer report($e), which routes through the exception handler and respects dontReport.
Testing. Three options, cheapest first:
Log::spy();
// ... act ...
Log::shouldHaveReceived('info')->withArgs(fn ($m, $c) => $c['order_id'] === 42);
Event::fake([MessageLogged::class]); // assert on the MessageLogged event
For output-level assertions, push a Monolog\Handler\TestHandler via Log::extend() in the test and inspect getRecords().
LogManager::get() catches InvalidArgumentException (typo'd channel, Driver [x] is not supported, closed stream) and returns an emergency logger pointed at storage/logs/laravel.log at debug. Your app looks fine and your logs vanish into the wrong file. Symptom to remember: "it logs, just not where I configured." Inspect what you actually got with Log::channel('payments')->getLogger()->getHandlers() — Logger::__call forwards to Monolog, so this works.level is a severity floor, not a filter. 'level' => 'warning' silently discards info and debug. Setting 'level' => 'emergency' means the channel records essentially nothing. For "only this level, nothing above or below," wrap in FilterHandler.env() inside config/logging.php returns null after php artisan config:cache. Standard Laravel footgun, but it bites hardest here because a null path or channel throws inside the manager and gets swallowed by the emergency-logger fallback above.handle(): bool and formatter constructors. Check the PHP/Monolog floor in the composer.json of the branch you actually install and pin consistently.Context leaks across long-lived processes. In Octane or a queue worker the repository persists between requests/jobs. withoutContext() (returns a clone) helps for a single call, but you generally need explicit Context::flush() in a terminating callback.MessageLogged fires on every write. A listener that does I/O, or worse logs something itself, will tank throughput or recurse. Keep listeners cheap and guard against re-entry.Log::extend() and shareContext() must run before a channel is first resolved; once cached in LogManager::$channels the driver is reused for the process lifetime. Register in boot(), never lazily.days and, on shared hosts, locking. Also note the filename is laravel-YYYY-MM-DD.log; permission is ignored if the file already exists with other ownership.Psr\Log\LoggerInterface rather than using the facade in services and jobs — Laravel aliases log to it, so you get testability without Log::spy() gymnastics.Log::extend() for new drivers → Monolog processors for record mutation → tap classes for formatter/handler tweaks → listeners on MessageLogged for observation. Reach for the last two only when the first isn't enough, because they're the ones that behave differently per driver.How can I help you explore Laravel packages today?