Channels and Handlers
Channels label where a record came from, handlers decide where it goes, and fingers_crossed keeps the quiet case quiet.
channels: ['!doctrine'] # or [doctrine], or doctrineEvery 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
// 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.
# 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.
# 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.jsonAsking 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.
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