Laravel трюки: автоматическое подключение каналов логирования
Фреймворк Laravel имеет встроенный мощный функционал логирования на базе библиотеки Monolog, скрытый за фасадом Log для удобства клиентского кода. Каналы описываются в config/logging.php — каждый со своим набором обработчиков, форматтером и уровнем. Дальше вы просто пишете Log::channel('payments')->info(...), и запись уходит туда, куда настроен канал.
Пока каналов два-три и вызовы разбросаны точечно — всё в порядке. Проблемы начинаются, когда логирование по каналам становится сквозной темой проекта и класс намертво знает, в какой канал он пишет. Ниже — приём, которым я развязываю этот узел. Сразу оговорюсь: это именно приём, а не архитектурный закон.
Определение проблемы
Обычно для сбора логов в классах мы используем следующую конструкцию:
<?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');
}
}
Здесь класс зашивает в себя имя канала task. Это сильная связанность: контроллер знает не только про сам факт логирования, но и про конкретный канал в конфиге. Пока класс один — терпимо. Когда таких классов десятки, а канал нужно переименовать, разнести на два или подменить в тестах — приходится прыгать по классам и править одно и то же имя руками. Ни IDE, ни контейнер тут не помогут: строка 'task' для них просто строка.
Хочется, чтобы класс объявлял «мне нужен логгер», а решение о том, какой именно канал ему подставить, принималось в одном месте.
Автоматическое подключение
В PSR-3 для этого уже есть готовый механизм — LoggerAwareInterface с единственным методом setLogger() и парный к нему LoggerAwareTrait, который держит свойство $logger и реализует сеттер. Осталось научить контейнер Laravel вызывать этот сеттер автоматически.
Создаём интерфейс-маркер, наследующий LoggerAwareInterface — он нужен, чтобы отличать «наши» классы от чужих и привязать к ним конкретный канал:
<?php
namespace App\Support\Logging;
use Psr\Log\LoggerAwareInterface;
interface DefaultLoggerAwareInterface extends LoggerAwareInterface
{
}
В сервис-провайдере вешаем на этот интерфейс хук afterResolving — контейнер дёрнет его каждый раз, когда разрешит объект, реализующий маркер, и подставит нужный канал:
<?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'));
}
);
}
}
В самом классе реализуем маркер и подключаем трейт — трейт сам добавит свойство $logger и метод setLogger():
<?php
namespace App\Http\Controllers;
use App\Support\Logging\DefaultLoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
class TaskController extends Controller implements DefaultLoggerAwareInterface
{
use LoggerAwareTrait;
}
Теперь логгер уже проставлен к моменту вызова метода — пользуемся свойством $logger:
public function __invoke(Request $request)
{
$this->logger?->info('Данные запроса', $request->toArray());
}
Оператор ?-> тут не для красоты: LoggerAwareTrait объявляет $logger как nullable, и если объект вдруг создан в обход контейнера, свойство останется null, а вызов просто не выстрелит вместо фатальной ошибки. Имя канала теперь живёт в одном месте — в провайдере. Меняете там строку — меняется поведение всех классов с маркером сразу.
Ключевое условие: класс должен приходить из контейнера. Контроллеры, джобы, слушатели событий Laravel и так резолвит через контейнер, поэтому хук срабатывает без дополнительных телодвижений. А вот объект, созданный через new, мимо контейнера пройдёт — и логгер ему никто не подставит.
Без магии
Если прятать привязку логгера в хук afterResolving не хочется, тот же результат даёт контекстное связывание — более явный и заметный в коде вариант. Запрашиваем LoggerInterface прямо в сигнатуре метода, и Laravel разрешит его через контейнер (autowiring):
public function __invoke(Request $request, LoggerInterface $logger)
{
$logger->info('Данные запроса', $request->toArray());
}
А в провайдере через when()->needs()->give() говорим, какой именно канал отдавать этому классу, когда он просит LoggerInterface:
$this->app->when(TaskController::class)
->needs(LoggerInterface::class)
->give(fn () => Log::channel('common'));
Здесь нет ни интерфейса-маркера, ни трейта, ни свойства класса — зависимость приходит аргументом и видна прямо в сигнатуре. Плата за явность — правило приходится прописывать под каждый класс отдельно, тогда как маркер вешается один раз на всех.
Эпилог
Оба приёма делают одно: убирают имя канала из тела класса и оставляют классу право просто сказать «мне нужен логгер». Это снижает связанность и упрощает поддержку — переименовать канал или подменить его в тестах становится делом одной строки в провайдере.
Первая версия этого разбора вышла у меня на Хабре: habr.com/ru/articles/790936.
Однако это трюк, а не универсальное правило — применяйте его там, где это действительно имеет смысл. Он окупается, когда классов с логированием много и канал реально может поменяться; на паре мест с прямым Log::channel() вы только добавите себе интерфейсов и хуков на ровном месте. И помните про границу: работает это только для объектов, которые вы получаете через DI-контейнер.