Dash0 Raises $110M Series B at $1B Valuation

Last updated: June 5, 2026

Laravel Logging: A Practitioner's Guide

Laravel's logging system is more capable than its documentation lets on. Under the surface, the framework wraps Monolog with its own conventions for contextual data, channel routing, and exception reporting.

Recent versions introduced the Context facade, which changes how you should think about attaching metadata to log entries. And the OpenTelemetry ecosystem now provides first-class support for PHP and Laravel, making it possible to connect your logs to distributed traces without writing custom plumbing.

But there's a significant gap between calling Log::info() in a controller and having logs that actually help you understand what your application is doing in production. Bridging that gap requires deliberate choices about output format, contextual enrichment, exception handling, channel architecture, and telemetry integration.

This guide focuses on exactly those choices. It assumes you understand the basics of Laravel's channel system and Monolog's role behind it. If you need a refresher on Monolog itself, including handlers, processors, formatters, and the PSR-3 interface, our PHP Logging with Monolog guide covers the library in depth.

By the end, your Laravel logs will be structured, enriched with per-request context, and flowing through an OpenTelemetry pipeline into a centralized platform where you can correlate them with traces and metrics across your entire infrastructure.

Prerequisites

Before working through the examples in this guide, make sure your environment includes:

  • PHP 8.2 or later
  • Composer (latest version)
  • Laravel 11 or newer

You should also be comfortable running Artisan commands and editing Laravel configuration files.

How Laravel's logging system works

Laravel's logging infrastructure lives in the config/logging.php configuration file. This file defines a set of channels, each of which describes a destination and format for log output. When your application calls Log::info() or Log::error(), Laravel routes the message through whichever channel is currently active.

Understanding the default configuration

A fresh Laravel installation ships with several pre-configured channels, but the one that matters on day one is stack. The default key at the top of config/logging.php determines which channel is used when you don't specify one explicitly, and it reads from the LOG_CHANNEL environment variable:

php
123456789101112131415161718192021222324252627282930
// config/logging.php
return [
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
// ... additional channels omitted
],
];

The stack driver is a meta-channel that fans out to one or more other channels listed in its channels array. By default it points to single, which writes everything to a single file at storage/logs/laravel.log. You can add more channels to the stack (or swap single for daily, which rotates files automatically) by editing this array.

Channels, drivers, and Monolog

Each channel specifies a driver that determines its behavior. Laravel ships with several built-in drivers:

  • single writes to one file that grows indefinitely.
  • daily creates a new file each day (e.g. laravel-2026-04-12.log) and removes files older than the configured days value.
  • syslog and errorlog route messages to PHP's native syslog() and error_log() functions respectively.

Under the hood, all of these drivers create Monolog handler instances. For example, the single driver uses Monolog's StreamHandler, daily uses RotatingFileHandler, and so on.

If you need to use a Monolog handler that doesn't have a dedicated Laravel driver, you can use the monolog driver to reference any handler class directly, or the custom driver to build a channel from scratch in a factory class. You'll see both of these in later sections.

Log levels

Laravel follows the eight severity levels defined in RFC 5424, from DEBUG (least severe) through INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, to EMERGENCY (most severe). Each channel has a level setting that acts as a minimum threshold: any message below that level is silently discarded.

The LOG_LEVEL environment variable in your .env file controls this threshold for channels that reference it. In development, debug is a sensible default. In production, most teams set it to info or warning to reduce noise and storage costs, then lower it temporarily during incidents.

Where the .env file fits in

Your .env file is where you control logging behavior without touching the configuration code:

text
1234
LOG_CHANNEL=stack
LOG_LEVEL=debug
LOG_DAILY_DAYS=14
LOG_DEPRECATIONS_CHANNEL=null

LOG_CHANNEL selects the active channel, LOG_LEVEL sets the minimum severity, LOG_DAILY_DAYS controls how many days of rotated log files to retain (when using the daily driver) andLOG_DEPRECATIONS_CHANNEL routes PHP and Laravel deprecation warnings to a specific channel, which is useful when preparing for major version upgrades.

Writing your first log entry

With the default setup, you can immediately begin creating log entries in your application:

php
12345678910111213
// routes/web.php
use Illuminate\Support\Facades\Log;
Route::get('/test-logging', function () {
Log::info('Application started.');
Log::error('Something went wrong.', [
'component' => 'payment-gateway',
]);
return response()->json([
'message' => 'Logs written.',
]);
});

The second argument is an optional context array that gets appended to the log entry. Check storage/logs/laravel.log and you'll see output like this:

text
12
[2026-04-12 20:14:29] local.INFO: Application started.
[2026-04-12 20:14:29] local.ERROR: Something went wrong. {"component":"payment-gateway"}

The format is [timestamp] environment.LEVEL: message {context}. It's human-readable, which is fine for local development. But this format has real limitations once your application reaches production.

Switching to structured JSON output

The default format you saw above is produced by Monolog's LineFormatter. It's readable in a terminal, but it falls apart in production: multi-line stack traces break line-based tooling, context values are hard to search reliably, and parsing requires fragile regular expressions.

Structured JSON output solves all of this by turning each log entry into a well-defined object that downstream systems can index, filter, and aggregate reliably.

Laravel's tap mechanism lets you customize how Monolog is configured for any channel without replacing the channel driver. Create a class that receives the logger instance and swaps in the JsonFormatter:

php
123456789101112131415161718
<?php
// app/Logging/JsonFormatter.php
namespace App\Logging;
use Monolog\Formatter\JsonFormatter as MonologJsonFormatter;
class JsonFormatter
{
public function __invoke($logger)
{
foreach ($logger->getHandlers() as $handler) {
$handler->setFormatter(
new MonologJsonFormatter()
);
}
}
}

In Docker or Kubernetes, writing to stdout or stderr is the recommended approach because container runtimes capture these streams natively and orchestrators handle collection, rotation, and forwarding for you. This aligns with the twelve-factor app methodology and keeps log management out of your application code.

Laravel already ships with a stderr channel in config/logging.php. Add the tap key to it so that it uses your JsonFormatter class:

php
12345678910111213
// config/logging.php
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
'tap' => [App\Logging\JsonFormatter::class],
'processors' => [PsrLogMessageProcessor::class],
],

Make sure LOG_CHANNEL=stack is set (it's the default), then set LOG_STACK=stderr to tell the stack which channel to fan out to:

bash
123
# .env
LOG_CHANNEL=stack
LOG_STACK=stderr

Now visit /test-logging again and you'll see JSON output directly in your terminal:

json
12
{"message":"Application started.","context":{},"level":200,"level_name":"INFO","channel":"local","datetime":"2026-04-12T20:30:42.918064+00:00","extra":{}}
{"message":"Something went wrong.","context":{"component":"payment-gateway"},"level":400,"level_name":"ERROR","channel":"local","datetime":"2026-04-12T20:30:42.920222+00:00","extra":{}}

The context field carries the data you passed explicitly with the log call, while extra is reserved for data injected automatically by Monolog processors. Both are now first-class, queryable fields rather than a string appended to the end of a line.

Contextual logging in Laravel

Logs only become valuable when they carry enough context to explain what happened, to whom, and as part of which operation.

Laravel provides several mechanisms for attaching this context. Understanding which one to use and when is one of the most important production logging decisions you'll make.

Per-message context

The simplest form of context is the array you pass as the second argument to any Log method:

php
12345678
use Illuminate\Support\Facades\Log;
Log::error('Payment processing failed.', [
'order_id' => $order->id,
'gateway' => 'stripe',
'error_code' => $exception->getCode(),
'customer_id' => $order->customer_id,
]);

This data appears in the context field of the resulting JSON record, and it should be used for event-specific context alone. The quality of the resulting logs largely depends on the attributes you choose to include here.

An error log that says "processing failed" tells you almost nothing; one that includes the order ID, gateway name, and error code gives you the necessary details to aid your investigation.

The Context facade (Laravel 11+)

Per-message context doesn't scale when the same attributes need to appear on every log entry in a request. Passing cross-cutting attributes into every Log call is tedious, inconsistent, and easy to forget in the one place where it matters most.

Laravel 11 introduced the Context facade to solve exactly this problem. It provides a request-scoped data store that's automatically appended to every subsequent log entry without you needing to pass it explicitly.

This is the recommended approach for request-scoped metadata like request IDs, authenticated user details, and tenant identifiers. A middleware is the natural place to set it up:

php
1234567891011121314151617181920212223242526
<?php
// app/Http/Middleware/AttachRequestContext.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 AttachRequestContext
{
public function handle(
Request $request,
Closure $next
): Response {
Context::add('request_id', Str::uuid()->toString());
if ($user = $request->user()) {
Context::add('user_id', $user->id);
}
return $next($request);
}
}

Register this middleware globally in your application's bootstrap/app.php:

php
12345
->withMiddleware(function (Middleware $middleware) {
$middleware->append(
\App\Http\Middleware\AttachRequestContext::class
);
})

From this point forward, every log call made during the request will automatically include the request_id and user_id fields (if available) without you needing to pass them explicitly.

You'll see these fields in the extra attribute:

json
12
{"message":"Application started.",..., "extra":{"request_id":"2321d78f-2212-4b04-bd81-c0d064022655"}}
{"message":"Something went wrong.",..., "extra":{"request_id":"2321d78f-2212-4b04-bd81-c0d064022655"}}

Context propagation across queued jobs

One of the most powerful features of the Context facade is its automatic propagation to queued jobs. When Laravel dispatches a job, it serializes the current context data through a dehydration step. Then when a queue worker picks up the job, the context is hydrated back into the worker's process.

This means that if your middleware sets a request_id and user_id via Context::add(), and the request dispatches a ProcessOrder job to the queue, the log entries emitted by that job will automatically include the same request_id and user_id. You get a thread of correlation from the initial HTTP request through to the background processing without any manual wiring.

php
123456789101112
// In your controller or service
Context::add('request_id', Str::uuid()->toString());
Context::add('user_id', $user->id);
// Later in the same request
ProcessOrder::dispatch($order);
// Inside the job's handle() method, logs automatically
// carry request_id and user_id from the originating request
Log::info('Processing order.', [
'order_id' => $order->id,
]);

If you need to add job-specific context without overwriting the inherited request context, you can call Context::add() inside the job's handle() method.

Preventing sensitive data from leaking into your logs

The more context you attach to your logs, the more useful they become for debugging. But that same habit creates a risk: it's easy to inadvertently log API keys, bearer tokens, passwords, or personally identifiable information that should never be exposed in your logs.

Laravel's Context facade provides one built-in safeguard through hidden context, and you can supplement it with a sanitization layer in your own code.

Using hidden context for sensitive data

The Context facade supports hidden context, which is data that's available to your application code but never written to log entries. This is useful for values that need to flow through the request lifecycle for authorization or routing decisions but shouldn't appear in logs.

php
12345
Context::addHidden('api_key', $request->bearerToken());
Context::addHidden(
'session_token',
$request->session()->getId()
);

Hidden context propagates to queued jobs just like regular context, but it's excluded from log output by design.

Redacting sensitive keys with a Monolog processor

Another safety net is using a Monolog processor that intercepts every log record and redacts specified keys before they reach any handler, regardless of how the log call was written.

For something this security-critical, you're better off writing this yourself than pulling in a third-party package. A processor that covers the common cases is around 20 lines, you own the logic entirely, and there's nothing to audit or keep updated.

Laravel's tap mechanism expects a class with an __invoke($logger) signature, so combine both responsibilities in one class: the tap registers the processor, and the processor does the redaction:

php
1234567891011121314151617181920212223242526272829303132333435
<?php
// app/Logging/RedactSensitiveFields.php
namespace App\Logging;
use Monolog\LogRecord;
class RedactSensitiveFields
{
private array $keys = [
'password',
'password_confirmation',
'api_key',
'token',
'secret',
'ssn',
'credit_card',
'authorization',
];
public function __invoke($logger): void
{
$logger->pushProcessor(function (LogRecord $record) {
$context = $record->context;
foreach ($this->keys as $key) {
if (isset($context[$key])) {
$context[$key] = '********';
}
}
return $record->with(context: $context);
});
}
}

To wire this into your Laravel channel, add it to its tap array:

php
123456789
// config/logging.php
'stderr' => [
// ...existing config...
'tap' => [
App\Logging\JsonFormatter::class,
App\Logging\RedactSensitiveFields::class,
],
],

Now even if someone writes Log::info('Login attempt.', $request->all()), sensitive fields are masked before they're written anywhere. You can test this with a quick route:

php
1234567891011
Route::get('/test-redaction', function () {
Log::info('login attempt', [
'user_id' => 'usr-1234',
'password' => 'secret123',
'api_key' => 'sk-live-abc123xyz',
]);
return response()->json([
'message' => 'Check logs for redacted output.',
]);
});

The resulting log entry will show both password and api_key fully masked, while user_id passes through:

json
123456789101112
{
"message": "login attempt",
"context": {
"user_id": "usr-1234",
"password": "********",
"api_key": "********"
},
"level": 200,
"level_name": "INFO",
"channel": "local",
"datetime": "2026-04-13T07:12:04.584784+00:00"
}

Keep in mind that this approach isn't foolproof since a developer can still log a sensitive value under a key name that isn't in the redaction list so this doesn't remove the need for code review and team awareness around what gets logged.

It's also worth noting that sensitive values can leak through exception stack traces, not just context arrays. PHP 8.2's #[\SensitiveParameter] attribute lets you mark function parameters so their values are replaced with a placeholder in stack traces. Apply it to any parameter that accepts credentials, tokens, or secrets.

If your logs flow through an OpenTelemetry Collector (which we'll set up later in this guide), you can add further layers of redaction that strip sensitive attributes from log records and other telemetry signals before they leave your infrastructure.

This catches anything the application layer missed, and it's where you'd put redaction rules that don't belong in application code.

Capturing debug context only when errors occur

One of the hardest trade-offs in logging is choosing between verbosity and cost. You generally want DEBUG-level detail when actively investigating a production issue, but emitting it continuously generates data volumes that are expensive to store and almost never worth the cost on the requests where nothing interesting happens.

Monolog's FingersCrossedHandler solves this by buffering all log records in memory without writing them anywhere. The moment a record at or above a configurable trigger level arrives (typically ERROR), the handler flushes the entire buffer to the wrapped handler.

Because FingersCrossedHandler takes a handler instance as its first constructor argument, you can't wire it up cleanly using Laravel's monolog driver alone as it resolves a single handler class and has no way to express the nested handler relationship.

The right approach is the custom driver with a factory class. This also keeps your config array free of object instantiation and safe to serialize with php artisan config:cache.

Create the factory class:

php
123456789101112131415161718192021222324252627
<?php
// app/Logging/BufferedChannel.php
namespace App\Logging;
use Monolog\Handler\FingersCrossedHandler;
use Monolog\Handler\FingersCrossed\ErrorLevelActivationStrategy;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
class BufferedChannel
{
public function __invoke(array $config): Logger
{
$logger = new Logger('buffered');
$innerHandler = new StreamHandler('php://stderr');
$logger->pushHandler(new FingersCrossedHandler(
$innerHandler,
new ErrorLevelActivationStrategy(\Monolog\Level::Error),
bufferSize: 200,
));
return $logger;
}
}

Then register it as a custom channel in your logging configuration:

php
12345678910
// config/logging.php
'buffered' => [
'driver' => 'custom',
'via' => App\Logging\BufferedChannel::class,
'tap' => [
App\Logging\JsonFormatter::class,
App\Logging\RedactSensitiveFields::class,
],
],

This channel buffers all log records in memory and only flushes them to stderr when a record at ERROR level or above arrives. If the request completes without an error, the buffer is discarded and nothing is written.

The bufferSize parameter caps how many records the buffer holds. It's unbounded by default which could lead to memory exhaustion for long-running queue workers that process many jobs back-to-back.

It's best to cap the bufferSize to keep memory usage predictable, so set it to a value that reflects how verbose your debug logging is and how long your workers run.

For this channel to receive your logs, include it alongside your existing stderr channel via LOG_STACK:

text
1
LOG_STACK=stderr,buffered

Note that LOG_LEVEL controls the minimum level for the stderr channel (which references it), but the buffered channel hardcodes level: debug so it always accepts every record into its buffer regardless of your production LOG_LEVEL setting. Only the ErrorLevelActivationStrategy determines when the buffer is flushed.

You can test this by adding a route that either succeeds or fails:

php
12345678910111213141516171819
// routes/web.php
Route::get(
'/test-buffered/{fail?}',
function (string $fail = 'no') {
Log::debug('Step 1: validating input.');
Log::debug('Step 2: checking inventory.');
Log::info('Step 3: charging payment.');
if ($fail === 'yes') {
Log::error('Step 4: payment failed.');
} else {
Log::info('Step 4: payment succeeded.');
}
return response()->json([
'message' => "Done (fail=$fail). Check logs.",
]);
}
);

Make sure your LOG_LEVEL environment variable is set to info, then hit the /test-buffered/no endpoint. You'll see two INFO messages from stderr channel:

text
12
{"message":"Step 3: charging payment.","level_name":"INFO", ...}
{"message":"Step 4: payment succeeded.","level_name":"INFO", ...}

The buffered channel accumulated all four entries in-memory but discarded them because no error occurred. Now, hit /test-buffered/yes (to simulate error conditions) and you'll see an ERROR message from stderr as before, plus all four entries (including the two DEBUG messages) flushed by the buffered channel:

text
123456789
// stderr channel
{"message":"Step 3: charging payment.","level_name":"INFO", ...}
{"message":"Step 4: payment failed.","level_name":"ERROR", ...}
// buffered channel (the error + full context)
{"message":"Step 1: validating input.","level_name":"DEBUG", ...}
{"message":"Step 2: checking inventory.","level_name":"DEBUG", ...}
{"message":"Step 3: charging payment.","level_name":"INFO", ...}
{"message":"Step 4: payment failed.","level_name":"ERROR", ...}

The result is zero noise on success and full context on failure.

You'll notice that INFO and ERROR entries appear in both channels when the buffer flushes. This duplication is inherent to the pattern: the buffered channel needs to see the ERROR to trigger its flush, and stderr writes INFO+ as its normal baseline.

In most setups you'll want to avoid the duplication to avoid paying unnecessary ingestion or egress costs. The cleanest way is to wrap the inner StreamHandler in a FilterHandler that only passes DEBUG records through. When the buffer flushes on ERROR, FingersCrossedHandler sends every buffered record to the inner handler, but FilterHandler discards anything above DEBUG since those were already written by stderr.

Update BufferedChannel like this:

php
1234567891011121314151617181920212223242526272829303132
<?php
// app/Logging/BufferedChannel.php
namespace App\Logging;
use Monolog\Handler\FilterHandler;
use Monolog\Handler\FingersCrossedHandler;
use Monolog\Handler\FingersCrossed\ErrorLevelActivationStrategy;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
class BufferedChannel
{
public function __invoke(array $config): Logger
{
$logger = new Logger('buffered');
$innerHandler = new FilterHandler(
new StreamHandler('php://stderr'),
Level::Debug,
Level::Debug,
);
$logger->pushHandler(new FingersCrossedHandler(
$innerHandler,
new ErrorLevelActivationStrategy(Level::Error),
));
return $logger;
}
}

The FilterHandler constructor takes a minimum and maximum level, so passing Level::Debug for both limits output strictly to DEBUG records. Now the flushed output contains only what stderr didn't already write:

The Log::debug() calls still go through the default stack, where stderr drops them (because LOG_LEVEL=info) and buffered accepts them into its memory buffer. But INFO goes directly to stderr only, and ERROR goes directly to buffered only, so there are no duplicate entries:

json
1234567
// stderr channel
{"message":"Step 3: charging payment.","level_name":"INFO", ...}
{"message":"Step 4: payment failed.","level_name":"ERROR", ...}
// buffered channel (DEBUG records only)
{"message":"Step 1: validating input.","level_name":"DEBUG", ...}
{"message":"Step 2: checking inventory.","level_name":"DEBUG", ...}

The out-of-order appearance is only a visual issue in your terminal. Each JSON entry includes a datetime field with microsecond precision, so any log aggregation platform will sort them correctly regardless of the order they arrive.

Exception handling and error logging

Laravel's exception handler is the single most important piece of your logging infrastructure, because it's the last line of defense for errors that aren't caught by application code. Get it wrong and errors disappear silently

How Laravel handles exceptions by default

When an unhandled exception occurs, Laravel's exception handler logs it at the ERROR level and renders an appropriate response. The default behavior already does several useful things: it serializes the exception class, message, file, line, and full stack trace into the log entry.

In most cases, you don't need to override this behavior. What you do need is to ensure that the surrounding context is rich enough to make the logged exception actionable.

Adding context to exceptions

Laravel supports a context() method on exception classes. If your custom exception defines this method, the returned array is automatically merged into the log entry's context when the exception is reported:

php
123456789101112131415161718192021222324252627
<?php
namespace App\Exceptions;
use RuntimeException;
class PaymentFailedException extends RuntimeException
{
public function __construct(
string $message,
private string $orderId,
private string $gateway,
private string $errorCode,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
public function context(): array
{
return [
'order_id' => $this->orderId,
'gateway' => $this->gateway,
'error_code' => $this->errorCode,
];
}
}

When this exception is thrown and reaches the handler, the resulting log entry includes both the exception details and your structured business context automatically. This is cleaner than catching the exception just to add context to a manual Log::error() call.

Controlling exception reporting

Laravel's bootstrap/app.php lets you customize exception reporting behavior. You can use reportable() to add logic for specific exception types, or dontReport() to suppress exceptions that you know are benign:

php
123456789101112131415161718
use App\Exceptions\PaymentFailedException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->reportable(
function (PaymentFailedException $e) {
Log::channel('critical-alerts')->error(
'Payment failure requires attention.',
$e->context()
);
// Return false to prevent default reporting
// (which would double-log), or omit the return
// to let both the custom and default reporting
// happen.
return false;
}
);
})

Avoiding duplicate logging

A common mistake is catching an exception, logging it manually, and then re-throwing it so that the exception handler logs it again:

php
123456789
// This produces two log entries for the same error
try {
$this->processPayment($order);
} catch (PaymentFailedException $e) {
Log::error('Payment failed.', [
'order_id' => $order->id,
]);
throw $e; // The exception handler also logs this
}

The cleaner approach is to let the exception carry its own context (via the context() method shown above) and let Laravel's exception handler do the logging. If you need additional custom behavior, use reportable() in the exception configuration rather than catch-and-rethrow patterns.

When you do need to catch an exception for control flow but still want it reported, use Laravel's report() helper:

php
1234567
try {
$this->processPayment($order);
} catch (PaymentFailedException $e) {
report($e);
// Handle the failure gracefully without re-throwing
return $this->fallbackResponse($order);
}

The report() helper sends the exception through the standard reporting pipeline exactly once.

Connecting Laravel logs to OpenTelemetry

Everything covered so far makes your logs useful within a single service. But production applications rarely consist of a single service. Your Laravel API might talk to a payment gateway, a notification service, a queue worker, and a database. When something fails, you need to follow the thread across all of them.

OpenTelemetry makes this possible. By routing your Laravel logs through an OpenTelemetry pipeline, each record is normalized into a vendor-neutral data model and enriched with trace and span identifiers that link it to the distributed trace it belongs to.

You can start from a log entry that says "payment failed", jump to the distributed trace for that request, see every service involved, and identify exactly where the error originated. Without OpenTelemetry, reconstructing that picture means manually correlating timestamps and request IDs across separate systems.

For a deeper look at how the OpenTelemetry log data model works, including resource attributes, severity mapping, and trace correlation, see our dedicated guide.

Setting up the integration

The keepsuit/laravel-opentelemetry package is the most practical way to add OpenTelemetry to a Laravel application. It handles log export, distributed tracing, metrics, and trace correlation in a single dependency, and integrates with Laravel's logging system directly through a pre-configured Monolog channel.

Install it via Composer:

bash
1
composer require keepsuit/laravel-opentelemetry

The package auto-injects an otlp log channel that you can add to your logging stack. Update your .env to include it alongside your existing stderr channel:

text
1
LOG_STACK=stderr,otlp

Then configure the exporter to point at your OpenTelemetry Collector (or backend endpoint):

text
123
OTEL_SERVICE_NAME=my-laravel-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

Your application continues writing JSON logs to stderr for local collection, and the otlp channel simultaneously exports log records through the OpenTelemetry SDK. The two pipelines are independent, so you can configure different minimum levels, apply different processors, or disable one without affecting the other.

What you get for free

Beyond log export, the package automatically instruments HTTP server requests, database queries (Eloquent and the query builder), queue job processing, Redis commands, and cache operations. Each of these produces distributed trace spans without any code changes.

Trace correlation is handled automatically too. The package injects the active trace_id into the log context of every entry, so your structured JSON output from stderr includes correlation identifiers you can search for manually, and the otlp channel attaches the full trace and span IDs to each exported LogRecord.

It also detects worker mode automatically. When your application runs under Octane, Horizon, or a standard queue worker, the package adjusts its flush behavior so telemetry is exported correctly in long-running processes rather than being lost when the worker eventually shuts down.

If you need to customize the instrumentation (disable specific auto-instruments, exclude certain routes from tracing, configure sampling), publish the config file:

bash
123
php artisan vendor:publish \
--provider="Keepsuit\LaravelOpenTelemetry\LaravelOpenTelemetryServiceProvider" \
--tag="opentelemetry-config"

This creates config/opentelemetry.php where you can toggle each instrumentation, configure tail sampling rules, set metrics export intervals, and more. But the defaults are sensible for most applications, and you can control the most important settings through environment variables alone.

What your logs look like in the OTel data model

When a Laravel log record reaches the OpenTelemetry Collector, it's transformed into the OpenTelemetry log data model. If you enable the debug exporter on the Collector, the output looks something like this (assuming verbosity: detailed):

text
12345678910111213141516171819202122232425262728293031323334353637
2026-06-04T16:48:44.811Z info ResourceLog #0
Resource SchemaURL:
Resource attributes:
-> service.name: Str(my-laravel-app)
-> host.name: Str(falcon)
-> host.arch: Str(x86_64)
-> host.id: Str(4a3dc42bf0564d50807d1553f485552a)
-> os.type: Str(linux)
-> os.description: Str(6.6.114.1-microsoft-standard-WSL2)
-> os.name: Str(Linux)
-> os.version: Str(#1 SMP PREEMPT_DYNAMIC Mon Dec 1 20:46:23 UTC 2025)
-> process.runtime.name: Str(cli-server)
-> process.runtime.version: Str(8.4.21)
-> process.pid: Int(14487)
-> process.executable.path: Str(/usr/bin/php8.4)
-> process.owner: Str(ayo)
-> telemetry.sdk.name: Str(opentelemetry)
-> telemetry.sdk.language: Str(php)
-> telemetry.sdk.version: Str(1.14.0)
-> telemetry.distro.name: Str(opentelemetry-php-instrumentation)
-> telemetry.distro.version: Str(1.2.1)
ScopeLogs #0
ScopeLogs SchemaURL: https://opentelemetry.io/schemas/1.38.0
InstrumentationScope laravel-opentelemetry 2.2.1
LogRecord #0
ObservedTimestamp: 2026-06-04 16:48:44.746552064 +0000 UTC
Timestamp: 2026-06-04 16:48:44.746473502 +0000 UTC
SeverityText: error
SeverityNumber: Error(17)
Body: Str(Gateway timeout)
Attributes:
-> order_id: Str(ord-817)
-> gateway: Str(stripe)
-> error_code: Str(timeout)
Trace ID: 130588ac63b7ae332a58d55c8863d16d
Span ID: b633df644bce662c
Flags: 1

The Resource attributes section contains metadata about the service and runtime environment, attached automatically by the OTel SDK. Your Laravel context data appears under Attributes, and the Trace ID and Span ID fields link this log entry directly to the distributed trace for the request that triggered the log entry.

Seeing it come together in Dash0

Once your telemetry is flowing through the OpenTelemetry Collector, you need to send it somewhere it can be analyzed and correlated across services.

Dash0 is OpenTelemetry-native, which means your logs, traces, and metrics keep their semantic structure from ingestion through to query time. There's no translation layer or proprietary schema mapping sitting between your data and the query engine.

Dash0 laravel logs

To see it in action, sign up for a free Dash0 trial and configure the Collector's OTLP exporter to point at your Dash0 ingress endpoint. Our PHP integration page has other setup instructions should you need them.

Final thoughts

Laravel's logging system is significantly more capable than its default configuration suggests. The Context facade, introduced in Laravel 11, makes it trivial to attach metadata that propagates across log entries and queued jobs automatically. Combined with structured JSON output and OpenTelemetry integration, this gives you logs you can actually debug with in production.

For deeper coverage of Monolog's internals, including handlers, processors, and formatters, see our companion guide on logging in PHP with Monolog. For language-agnostic patterns, see our logging best practices guide.

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah