Channels and Handlers

Channels label where a record came from, handlers decide where it goes, and fingers_crossed keeps the quiet case quiet.

Syntaxchannels: ['!doctrine'] # or [doctrine], or doctrine

Every Monolog record carries two independent things: a channel, a name saying which part of the system produced it, and a handler, a destination that writes it somewhere.

Your own logger uses the channel app. Symfony adds its own: request, security, console, php, event, cache, mailer, messenger, and doctrine for the database library taught in Stage 3, which logs every query at debug and will bury your lines if you let it.

Routing

A handler's channels option picks which channels reach it. channels: [security] takes only that one. channels: ['!doctrine'] takes everything else. Declare your own channels under monolog.channels, which creates services named monolog.logger.<name>.

Containers and fingers_crossed

In a container, write to php://stderr. There is no disk to rotate and the platform already collects that stream. The fingers_crossed handler buffers records and forwards nothing until one arrives at action_level or above; then it flushes the whole buffer to its nested handler. You get silence on success and full detail around a failure.

What trips people up

excluded_http_codes stops a 404 from triggering that flush, which otherwise fills the log with crawler traffic.

Example

Example · php
// src/Service/PaymentGateway.php
declare(strict_types=1);

namespace App\Service;

use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface;

// Every LoggerInterface injected into this class logs on the
// 'payment' channel instead of 'app'.
#[WithMonologChannel('payment')]
final class PaymentGateway
{
    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    public function capture(string $orderId, int $amountCents): string
    {
        $this->logger->info('Capture requested', [
            'order' => $orderId,
            'amount_cents' => $amountCents,
        ]);

        return 'captured';
    }
}

// $gateway->capture('A-1187', 4500);
// Output: 'captured'   (one INFO record, channel 'payment')

When to use it

  • Give security or audit records their own file with a longer retention than the rest.
  • Stop Doctrine query logging from drowning your own lines in development.
  • Log to stderr in a container so the cluster collector picks the records up.
  • Keep production logs empty until a request actually fails.

More examples

Sending one channel to its own file

Declaring the channel and routing it gives money-related records a file of their own, and the exclusions keep query noise out of the main file. Reach for this when one subsystem has a different audience or retention from the rest.

Example · yaml
# config/packages/monolog.yaml
monolog:
    channels: ['payment']

when@dev:
    monolog:
        handlers:
            main:
                type: stream
                path: '%kernel.logs_dir%/%kernel.environment%.log'
                level: debug
                channels: ['!payment', '!doctrine']
            payment:
                type: stream
                path: '%kernel.logs_dir%/payment.log'
                level: info
                channels: ['payment']

Production: stderr behind fingers_crossed

A successful request writes nothing. A failing one writes its last 50 records, debug included, as JSON on stderr. This is the shape to deploy in a container, where that stream is collected for you.

Example · yaml
# config/packages/monolog.yaml
when@prod:
    monolog:
        handlers:
            main:
                type: fingers_crossed
                action_level: error
                handler: nested
                excluded_http_codes: [404, 405]
                buffer_size: 50
            nested:
                type: stream
                path: php://stderr
                level: debug
                formatter: monolog.formatter.json

Asking for one channel, argument by argument

#[Target] names the channel for one argument, so a class can write to two channels. It resolves to the service monolog.logger.payment, which is the same service you would wire by hand as an argument in services.yaml; the attribute and the manual wiring are the same thing.

Example · php
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;

final class ReconciliationReport
{
    public function __construct(
        #[Target('payment')] private readonly LoggerInterface $paymentLogger,
        private readonly LoggerInterface $logger,
    ) {
    }
}

Discussion

  • Be the first to comment on this lesson.