DEV Community

Cover image for Beyond debug.log: 10 Advanced Logging Patterns for Symfony 7.4
Matt Mochalkin
Matt Mochalkin

Posted on

Beyond debug.log: 10 Advanced Logging Patterns for Symfony 7.4

Logging is the heartbeat of a production application. In the early days of a project, a simple dev.log tail is sufficient. But as your Symfony application scales to handle payments, asynchronous workers and high-concurrency traffic, “writing to a file” becomes a liability rather than an asset.

The symfony/monolog-bundle offers sophisticated tools to transform logs from simple text streams into structured, actionable observability data.

This guide explores 10 advanced logging patterns that go beyond the defaults. We will use strict typing, PHP Attributes and modern YAML configuration.

Prerequisites

  • Symfony: 7.4+
  • PHP: 8.3+
  • symfony/monolog-bundle
  • monolog/monolog
  • symfony/notifier (for alerting examples)

Scenation 1. The “Black Box” Recorder: FingersCrossed Handler

You want detailed debug logs when an error occurs to understand the sequence of events leading up to it, but you can’t afford the disk I/O to log debug messages for every successful request in production.

The FingersCrossedHandler buffers all logs in memory during the request. If the request finishes successfully, the buffer is discarded. If an error (or a specific threshold) is reached, the entire buffer (including previous debug logs) is flushed to the persistence handler.

Configuration

config/packages/prod/monolog.yaml:

monolog:
    handlers:
        main:
            type: fingers_crossed
            # The strategy: "error" means if an ERROR occurs, dump everything.
            action_level: error
            # Where to dump the logs if the threshold is met
            handler: nested
            # Optional: Keep a small buffer size to prevent memory leaks in long processes
            buffer_size: 50
        nested:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
Enter fullscreen mode Exit fullscreen mode

You’ll get the forensic detail of debug level logging exactly when you need it — during a crash — without filling your disk with noise during normal operations.

Scenario 2. Segregated Channels: The “Payment” Log

Your app.log is a mix of Doctrine queries, router matching and critical business logic. You need a dedicated file for financial transactions that can be audited separately.

Create a custom Monolog Channel.

Configuration

config/packages/monolog.yaml:

monolog:
    channels: ['payment'] # Register the channel

    handlers:
        payment:
            type: stream
            path: "%kernel.logs_dir%/payment.log"
            level: info
            channels: ["payment"] # Only listen to this channel

        main:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: ["!payment"] # Exclude payment logs from the main file
Enter fullscreen mode Exit fullscreen mode

Implementation

Inject the logger specifically for this channel using the Target attribute (available since Symfony 5.3+).

namespace App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;

#[AsCommand(name: 'app:process-payments', description: 'Processes pending payments')]
class ProcessPaymentsCommand extends Command
{
    public function __construct(
        #[Target('payment.logger')]
        private readonly LoggerInterface $paymentLogger,
        private readonly LoggerInterface $mainLogger
    ) { parent::__construct(); }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->mainLogger->info('Cron job app:process-payments started.');

        $amounts = [10.50, 99.99, 45.00];
        foreach ($amounts as $amount) {
            $this->paymentLogger->info('Processing payment', ['amount' => $amount, 'status' => 'success']);
        }

        $this->mainLogger->info('Cron job finished.');
        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the Command. You will see payment.log created in var/log/ containing only these specific entries.

Scenario 3. Context Enrichment: The #[AsMonologProcessor] Attribute

Logs are useless if you can’t correlate them to a specific user or request ID. You find yourself manually adding [‘user_id’ => $user->getId()] to every single log statement.

A global Processor can automatically injects context into every log record.

Implementation

namespace App\Log;

use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;

#[AsMonologProcessor]
class RequestContextProcessor
{
    public function __invoke(LogRecord $record): LogRecord
    {
        // Simulated context since CLI commands don't have HTTP Requests
        $extra = [
            'pid' => getmypid(),
            'user' => get_current_user(),
        ];

        return $record->with(extra: array_merge($record->extra, $extra));
    }
}
Enter fullscreen mode Exit fullscreen mode

In Monolog 3, LogRecord is immutable. We use with() to return a modified copy.

Scenario 4. GDPR Compliance: Sensitive Data Redaction

A developer accidentally logs a user object, dumping PII (Personally Identifiable Information) or credit card numbers into the logs, violating GDPR/PCI-DSS.

A specialized processor can scans the context array and masks sensitive keys.

Implementation

namespace App\Log;

use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;

#[AsMonologProcessor]
class SensitiveDataProcessor
{
    private const array SENSITIVE_KEYS = ['password', 'credit_card', 'cvv', 'token'];

    public function __invoke(LogRecord $record): LogRecord
    {
        $context = $record->context;

        foreach ($context as $key => $value) {
            if (in_array($key, self::SENSITIVE_KEYS, true)) {
                $context[$key] = '***REDACTED***';
            }
        }

        return $record->with(context: $context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Verification:

$logger->info('User login', ['password' => 'secret123']);
// Output in log: "User login" {"password": "***REDACTED***"}
Enter fullscreen mode Exit fullscreen mode

Scenario 5. Structured Logging: JSON for ELK/Datadog

Parsing multi-line text logs (like stack traces) in Kibana or Datadog is painful. Regex parsers break easily.

You can output logs as JSON lines. This allows log aggregators to natively index fields like context.order_id or extra.req_id.

Configuration

config/packages/monolog.yaml:

monolog:
    handlers:
        json_report:
            type: stream
            path: "%kernel.logs_dir%/app.json"
            level: info
            formatter: monolog.formatter.json
            channels: ["!payment", "!event"]
Enter fullscreen mode Exit fullscreen mode

Open var/log/app.json. The output should look like:

{"message":"Order created","context":{"id":123},"level":200,"channel":"app","datetime":"..."}
Enter fullscreen mode Exit fullscreen mode

Scenario 6. Spam Prevention: The Deduplication Handler

Your database goes down. Your application receives 5,000 requests in a minute. Your “Email on Error” handler sends you 5,000 emails, getting your SMTP server blacklisted and flooding your inbox.

The DeduplicationHandler can aggregates identical log records and sends a single summary.

Configuration

config/packages/monolog.yaml:

monolog:
    handlers:
        deduplication:
            type: deduplication
            handler: nested_dedup
            buffer_size: 60
            time: 60
            level: error
            channels: ["!console"]
Enter fullscreen mode Exit fullscreen mode

If the DB crashes, you receive one email every 60 seconds listing all occurrences, rather than one email per request.

Scenario 7. Dynamic Log Levels (Runtime Debugging)

A specific customer is reporting an issue in production. You can’t reproduce it and you can’t switch the entire production server to DEBUG level because of the performance hit.

Use an ActivationStrategy to switch the log level dynamically based on a request header.

Implementation

Create a custom strategy:

namespace App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:dynamic-debug', description: 'Tests dynamic log level activation')]
class DynamicDebugCommand extends Command
{
    public function __construct(private readonly LoggerInterface $logger) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addOption('force-debug', null, InputOption::VALUE_NONE, 'Force debug logging for this run');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        if ($input->getOption('force-debug')) {
            $output->writeln('Debug mode forced via option. (Simulated, as Monolog ActivationStrategy relies on Http/Request state typically. But you can add processors/handlers dynamically in real apps based on this flag).');
        }

        $this->logger->debug('This detailed trace only appears if --force-debug is passed or an error occurs.');
        $this->logger->info('Standard processing information.');

        return Command::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

Scenario 8. Messenger Logging: Worker Context

Logs from messenger:consume are hard to trace. You see “Handling message,” but you don’t know which message ID caused the error because workers run as long-running processes.

Use Symfony’s EventListener to inject the Message ID into the Monolog context specifically for the worker process.

Implementation

namespace App\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent;

readonly class WorkerLogContextListener
{
    public function __construct(private LoggerInterface $logger) {}

    #[AsEventListener]
    public function onMessageHandling(WorkerMessageReceivedEvent $event): void
    {
        $this->logger->info('Worker started message', [
            'message_class' => $event->getEnvelope()->getMessage()::class,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Scenario 9. Excluding 404s from Error Logs

Bots scanning your site for .env or wp-login.php generate thousands of 404 NotFoundHttpException logs. These clog your error monitoring tool (Sentry/Slack) with false positives.

Use the channels exclusion or a specific configuration to ignore bounced logs or better - configure the NotFoundHttpException to be ignored by the main error handler.

Configuration

config/packages/monolog.yaml:

monolog:
    handlers:
        fingers_crossed:
            type: fingers_crossed
            action_level: error
            handler: nested
            excluded_http_codes: [404, 405]
            buffer_size: 50
Enter fullscreen mode Exit fullscreen mode

Scenario 10. Notifier Bridge: ChatOps

Email alerts are slow and often ignored. You want critical infrastructure failures to ping a Slack channel immediately.

Use symfony/notifier bridged with Monolog.

Prerequisites

composer require symfony/notifier symfony/slack-notifier
Enter fullscreen mode Exit fullscreen mode

Configuration

config/packages/monolog.yaml:

monolog:
    handlers:
        slack_alerts:
            type: service
            id: Symfony\Bridge\Monolog\Handler\NotifierHandler
            level: critical
Enter fullscreen mode Exit fullscreen mode

Then configure the notifier chatter in config/packages/notifier.yaml and your DSN in .env.

framework:
    notifier:
        chatter_transports:
            slack: '%env(SLACK_DSN)%'
        texter_transports:
        channel_policy:
            urgent: ['chat/slack']
            high: ['chat/slack']
            medium: ['chat/slack']
            low: ['chat/slack']
        admin_recipients:
            - { email: admin@example.com }
Enter fullscreen mode Exit fullscreen mode

NotifierHandler maps log levels to Notifier importance. A critical log becomes a High Priority Slack notification automatically.

Conclusion

Logging is not a byproduct of code - it is a feature of your infrastructure.

In a junior developer’s mindset, logging is a safety net — something to check only when things break. But as you scale to Senior and Lead roles, your perspective must shift. You stop looking at logs as text files and start treating them as a stream of structured events.

By moving to Symfony 7.4 and leveraging the full power of Monolog 3, we transition from “logging” to “observability.”

Structured JSON turns your logs into a queryable database.

FingersCrossed handlers solve the “signal-to-noise” ratio, saving you gigabytes of storage while preserving critical context.

Processors ensuring every log entry carries the DNA of the request (User ID, Request ID) turn hours of debugging into minutes of verification.

Deduplication protects your inbox and your sanity.

Implementation of these patterns distinguishes a fragile application from a robust, enterprise-grade system. When your production environment faces a traffic spike or a silent data corruption issue, these configurations will be the difference between a stressful all-nighter and a quick, precise hotfix.

Source Code: You can find the full implementation and follow the project’s progress on GitHub: [https://github.com/mattleads/MonologPatterns]

Let’s Connect!

If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:

Top comments (0)