Structured Context

Keep the message a constant string and put the values in the context array, so logs stay groupable and searchable.

Syntax$logger->info('Order {order} shipped', ['order' => $id, 'carrier' => 'dhl']);

The logger takes a message and a second argument: context, an array of key and value pairs. Values belong there, not inside the message.

The reason is search. Build the message with the order id inside it and every occurrence is a different string, so a log tool cannot group them, count them or alert on them. Keep the message constant and the same event becomes one row with a count, while the id stays available as a field.

Placeholders

PSR-3 allows {key} placeholders in the message. Monolog fills them from context through its PsrLogMessageProcessor, which MonologBundle adds to handlers by default; the handler option process_psr_3_messages: false turns it off, and the keys stay in the context either way.

Processors

A processor is a callable that receives every record and returns it, usually with fields added to $record->extra. That is how a request id reaches every line without each call site passing it. Tag the class with #[AsMonologProcessor], or with monolog.processor in services.yaml, which does the same job.

What trips people up

Pass a Throwable under the exception key, which is the PSR-3 convention Monolog's formatters recognise, rather than casting it to a string yourself.

Example

Example · php
// src/Logger/RequestIdProcessor.php
declare(strict_types=1);

namespace App\Logger;

use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use Symfony\Component\HttpFoundation\RequestStack;

#[AsMonologProcessor]
final class RequestIdProcessor implements ProcessorInterface
{
    public function __construct(private readonly RequestStack $requestStack)
    {
    }

    public function __invoke(LogRecord $record): LogRecord
    {
        $request = $this->requestStack->getCurrentRequest();

        if (null === $request) {
            return $record; // console command or worker: no request
        }

        // Reuse the id the proxy sent so one id spans every service.
        $record->extra['request_id'] = $request->headers->get('X-Request-Id', 'none');

        return $record;
    }
}

// Request header 'X-Request-Id: 7c41f0':
// Output: $record->extra['request_id'] === '7c41f0'

When to use it

  • Count how often a particular failure happened this week, not just read about it.
  • Follow one request across several services by a shared request id.
  • Search for every record touching one order without grepping free text.

More examples

The same event, three ways

The second and third calls produce the same context; the placeholders only change how the line reads once rendered. The first throws the structure away, which is why it is the version to stop writing.

Example · php
// Hard to group: a different message string for every order.
$this->logger->info(sprintf('Order %s shipped via %s', $orderId, $carrier));

// Groupable: constant message, values as fields.
$this->logger->info('Order shipped', ['order' => $orderId, 'carrier' => $carrier]);

// Shorthand for the same record, readable in a plain text log.
$this->logger->info('Order {order} shipped via {carrier}', [
    'order' => $orderId,
    'carrier' => $carrier,
]);

Attaching the exception

Use this whenever you catch and continue. The message stays the constant you can alert on, and the trace rides along in context instead of being flattened into the message text.

Example · php
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;

try {
    $this->mailer->send($message);
} catch (TransportExceptionInterface $e) {
    // 'exception' is the PSR-3 key; formatters render the class,
    // message and stack trace from it.
    $this->logger->error('Invoice email failed', [
        'invoice' => $invoiceId,
        'exception' => $e,
    ]);
}

One JSON object per line

The JSON formatter writes message, context, extra and channel as separate fields, which is what a log search tool indexes. Turning interpolation off keeps the message byte-identical across occurrences, so grouping stays exact.

Example · yaml
# config/packages/monolog.yaml
when@prod:
    monolog:
        handlers:
            main:
                type: stream
                path: php://stderr
                level: info
                formatter: monolog.formatter.json
                process_psr_3_messages: false

Discussion

  • Be the first to comment on this lesson.