Logging
Background jobs, API clients, and access decisions all do their work with nobody watching, so the site log is where their story gets told. This page shows you how to give your module its own logger channel, log at the right severity with safe placeholders, and choose where log entries end up.
Your module logs through its own named channel, so site owners can filter its messages, and the messages carry the right severity and sanitized values. Logging is for operators reading history; for messages shown to the current user in the browser, use the messenger service (MessengerInterface) instead.
Prerequisites
Section titled “Prerequisites”- A custom module; the first tutorial part gets you one.
- Services and dependency injection for the injection pattern.
Define a channel for your module
Section titled “Define a channel for your module”A channel is a lightweight logger labeled with your module’s name; every entry it writes carries that label. Define one in your services.yml by extending the abstract logger.channel_base definition (which calls the logger factory for you):
logger.channel.my_module: parent: logger.channel_base arguments: - 'my_module'By convention the channel name is your module’s machine name, and the service ID is logger.channel.<channel name>. Core defines its channels the same way in core.services.yml.
Inject the channel and log
Section titled “Inject the channel and log”Type hints cannot distinguish one channel from another (they are all \Psr\Log\LoggerInterface), so even in autowired files the logger is passed by name, as $logger: '@logger.channel.my_module'. Then log at the severity that matches what happened:
<?php
declare(strict_types=1);
namespace Drupal\module_howtos_b_demo;
use Drupal\Core\Utility\Error;use Psr\Log\LoggerInterface;
/** * Writes the outcome of a sync run to the site log. * * The constructor receives this module's own logger channel, which is * defined in module_howtos_b_demo.services.yml. Everything this class * logs is filterable by that channel name. */class StatusReporter {
/** * Constructs the reporter. * * @param \Psr\Log\LoggerInterface $logger * This module's logger channel. */ public function __construct( protected readonly LoggerInterface $logger, ) {}
/** * Logs the outcome of one sync run at the appropriate severities. * * @param int $synced * How many articles were pushed. * @param int $skipped * How many articles were skipped. * @param \Throwable|null $error * The failure that ended the run early, if any. */ public function report(int $synced, int $skipped, ?\Throwable $error = NULL): void { // Never concatenate values into the message: placeholders keep the // string translatable and sanitize the values. @value escapes, // %value escapes and emphasizes, :value is for URLs in links. $this->logger->info('Sync finished: @count articles pushed.', [ '@count' => $synced, ]);
if ($skipped > 0) { $this->logger->warning('Skipped @count articles with no remote ID.', [ '@count' => $skipped, ]); }
if ($error) { // For exceptions, let core format the class, message, and // backtrace consistently. Error::logException($this->logger, $error); } }
}The placeholder rules come from LogMessageParser and matter for security and translation:
@valueinserts the value escaped. Your default.%valueescapes and wraps it in emphasis markup.:valueis for URLs used in link attributes.- For caught exceptions, call
Error::logException(); it formats the type, message, and location consistently with how core logs its own errors.
Building the message with string interpolation instead of placeholders breaks translation, defeats sanitization, and floods the log with unique strings; the placeholder form is not optional style.
Choose severities deliberately
Section titled “Choose severities deliberately”The eight levels are RFC 5424’s, defined as constants on RfcLogLevel and as methods on every logger:
| Method | Constant | Use for |
|---|---|---|
emergency() | RfcLogLevel::EMERGENCY (0) | The system is unusable. |
alert() | RfcLogLevel::ALERT (1) | Someone must act immediately. |
critical() | RfcLogLevel::CRITICAL (2) | A critical component failed. |
error() | RfcLogLevel::ERROR (3) | An operation failed; data may be affected. |
warning() | RfcLogLevel::WARNING (4) | Something unexpected that the code worked around. |
notice() | RfcLogLevel::NOTICE (5) | Normal but significant events. |
info() | RfcLogLevel::INFO (6) | Routine operational messages. |
debug() | RfcLogLevel::DEBUG (7) | Diagnostic detail for developers. |
Operators page people based on these. If everything your module writes is an error(), real errors disappear into the noise.
Route log entries to a destination
Section titled “Route log entries to a destination”Channels do not store anything. The logger factory fans every entry out to all services tagged logger; which of those exist is a site-building decision:
- Database Logging (
dblog) stores entries in the database and shows them at Administer → Reports → Recent log messages/admin/reports/dblog. Convenient in development; on busy production sites the constant writes and the retention pruning are real overhead. - Syslog (
syslog) hands entries to the operating system’s log facility, where logrotate, journald, or your hosting’s log pipeline takes over. The usual production choice, paired with uninstallingdblog. - Contributed modules add destinations the same way: implement
\Psr\Log\LoggerInterface, tag the service withlogger, and you receive every entry on the site.
ddev drush pm:install syslogddev drush pm:uninstall dblogVerify
Section titled “Verify”-
With Database Logging installed (
drush watchdog:showreads its table), trigger a code path that logs, or write a test entry directly:Terminal window drush php:eval "\Drupal::logger('my_module')->notice('Logging works.');"drush watchdog:show --count=5The entry appears with type
my_moduleand severity Notice. -
With
dbloginstalled, the same entry is visible at Administer → Reports → Recent log messages/admin/reports/dblog, filterable by themy_moduletype.
Variations
Section titled “Variations”- In plugins and other class hierarchies, where adding constructor arguments means fighting a parent class, use
LoggerChannelTraitand call$this->getLogger('my_module'). - Classes implementing
\Psr\Log\LoggerAwareInterfaceget their logger without any wiring: services autoconfigured with that interface are taggedlogger_aware, and a core compiler pass callssetLogger()withlogger.channel.<your module>automatically, provided you defined that channel as in step 1. - In procedural code (update hooks, Drush scripts) where injection is impossible,
\Drupal::logger('my_module')fetches the channel from the factory, creating it on the fly if you never defined the service.
Related
Section titled “Related”- Background processing and Call external APIs are the pages whose code does the most logging.
- Debug a Drupal site covers reading logs when something is wrong right now.
- API topic:
logging severity levels.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.