Mastering Monolog in Symfony 8: A Complete Guide to Professional Logging
Table of Contents
- Why Logging Matters in Production
- Understanding Monolog Log Levels
- Monolog Configuration in Symfony 8
- Using Multiple Log Files
- Understanding Bubble: Controlling Log Propagation
- Routing Log Levels to Separate Files
- Enriching Logs Automatically with Processors
- Logging to External Services
- Using Monolog in Your Code
- Real-World Configuration Example
- Best Practices
- Frequently Asked Questions
Why Logging Matters in Production
Every Symfony application eventually breaks in production — a missed edge case, a third-party API timeout, a database deadlock. Without proper logging, debugging those issues is like finding a needle in a haystack blindfolded.
Monolog is the de facto logging library in the PHP ecosystem and the default logger in Symfony. Symfony 8 ships with tight Monolog integration via the MonologBundle, giving you powerful, flexible logging out of the box.
This guide goes beyond the basics. By the end, you'll have a battle-tested logging setup that separates concerns, respects environments, and makes on-call debugging actually manageable.
Understanding Monolog Log Levels
Monolog follows the PSR-3 standard, which defines 8 log levels in ascending order of severity:
| Level | Value | When to Use |
|---|---|---|
debug |
100 | Detailed diagnostic info, only in development |
info |
200 | General interesting events (user logged in, job started) |
notice |
250 | Normal but significant events worth tracking |
warning |
300 | Exceptional occurrences that aren't errors (deprecated API, bad input recovered) |
error |
400 | Runtime errors that don't require immediate action but should be monitored |
critical |
500 | Critical conditions: component unavailable, unexpected exceptions |
alert |
550 | Action must be taken immediately (entire site down, DB unreachable) |
emergency |
600 | System is unusable |
Key rule: each handler has a minimum level. A handler set to
warningwill capturewarning,error,critical,alert, andemergency— but notdebug,info, ornotice.
Monolog Configuration in Symfony 8
Monolog is configured in config/packages/monolog.yaml. Symfony 8 uses separate files per environment, so you'll typically have:
config/
packages/
monolog.yaml # shared config (channels, processors)
packages/dev/
monolog.yaml # dev overrides
packages/prod/
monolog.yaml # prod overrides
packages/test/
monolog.yaml # test overrides
Core Concepts
A Monolog setup has three main building blocks:
- Handlers — where logs go (file, Slack, email, stdout, etc.)
-
Channels — named buckets that group log messages by domain (e.g.,
app,security,doctrine) - Processors — callables that automatically enrich every log record with extra data (request ID, user ID, environment, etc.)
Here is a minimal working configuration:
# config/packages/monolog.yaml
monolog:
channels:
- deprecation
# config/packages/dev/monolog.yaml
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
console:
type: console
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: "%kernel.logs_dir%/deprecation.log"
# config/packages/prod/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
deprecation:
type: stream
channels: [deprecation]
path: "%kernel.logs_dir%/deprecation.log"
Using Multiple Log Files
One of the most useful things you can do with Monolog is split logs into separate files by channel or purpose. This makes it trivial to tail -f just the security log during a breach investigation, or hand off the payment log to your finance team.
Step 1 — Declare Custom Channels
# config/packages/monolog.yaml
monolog:
channels:
- app
- security
- payment
- api
- deprecation
Step 2 — Assign Handlers to Channels
# config/packages/prod/monolog.yaml
monolog:
handlers:
# Application log — info and above
# bubble: false isolates this channel from the catch-all main handler.
# Remove it if you intentionally want records in both this file AND the main log.
app:
type: stream
path: "%kernel.logs_dir%/app.log"
level: info
bubble: false
channels: [app]
# Security log — all levels
security:
type: stream
path: "%kernel.logs_dir%/security.log"
level: debug
bubble: false
channels: [security]
# Payment log — warning and above only
payment:
type: stream
path: "%kernel.logs_dir%/payment.log"
level: warning
bubble: false
channels: [payment]
# API log — info and above
api:
type: stream
path: "%kernel.logs_dir%/api.log"
level: info
bubble: false
channels: [api]
Now each domain writes to its own file. Your ops team can rotate them independently, set different retention policies, and monitor them separately.
Understanding Bubble: Controlling Log Propagation
bubble is one of the most commonly misunderstood Monolog concepts. Getting it wrong either causes duplicate log entries or makes records disappear from your central log — depending on which way you get it wrong.
By default, bubble is true — meaning after a handler processes a record, it passes it further up the handler stack to the next handler. This is the propagation chain.
When to use bubble: false — isolation
Use bubble: false when you want a channel's records to stay exclusively in their dedicated file and not appear in your central log:
# payment logs go ONLY to payment.log — they stop here
payment:
type: stream
path: "%kernel.logs_dir%/payment.log"
level: warning
bubble: false
channels: [payment]
When to keep bubble: true (the default) — aggregation
Leave bubbling enabled when you intentionally want records to appear in both a dedicated channel log and your centralized application log. This is useful when your ops team monitors a single aggregated log while individual teams also have their own dedicated files:
# payment logs go to payment.log AND continue up to the main handler
payment:
type: stream
path: "%kernel.logs_dir%/payment.log"
level: warning
bubble: true # or simply omit the key — true is the default
channels: [payment]
main:
type: fingers_crossed
action_level: error
handler: main_stream
# receives payment records too, because bubble was left enabled
The decision is intentional, not a default. Ask yourself: should this channel be a silo, or should its records also feed the central log? Set bubble accordingly.
Routing Log Levels to Separate Files
Sometimes you want to split by level rather than by channel — for example, keeping a dedicated error.log that only contains errors and above, making it easy to wire up alerts.
Using the filter Handler Type
The filter handler wraps another handler and only passes messages within a defined level range. The key is to pair it with bubble: false on the inner stream handler so records don't also flow through to other handlers:
# config/packages/prod/monolog.yaml
monolog:
handlers:
# Catches ONLY debug and info (not warning+)
debug_info_filter:
type: filter
handler: debug_info_stream
min_level: debug
max_level: info
bubble: false
debug_info_stream:
type: stream
path: "%kernel.logs_dir%/debug.log"
level: debug
# Catches warning, error, critical, alert, emergency
errors:
type: stream
path: "%kernel.logs_dir%/error.log"
level: warning
Using fingers_crossed for Smart Buffering
The fingers_crossed handler is extremely useful in production. It buffers all log records (including debug ones) and only flushes them to the wrapped handler when an action level is reached.
This means: if a request goes fine, nothing is written. If an error occurs, you get the full context — including all the debug messages that led up to it.
# config/packages/prod/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error # trigger flush on error or above
handler: main_stream
buffer_size: 100 # keep up to 100 messages in buffer
excluded_http_codes: [404] # don't trigger on 404s
main_stream:
type: stream
path: "%kernel.logs_dir%/prod.log"
level: debug # write everything once triggered
formatter: monolog.formatter.json
Pro tip: Combine
fingers_crossedwith a JSON formatter so your logs are machine-readable for log aggregators like Datadog, Loki, or ELK.
Enriching Logs Automatically with Processors
Processors are one of Monolog's most powerful features and are often overlooked. A processor is a callable that receives every log record and can add extra data to it automatically — without you having to pass that data manually on every $this->logger->info(...) call.
Common use cases: attaching the current request ID, authenticated user ID, session ID, or application version to every single log entry.
Built-in Processors
Monolog ships with several ready-to-use processors:
# config/packages/monolog.yaml
monolog:
channels:
- app
- security
- payment
processors:
# Adds PHP's memory usage to every record
- Monolog\Processor\MemoryUsageProcessor
# Adds the current web request details (URL, method, IP, referrer)
- Monolog\Processor\WebProcessor
# Adds the file and line where the log was triggered.
# ⚠️ WARNING: uses debug_backtrace() on every record — expensive on high-traffic
# production systems. Use selectively, or restrict to error level and above:
# - id: Monolog\Processor\IntrospectionProcessor
# with: { level: error }
- Monolog\Processor\IntrospectionProcessor
Writing a Custom Processor
The real power is in custom processors. Here's one that automatically appends the authenticated user's ID and the Symfony environment to every log record:
namespace App\Logger;
use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsMonologProcessor]
final class AppContextProcessor
{
public function __construct(
private readonly Security $security,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
public function __invoke(LogRecord $record): LogRecord
{
$extra = $record->extra;
$extra['environment'] = $this->environment;
$user = $this->security->getUser();
if ($user !== null) {
$extra['user_identifier'] = $user->getUserIdentifier();
}
return $record->with(extra: $extra);
}
}
Thanks to the #[AsMonologProcessor] attribute, Symfony 8 auto-registers this processor — no service configuration needed. Every log entry in your application will now automatically include the environment and user identifier in its extra field.
You can scope a processor to a specific channel only:
# config/packages/monolog.yaml
monolog:
processors:
- id: App\Logger\AppContextProcessor
channels: [app, security, payment]
Logging to External Services
Slack Notifications for Critical Errors
# config/packages/prod/monolog.yaml
monolog:
handlers:
slack_critical:
type: slack
token: "%env(SLACK_BOT_TOKEN)%"
channel: "#alerts"
bot_name: "SymfonyBot"
level: critical
bubble: false
channels: ["app", "security", "payment"]
Sending Emails for Emergencies
Symfony 8 uses Symfony Mailer — SwiftMailer has been abandoned since 2021 and is not compatible. Use symfony_mailer instead:
email_emergency:
type: symfony_mailer
from_email: "noreply@yourdomain.com"
to_email: "%env(OPS_EMAIL)%"
subject: "[EMERGENCY] Symfony Application"
level: emergency
Rotating Files to Avoid Disk Bloat
Use rotating_file instead of stream to automatically rotate logs by date:
app_rotating:
type: rotating_file
path: "%kernel.logs_dir%/app.log"
level: info
max_files: 30 # keep last 30 days
bubble: false
channels: [app]
Using Monolog in Your Code
Injecting the Logger
In Symfony 8, inject Psr\Log\LoggerInterface directly via autowiring:
namespace App\Service;
use Psr\Log\LoggerInterface;
class PaymentService
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function processPayment(int $orderId, float $amount): void
{
$this->logger->info('Payment initiated', [
'order_id' => $orderId,
'amount' => $amount,
]);
try {
// ... payment logic
$this->logger->info('Payment successful', ['order_id' => $orderId]);
} catch (\RuntimeException $e) {
$this->logger->error('Payment failed', [
'order_id' => $orderId,
// Pass the exception object directly — Monolog's NormalizerFormatter
// extracts message, code, file, line, and trace automatically,
// producing richer output than getTraceAsString() ever would.
'exception' => $e,
]);
throw $e;
}
}
}
Injecting a Specific Channel Logger
If you want PaymentService to write to the payment channel, use a named autowiring alias:
# config/services.yaml
services:
App\Service\PaymentService:
arguments:
$logger: '@monolog.logger.payment'
Or use the #[Autowire] attribute directly in the constructor:
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
public function __construct(
#[Autowire(service: 'monolog.logger.payment')]
private readonly LoggerInterface $logger,
) {}
Using All Log Levels in Practice
// Development diagnostic — never use in prod
$this->logger->debug('SQL query executed', ['query' => $sql, 'params' => $params]);
// Something happened that's worth noting
$this->logger->info('User logged in', ['user_id' => $user->getId()]);
// Normal but worth tracking
$this->logger->notice('User attempted login with deprecated API key', ['user_id' => $id]);
// Something smells bad but we recovered
$this->logger->warning('Third-party API responded slowly', ['response_time_ms' => 4500]);
// Something broke, but the app is still running
$this->logger->error('Failed to send welcome email', ['user_id' => $id, 'error' => $e->getMessage()]);
// A major component is down
$this->logger->critical('Redis connection failed, falling back to database sessions', [
'host' => $redisHost,
]);
// Wake someone up NOW
$this->logger->alert('Payment gateway is unreachable. All checkouts are failing.');
// The application cannot function
$this->logger->emergency('Database connection pool exhausted. Application shutting down.');
Real-World Configuration Example
Here's a complete, production-grade Monolog setup for a Symfony 8 application:
# config/packages/monolog.yaml
monolog:
channels:
- app
- security
- payment
- api
- deprecation
- cron
processors:
- Monolog\Processor\WebProcessor
- Monolog\Processor\MemoryUsageProcessor
- App\Logger\AppContextProcessor
# config/packages/dev/monolog.yaml
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
console:
type: console
channels: ["!event", "!doctrine", "!cache"]
# config/packages/prod/monolog.yaml
monolog:
handlers:
# --- Channel-specific handlers (bubble: false keeps them isolated) ---
# Security: log everything for audit purposes
security:
type: rotating_file
path: "%kernel.logs_dir%/security.log"
level: debug
max_files: 90
bubble: false
channels: [security]
formatter: monolog.formatter.json
# Payment: warnings and above, long retention for compliance
payment:
type: rotating_file
path: "%kernel.logs_dir%/payment.log"
level: warning
max_files: 365
bubble: false
channels: [payment]
formatter: monolog.formatter.json
# API: info and above
api:
type: rotating_file
path: "%kernel.logs_dir%/api.log"
level: info
max_files: 14
bubble: false
channels: [api]
formatter: monolog.formatter.json
# Cron jobs
cron:
type: rotating_file
path: "%kernel.logs_dir%/cron.log"
level: info
max_files: 14
bubble: false
channels: [cron]
formatter: monolog.formatter.json
# Deprecations
deprecation:
type: rotating_file
path: "%kernel.logs_dir%/deprecation.log"
level: info
max_files: 7
bubble: false
channels: [deprecation]
# --- Catch-all handlers (bubble: true, so they receive everything not handled above) ---
# Slack alert for critical and above
slack:
type: slack
token: "%env(SLACK_BOT_TOKEN)%"
channel: "#production-alerts"
bot_name: "SymfonyBot"
level: critical
bubble: true # also let it reach the main handler below
# Main handler: buffer until error, then flush full context
main:
type: fingers_crossed
action_level: error
handler: main_stream
buffer_size: 100
excluded_http_codes: [404, 405]
main_stream:
type: rotating_file
path: "%kernel.logs_dir%/app.log"
level: debug
max_files: 30
formatter: monolog.formatter.json
# config/packages/test/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/test.log"
level: debug
Best Practices
1. Always Add Context
A log message without context is nearly useless in production. Always pass an array of relevant data:
// ❌ Bad
$this->logger->error('Order failed');
// ✅ Good
$this->logger->error('Order processing failed', [
'order_id' => $order->getId(),
'user_id' => $order->getUser()->getId(),
'total' => $order->getTotal(),
'error' => $e->getMessage(),
]);
2. Use JSON Formatting in Production
Plain-text logs are hard to parse programmatically. Use the built-in JSON formatter so tools like Datadog, Grafana Loki, or AWS CloudWatch can index your logs:
formatter: monolog.formatter.json
3. Never Log Sensitive Data
Never log passwords, tokens, credit card numbers, or personal data. This is both a security and a GDPR concern:
// ❌ Never do this
$this->logger->info('User login', ['password' => $password]);
// ✅ Safe
$this->logger->info('User login attempt', ['email' => $email, 'ip' => $request->getClientIp()]);
4. Use fingers_crossed in Production
Instead of writing every debug message to disk (expensive), buffer them and only persist when something goes wrong. You get full context without the I/O overhead.
5. Rotate Your Log Files
Use rotating_file with a reasonable max_files value. Without rotation, a busy app will fill your disk in days.
6. Use Channels to Separate Concerns
Don't dump everything into a single app.log. Separate channels by domain (security, payment, api) so you can monitor, rotate, and alert on them independently.
7. Choose bubble Based on Your Logging Strategy
Use bubble: false when you want a channel to be a silo — records stay in their dedicated file and don't reach the central log. Leave it enabled (the default) when you want records to appear in both the channel-specific file and your central aggregated log. The choice is intentional, not a rule to always follow one way.
8. Set Appropriate Retention Per Channel
The values below are starting points. Your actual obligations depend on your compliance framework — PCI-DSS, GDPR, SOC 2, or industry-specific regulations may require shorter or significantly longer retention periods than shown here.
| Channel | Example Retention |
|---|---|
security |
90 days (audit trail) |
payment |
365 days (financial compliance baseline) |
app |
30 days |
api |
14 days |
deprecation |
7 days |
9. Avoid Logging in Loops
If you're processing thousands of items, logging inside the loop will generate enormous files and tank performance. Aggregate and log summaries instead:
// ❌ Logs 10,000 lines
foreach ($items as $item) {
$this->logger->debug('Processing item', ['id' => $item->getId()]);
$this->process($item);
}
// ✅ Log a summary
$processed = 0;
foreach ($items as $item) {
$this->process($item);
$processed++;
}
$this->logger->info('Batch processed', ['count' => $processed]);
10. Use Processors for Shared Context
If you find yourself passing the same data (user ID, request ID, environment) on every log call, move it into a processor. It runs automatically on every record and keeps your service code clean.
Frequently Asked Questions
What is fingers_crossed in Monolog?
A handler that buffers all log records in memory and only writes them to disk when a threshold level (e.g. error) is reached. If no error occurs during the request, nothing is written. When an error does occur, you get the full preceding debug context, which is invaluable for root cause analysis.
What does bubble: false do in Monolog?
It stops a log record from propagating further up the handler stack after the current handler processes it. Use it to isolate a channel-specific handler so its records don't also appear in your central catch-all log. Leave it at the default (true) if you want records in both places.
How do I create a custom Monolog channel in Symfony 8?
Declare it under monolog.channels in config/packages/monolog.yaml, then create a handler that targets it via the channels key. Inject it into services using @monolog.logger.your_channel or the #[Autowire(service: 'monolog.logger.your_channel')] attribute.
How do I send Symfony logs to Slack?
Use the built-in slack handler type with a bot token and channel name. Set level: critical so only serious incidents trigger a notification, and wire it up in your prod config only.
Should I use JSON logs in production? Yes, if you use any log aggregation tool (Datadog, Loki, CloudWatch, ELK). JSON makes every field individually queryable and filterable. Plain-text logs are fine for local development where you're reading them directly in a terminal.
How do I log exceptions properly in Monolog?
Pass the exception object directly in the context array: 'exception' => $e. Monolog's NormalizerFormatter will extract the message, code, file, line, and full trace automatically — far cleaner than calling getTraceAsString() yourself.
Wrapping Up
Good logging is one of the highest-return investments in a production Symfony application. With channels, processors, structured JSON, and smart handlers like fingers_crossed, Monolog becomes more than a debugging tool — it becomes an operational asset.
Start simple, measure what you actually need, and evolve your logging strategy alongside your application. Future-you-at-3am will be grateful.