Все статьи

Laravel трюки: автоматическое подключение каналов логирования

4 мин чтения
Laravel PHP Logging

Фреймворк 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-контейнер.