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.
Table of Contents
- 1. Why the default handlers eventually fall short
- 2. Monolog architecture: handlers, processors, and formatters working together
- 3. Writing a custom handler: a SlackAlertHandler for critical errors
- 4. Registering the custom handler in monolog.yaml
- 5. Channels and log levels: fine grained routing
- 6. Writing a custom processor: enriching entries with request ID and user ID
- 7. Registering a processor globally or per handler
- 8. Performance considerations: buffering, deduplication, and fingers crossed
- 9. Testing a custom handler and processor
- 10. Summary
- 11. FAQ
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