Imagine your module breaks in production at 3 AM, and the only clue is a user's vague complaint that "the form didn't work." Without logging, you're debugging blind. Drupal's logging system is the flight recorder for your code — every important event, error, or state change gets written down somewhere a developer can find it later, long after the moment has passed.
What you'll learn in this lesson
- How to get a logger instance for your own module via the
logger.factoryservice - The full range of RFC 5424 severity levels Drupal's logger supports, and when to use each
- How to inject the logger service properly instead of calling
\Drupal::logger()directly - Where those log messages actually end up, and how to find them
Getting a logger for your module
Every logger in Drupal belongs to a named channel — a string that tags every message so you can filter by source later. The simplest way to get one:
\Drupal::logger('my_module')->notice('User %uid completed checkout.', ['%uid' => $account->id()]);
\Drupal::logger('my_module') is a static shortcut into the logger.factory service, asking it for a channel named my_module. That channel name is exactly what shows up in the Type column when you go looking for these messages later — pick your module's machine name so there's never any ambiguity about where a log entry came from.
The severity levels, and when each one is right
emergency()— the system is unusable. Reserve this for genuinely catastrophic failures.alert()— action must be taken immediately (a security breach, a full disk).critical()— critical conditions (a required service is down).error()— a runtime error that doesn't require immediate action but should be tracked and fixed.warning()— an exceptional occurrence that isn't an error (a deprecated API call, a fallback path taken).notice()— normal but significant events (the checkout example above).info()— routine, informational messages.debug()— detailed debug information, usually silenced in production.
%uid token above rather than "User " . $account->id(). Drupal's logger runs your message through its own placeholder substitution (the same %/@/: convention as t()), which keeps dynamic values out of the raw message string — useful for grouping identical log entries together in reports, and safer if the value ever contains user input.Injecting the logger properly
The static \Drupal::logger() call above is fine for a quick script, but inside a service, controller, or form class, inject logger.factory like any other dependency:
use Psr\Log\LoggerInterface;
class MyService {
public function __construct(
protected LoggerInterface $logger,
) {}
public static function create(ContainerInterface $container) {
return new static(
$container->get('logger.factory')->get('my_module'),
);
}
public function processOrder(int $orderId): void {
$this->logger->notice('Processing order #@id.', ['@id' => $orderId]);
}
}
Notice the injected type is Psr\Log\LoggerInterface, not some Drupal-specific logger class — Drupal's logging system implements the standard PSR-3 interface, so any code you write against it is portable to other PHP projects that follow the same standard.
See it for yourself
Log a test message from any custom code (even a quick \Drupal::logger('my_module')->notice('test') in a controller), then visit Reports → Recent log messages (/admin/reports/dblog) on your Drupal site. Filter the Type column to your module's machine name and you'll see every message you've logged, each with a timestamp, severity icon, and the exact text.
Quick check: you're logging a message every time a user submits a contact form — is that
notice()orinfo()? Either could arguably work, butnotice()is the better fit: it's a normal, expected event that's still worth tracking (someone submitted the form), whereasinfo()is better reserved for lower-value routine chatter you'd rarely need to search for specifically.
Key takeaways
\Drupal::logger('channel')(or the injectedlogger.factoryservice) is how every log message gets written in Drupal — always give it your module's machine name as the channel.- Use the standard 8 RFC 5424 severity levels correctly — they control how a message is filtered, surfaced, and prioritized by anyone reading the log later.
- Pass dynamic values as placeholders (
%value,@value,:value) rather than concatenating strings, exactly liket(). - Inject
logger.factoryas aPsr\Log\LoggerInterfacein services/controllers rather than calling the static\Drupal::logger()helper, for testability. - Every logged message lands in
/admin/reports/dblogby default, filterable by channel and severity.
Coming up next
One channel is fine for a small module, but larger ones often want to separate concerns — a "my_module.security" channel distinct from "my_module.performance," say. Next, we'll look at defining a custom log channel of your own.