Mastering Monolog in Symfony 8: A Complete Guide to Professional Logging


Table of Contents

  1. Why Logging Matters in Production
  2. Understanding Monolog Log Levels
  3. Monolog Configuration in Symfony 8
  4. Using Multiple Log Files
  5. Understanding Bubble: Controlling Log Propagation
  6. Routing Log Levels to Separate Files
  7. Enriching Logs Automatically with Processors
  8. Logging to External Services
  9. Using Monolog in Your Code
  10. Real-World Configuration Example
  11. Best Practices
  12. 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 warning will capture warning, error, critical, alert, and emergency — but not debug, info, or notice.


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_crossed with 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.

Tags: #Symfony #Monolog #PHP #Logging #Backend #DevOps
Oussama GHAIEB - Laravel Certified Developer in Paris

Oussama GHAIEB

Laravel Certified Developer | Full-Stack Web Developer in Paris

14+ years experience 20+ projects
Read more about me →

Comments (0)

No comments yet. Be the first to comment!


Leave a Comment

More Posts :