Symfony Monolog: Writing Custom Handlers and Processors
AI generated
SF
{ }
Symfony Logging
Monolog in Symfony: Writing Custom Handlers and Processors
From the default setup to a tailored logging pipeline

When StreamHandler and RotatingFileHandler stop being enough, here is how to write custom Monolog handlers for Slack alerts and processors that automatically enrich log context in Symfony.

14 min read Monolog 3 Symfony 7

1. Why the default handlers eventually fall short

In most Symfony projects, a simple combination of StreamHandler and RotatingFileHandler is enough at the start. Errors land in a file, the file rotates daily, and someone occasionally checks it with tail or a log viewer. That works fine while the application is small and nobody urgently needs to react to critical errors. Once a project goes into production and real users are affected, this model quickly stops being sufficient.

Teams often only discover that a payment webhook has been failing for hours because a support ticket comes in, not because anyone was actively watching the logs. This is exactly where Monolog's extensibility comes in. A custom handler can push critical entries straight to Slack, Teams, or a pager, while a processor can automatically attach context such as a request ID or the logged in user to every entry. Together they turn plain text files into an active observability system.

2. Monolog architecture: handlers, processors, and formatters working together

Monolog cleanly separates three responsibilities. The handler decides where a log entry goes and whether it gets handled at all, based on log level and its own logic. The processor enriches a LogRecord before it reaches the handler, for example by attaching additional context fields. The formatter finally determines how the entry looks in the end, as a JSON line, readable text, or a proprietary format for an external system.

In Symfony, this architecture is configured through the MonologBundle, which connects every channel to its own list of handlers. A channel is nothing more than a logical namespace, for example app, doctrine, or a custom channel such as payment. It is important to understand that handlers are processed in a stack and use the bubble property to control whether an entry is passed on to subsequent handlers or stops there.

3. Writing a custom handler: a SlackAlertHandler for critical errors

A custom handler is usually derived from AbstractProcessingHandler, which already takes care of level filtering and running processors. The only method you must implement is write(), which receives the fully processed LogRecord and forwards it to the target system. In the example below, critical errors are sent through symfony/http-client to a Slack webhook without blocking on a response, so the performance impact on the application stays negligible.

The level is deliberately set to Critical so that not every warning triggers a Slack message and numbs the team to alerts. The constructor receives the HttpClient and webhook URL through dependency injection, so the handler can easily be swapped for a mock client in tests. The following example shows the full implementation using Symfony 7 and PHP 8.4 typing.


<?php

declare(strict_types=1);

namespace App\Monolog\Handler;

use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SlackAlertHandler extends AbstractProcessingHandler
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly string $webhookUrl,
        Level $level = Level::Critical,
    ) {
        parent::__construct($level, bubble: true);
    }

    protected function write(LogRecord $record): void
    {
        $this->httpClient->request('POST', $this->webhookUrl, [
            'json' => [
                'text' => sprintf(
                    '[%s] %s',
                    $record->channel,
                    $record->formatted ?? $record->message,
                ),
            ],
        ]);
    }
}

4. Registering the custom handler in monolog.yaml

For Symfony to actually use the new handler, it first needs to be registered as a regular service, usually automatically through autowiring in services.yaml, since the class already has typed constructor arguments. The service name is then entered under the desired channel in monolog.yaml as type: service. The webhook URL should never be hardcoded but injected through an environment argument such as %env(SLACK_WEBHOOK_URL)%.

In practice it makes sense to only activate the SlackAlertHandler in the production environment and replace it with a NullHandler in dev and test, so local errors do not accidentally trigger alerts. Symfony allows exactly this through environment specific configuration files such as monolog.yaml under config/packages/prod. This keeps the base configuration lean while production specific details stay clearly separated.

5. Channels and log levels: fine grained routing

A common mistake is routing every log entry through the same handler stack. It is much more useful to define dedicated channels for functional areas, such as payment, import, or security, and list them under channels in the monolog block. Each channel can then get its own combination of handlers, so payment errors, for example, go both to a dedicated file and to Slack, while plain import warnings only land in the file.

Assigning a LoggerInterface to a channel happens automatically in Symfony through the monolog.logger tag convention. If a service is annotated with #[AsMonologChannel('payment')], Symfony automatically injects a logger writing to that channel. This clean separation makes debugging much easier later on, because you can filter by functional area instead of digging through a single giant app channel.

6. Writing a custom processor: enriching entries with request ID and user ID

At its core, a processor is a callable that receives a LogRecord and returns a modified LogRecord. Typically you implement ProcessorInterface with the method __invoke(LogRecord $record): LogRecord. A practical example is a RequestContextProcessor that automatically writes the current request ID from a RequestStack, as well as the ID of the logged in user from the security token storage, into the record's extra array on every log entry.

The big advantage over manually passing a context array on every logger call is consistency. Nobody can forget to include the user ID, because the processor adds it automatically to every single entry. This saves a huge amount of time when debugging production issues, since you can gather all related log lines across different services using the request ID, even when several requests are being processed concurrently.

7. Registering a processor globally or per handler

Symfony offers two ways to activate a processor. Globally, a processor applies to every handler and channel when it is simply registered as a service tagged with monolog.processor, without further restriction. That is the right approach for context data such as a request ID, which makes sense in practically every log entry regardless of which part of the application logged it.

Alternatively, a processor can be bound to a single handler or channel by restricting the tag with the handler or channel attribute. That is worthwhile when a processor performs expensive work, such as an extra API call, and should therefore only run for the rare, critical Slack handler rather than for every single debug entry in the local log file.

8. Performance considerations: buffering, deduplication, and fingers crossed

Custom handlers and processors add overhead that can become noticeable under high traffic. The FingersCrossedHandler is an important tool here. It buffers all entries of a request in memory and only actually writes them out once a defined trigger level, usually Error, is reached. On a successful request without any errors, this results in no I/O load at all, even though debug level entries were logged during processing.

For the custom SlackAlertHandler, it is also worth wrapping it with a DeduplicationHandler so that a new Slack message with identical content is not sent for every single failed request. It remembers already sent entries over a configurable time window and suppresses duplicates. Combined with an asynchronous HttpClient call, the performance impact of a custom alert handler stays minimal.

9. Testing a custom handler and processor

Since write() is the only touchpoint with the outside world, a custom handler can be tested in isolation with PHPUnit by passing a MockHttpClient into the constructor and then calling handle() with a manually created LogRecord. This lets you verify that exactly the expected URL is called with the expected JSON body, without needing any real network calls during the test run.

Testing a processor is even simpler. You create a LogRecord with an empty extra array, call the processor directly, and check that the expected keys, such as request_id and user_id, are present afterward. Since processors should be pure functions without side effects, they can be tested entirely without a Symfony kernel, which makes the overall test suite noticeably faster.

Class/Interface Purpose Key Method Typical Use
AbstractProcessingHandler Base class for custom handlers write(LogRecord $record) Slack or Teams alerts
ProcessorInterface Enriches context before output __invoke(LogRecord $record): LogRecord Adding request ID, user ID
FingersCrossedHandler Buffers entries until trigger level activate() I/O only on actual errors
DeduplicationHandler Suppresses repeated identical entries write(LogRecord $record) Prevents alert floods on repeated failures
RotatingFileHandler Default handler with daily rotation write() (internal via stream) Local log files with retention

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

Monolog Handlers & Processors

Handler

Controls where and whether a log entry is written, base class AbstractProcessingHandler

Processor

Enriches a LogRecord with context before output, e.g. request ID

Configuration

Registered via monolog.yaml and service tags, separated per environment

Performance

FingersCrossedHandler and DeduplicationHandler keep the overhead low

11. FAQ: Monolog Handlers & Processors

1When is a custom Monolog handler worth building?
As soon as default handlers like StreamHandler are no longer enough, for example when critical errors need to be actively reported to an external system like Slack, Teams, or a paging service instead of sitting passively in a file.
2Do I need to extend AbstractProcessingHandler or is HandlerInterface enough?
For most cases AbstractProcessingHandler is completely sufficient, since it already handles level filtering and running processors. Only for very specific requirements, such as custom buffering behavior, is implementing HandlerInterface directly worthwhile.
3How does a processor differ from a handler?
A handler decides where a log entry gets written, while a processor enriches the entry's content beforehand. Both work together but are independent building blocks with clearly separated responsibilities.
4Can a processor cause performance problems?
Yes, if it performs expensive operations like database queries or external API calls and is registered globally for every log entry. In such cases the processor should be bound only to specific handlers or channels.
5How do I prevent a flood of Slack messages during many simultaneous errors?
A DeduplicationHandler wrapped around the target handler suppresses repeated identical entries within a configurable time window, preventing an alert flood during repeated failures.
6How do I safely inject the Slack webhook URL?
Through an environment argument in the service definition, for example %env(SLACK_WEBHOOK_URL)%, so the actual URL only lives in .env.local or production secrets rather than in the code.
7What does the bubble property of a handler mean?
It controls whether a log entry is passed on to subsequent handlers in the stack after being processed by a handler. When bubble is set to false, processing stops after that handler.
8How do I test a custom handler without real network calls?
With the MockHttpClient from symfony/http-client, injected as a test double. You then call handle() with a manually created LogRecord and assert the expected request.
9Can I combine multiple channels with different handlers?
Yes, each channel in monolog.yaml gets its own list of handlers, so for example a payment channel can write to both a dedicated file and Slack, while other channels remain unaffected.
10Can FingersCrossedHandler be combined with a custom handler?
Yes, FingersCrossedHandler can wrap a custom handler like SlackAlertHandler, so entries are buffered first and only actually forwarded to the underlying handler once the trigger level is reached.