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

> Author: [Oussama Ghaieb](https://oussama.ghaieb.com/about-me)
> Canonical: https://oussama.ghaieb.com/posts/mastering-monolog-in-symfony-8-a-complete-guide-to-professional-logging

# Mastering Monolog in Symfony 8: A Complete Guide to Professional Logging

---

## Table of Contents

1. [Why Logging Matters in Production](#why-logging-matters-in-production)
2. [Understanding Monolog Log Levels](#understanding-monolog-log-levels)
3. [Monolog Configuration in Symfony 8](#monolog-configuration-in-symfony-8)
4. [Using Multiple Log Files](#using-multiple-log-files)
5. [Understanding Bubble: Controlling Log Propagation](#understanding-bubble-controlling-log-propagation)
6. [Routing Log Levels to Separate Files](#routing-log-levels-to-separate-files)
7. [Enriching Logs Automatically with Processors](#enriching-logs-automatically-with-processors)
8. [Logging to External Services](#logging-to-external-services)
9. [Using Monolog in Your Code](#using-monolog-in-your-code)
10. [Real-World Configuration Example](#real-world-configuration-example)
11. [Best Practices](#best-practices)
12. [Frequently Asked Questions](#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](https://www.php-fig.org/psr/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:

```yaml
# config/packages/monolog.yaml
monolog:
  channels:
    - deprecation
```

```yaml
# 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"
```

```yaml
# 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

```yaml
# config/packages/monolog.yaml
monolog:
  channels:
    - app
    - security
    - payment
    - api
    - deprecation
```

### Step 2 — Assign Handlers to Channels

```yaml
# 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:

```yaml
# 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:

```yaml
# 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:

```yaml
# 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.

```yaml
# 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:

```yaml
# 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:

```php

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:

```yaml
# config/packages/monolog.yaml
monolog:
  processors:
    - id: App\Logger\AppContextProcessor
      channels: [app, security, payment]
```

---

## Logging to External Services

### Slack Notifications for Critical Errors

```yaml
# 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:

```yaml
    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:

```yaml
    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:

```php

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**:

```yaml
# config/services.yaml
services:
  App\Service\PaymentService:
    arguments:
      $logger: '@monolog.logger.payment'
```

Or use the `#[Autowire]` attribute directly in the constructor:

```php
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

```php
// 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:

```yaml
# config/packages/monolog.yaml
monolog:
  channels:
    - app
    - security
    - payment
    - api
    - deprecation
    - cron

  processors:
    - Monolog\Processor\WebProcessor
    - Monolog\Processor\MemoryUsageProcessor
    - App\Logger\AppContextProcessor
```

```yaml
# 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"]
```

```yaml
# 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
```

```yaml
# 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:

```php
// ❌ 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:

```yaml
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:

```php
// ❌ 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:

```php
// ❌ 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.
