Laravel tricks: automatic logging channel injection
Laravel ships with a powerful logging stack built on top of Monolog, tucked behind the Log facade so client code stays clean. Channels live in config/logging.php — each one carries its own handlers, formatter and level. From there you just write Log::channel('payments')->info(...) and the record lands wherever that channel points.
With two or three channels and a handful of scattered call sites, this is fine. The pain shows up once channel-based logging turns into a cross-cutting concern and a class hard-codes the channel it writes to. Below is the trick I use to untangle that. Up front: it’s a trick, not an architectural law.
The problem
Here’s the shape most logging code takes inside a class:
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
class TaskController extends Controller
{
protected LoggerInterface $logger;
public function __construct()
{
$this->logger = Log::channel('task');
}
}
The class bakes the channel name task into itself. That’s tight coupling: the controller knows not only that it logs, but which specific channel in the config it writes to. With one class it’s tolerable. With dozens of them — and a channel you need to rename, split in two, or swap out in tests — you’re chasing the same string across files by hand. Neither the IDE nor the container can help you here: 'task' is just a string to them.
What I want is for the class to declare “I need a logger” and for the decision about which channel to hand it to live in one place.
Automatic injection
PSR-3 already has the machinery for this — LoggerAwareInterface with its single setLogger() method, plus the matching LoggerAwareTrait that holds the $logger property and implements the setter. All that’s left is teaching Laravel’s container to call that setter for us.
We start with a marker interface extending LoggerAwareInterface. It’s there to tell “our” classes apart from everyone else’s and to pin a specific channel to them:
<?php
namespace App\Support\Logging;
use Psr\Log\LoggerAwareInterface;
interface DefaultLoggerAwareInterface extends LoggerAwareInterface
{
}
In a service provider we hang an afterResolving hook on that interface — the container fires it every time it resolves an object implementing the marker, and we inject the channel we want:
<?php
namespace App\Providers;
use App\Support\Logging\DefaultLoggerAwareInterface;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->afterResolving(
DefaultLoggerAwareInterface::class,
function (DefaultLoggerAwareInterface $aware) {
$aware->setLogger(Log::channel('common'));
}
);
}
}
In the class itself we implement the marker and pull in the trait — the trait brings the $logger property and the setLogger() method along for free:
<?php
namespace App\Http\Controllers;
use App\Support\Logging\DefaultLoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
class TaskController extends Controller implements DefaultLoggerAwareInterface
{
use LoggerAwareTrait;
}
By the time a method runs, the logger is already set — so we just reach for the $logger property:
public function __invoke(Request $request)
{
$this->logger?->info('Request data', $request->toArray());
}
The ?-> isn’t decoration. LoggerAwareTrait declares $logger as nullable, so if the object was ever built outside the container the property stays null and the call quietly no-ops instead of throwing a fatal error. The channel name now lives in exactly one spot — the provider. Change that string and every marked class changes with it.
The one condition: the class has to come from the container. Laravel already resolves controllers, jobs and event listeners through the container, so the hook fires with no extra effort. An object built with new, though, sails right past it — and nothing will inject a logger into it.
Without the magic
If hiding the wiring inside an afterResolving hook feels too implicit, contextual binding gets you the same result in a more visible way. Ask for LoggerInterface straight in the method signature and Laravel resolves it through the container via autowiring:
public function __invoke(Request $request, LoggerInterface $logger)
{
$logger->info('Request data', $request->toArray());
}
Then in the provider you use when()->needs()->give() to say which channel this class should get when it asks for a LoggerInterface:
$this->app->when(TaskController::class)
->needs(LoggerInterface::class)
->give(fn () => Log::channel('common'));
No marker interface, no trait, no class property — the dependency arrives as an argument and is right there in the signature. The price of that explicitness is that you write the rule per class, whereas the marker gets hung once for everyone.
Wrapping up
Both approaches do the same thing: they lift the channel name out of the class body and leave the class free to simply say “I need a logger.” That drops coupling and makes maintenance easier — renaming a channel or swapping it in tests becomes a one-line change in the provider.
The first version of this write-up went out on my Habr: habr.com/ru/articles/790936.
Still, this is a trick, not a universal rule — reach for it where it actually earns its keep. It pays off when you have plenty of logging classes and the channel might genuinely change; on a couple of spots with a plain Log::channel() you’d only be adding interfaces and hooks for nothing. And mind the boundary: it only works for objects you get through the DI container.