События и слушатели, кеширование, логирование

Рейтинг: 74.1% · 11 голосов
Курс по Laravel: маршруты, Eloquent, Blade, миграции, очереди и API. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
oleg_php
Сообщения: 25
Зарегистрирован: 14 май 2026, 08:06

События и слушатели, кеширование, логирование

Сообщение oleg_php »

АкадемияLaravel с нуляГлава 18 из 18
Оглавление курса (18)
  1. Знакомство с Laravel и установка окружения
  2. Маршруты и контроллеры
  3. Blade: шаблоны и вёрстка страниц
  4. Миграции и структура базы данных
  5. Eloquent ORM: модели и CRUD
  6. Связи в Eloquent: hasMany, belongsTo и другие
  7. Формы и валидация данных
  8. Аутентификация пользователей
  9. Middleware и защита маршрутов
  10. Очереди и фоновые задачи
  11. Отправка почты и уведомления
  12. Строим REST API на Laravel
  13. Авторизация: Gates и Policies
  14. Работа с файлами: загрузка, Storage, диски local и S3
  15. Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase
  16. Сервис-контейнер, провайдеры, свои artisan-команды и планировщик
  17. Деплой в продакшен и обзор современного фронтенда (Vite, Livewire, Inertia)
  18. События и слушатели, кеширование, логирование (вы здесь)
Формально курс закончился деплоем, но три темы я сознательно отложил, а без них на проде живется тяжело. Еще в главе 11 мы отправляли письмо из слушателя события, и я обещал разобрать сам механизм позже. Время пришло: события и слушатели, кеширование и логирование с разбором ошибок. Примеры проверены на Laravel 12 и PHP 8.3, на Laravel 11 все работает так же.

Зачем нужны события:

Представьте метод контроллера, который проводит оплату заказа. После оплаты надо отправить чек на почту, уведомить менеджера в Telegram, начислить бонусы и сбросить кэш статистики. Если писать все подряд, метод распухнет до сотни строк, а каждая новая хотелка будет лезть правками в оплату, самое опасное место проекта. События разворачивают зависимость: код оплаты сообщает "заказ оплачен" и идет дальше, а кто на это подписан, его не касается.

Код: Выделить всё

php artisan make:event OrderPaid
php artisan make:listener SendOrderReceipt --event=OrderPaid
Событие, это просто контейнер с данными:

Код: Выделить всё

// app/Events/OrderPaid.php
class OrderPaid
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}
Слушатель получает его в handle():

Код: Выделить всё

// app/Listeners/SendOrderReceipt.php
class SendOrderReceipt
{
    public function handle(OrderPaid $event): void
    {
        Mail::to($event->order->user)->send(new OrderReceipt($event->order));
    }
}
Связывать их руками не нужно. С Laravel 11 фреймворк сам сканирует app/Listeners и подписывает слушателей по тайпхинту аргумента в handle(). Кто на что подписан, показывает php artisan event:list. Осталось бросить событие в момент оплаты:

Код: Выделить всё

OrderPaid::dispatch($order);
Слушателей у события может быть сколько угодно: чек, Telegram, бонусы, каждый в своем классе. Добавление нового не трогает ни оплату, ни соседей.

Слушатель в очереди:

По умолчанию слушатели выполняются синхронно, прямо в запросе. Отправка письма занимает секунды, держать ради нее пользователя глупо. Добавьте интерфейс ShouldQueue, и слушатель уедет в очередь из главы 10 без единой лишней строчки:

Код: Выделить всё

class SendOrderReceipt implements ShouldQueue
{
    use InteractsWithQueue;

    public int $tries = 3;

    public function viaQueue(): string
    {
        return 'mail';
    }

    public function failed(OrderPaid $event, Throwable $e): void
    {
        Log::error('Чек не отправлен', ['order_id' => $event->order->id]);
    }
}
Трейт SerializesModels в событии кладет в очередь не объект целиком, а id модели, воркер сам достанет свежую запись из базы. И тут зарыта самая злая граблина главы. Если dispatch() случился внутри транзакции, воркер может схватить задачу раньше, чем транзакция закоммитится, и поймает ModelNotFoundException на записи, которой для него еще не существует. Лечение: интерфейс ShouldQueueAfterCommit вместо ShouldQueue, либо 'after_commit' => true у соединения в config/queue.php.

Наблюдатели моделей:

Eloquent и сам бросает события на каждый чих: creating, created, updating, updated, deleting, deleted. Подписываться на них удобнее не россыпью слушателей, а наблюдателем, классом, где собраны реакции на жизненный цикл одной модели:

Код: Выделить всё

php artisan make:observer ProductObserver --model=Product

Код: Выделить всё

class ProductObserver
{
    public function saved(Product $product): void
    {
        Cache::forget('catalog.menu');
    }

    public function deleted(Product $product): void
    {
        Cache::forget('catalog.menu');
    }
}
Регистрируется атрибутом прямо на модели:

Код: Выделить всё

use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy([ProductObserver::class])]
class Product extends Model
Этот наблюдатель сбрасывает кэш меню каталога при любом изменении товара. Что за кэш, разберем прямо сейчас.

Кеширование:

Меню каталога строится тяжелым запросом и меняется пару раз в день. Считать его заново на каждый запрос расточительно. Фасад Cache:

Код: Выделить всё

use Illuminate\Support\Facades\Cache;

$menu = Cache::remember('catalog.menu', now()->addHours(6), function () {
    return Category::query()
        ->whereNull('parent_id')
        ->with('children')
        ->get();
});
remember() смотрит в кэш по ключу, и если там пусто, выполняет замыкание и кладет результат на указанный срок. Второй аргумент принимает секунды или объект даты. Cache::forget('catalog.menu') удаляет ключ, именно его дергал наблюдатель выше. Есть еще rememberForever(), put(), get(), инкременты, но remember и forget закрывают процентов девяносто задач.

Где данные лежат физически, решает драйвер. С Laravel 11 по умолчанию стоит database, таблица cache уже есть в стартовых миграциях, для начала хватает. Драйвер file пишет в storage/framework/cache и бесполезен, когда серверов больше одного: у каждого свой диск и свой кэш. Для прода ставьте Redis: расширение phpredis из репозитория дистрибутива и CACHE_STORE=redis в .env. Redis держит данные в памяти, один на все серверы, и его же вы уже используете под очереди.

Инвалидация:

Главный вопрос кэширования не "как положить", а "когда сбросить". Стратегии три. Короткий TTL: данные отстают на пару минут, для счетчиков и статистики нормально. Явный forget() в наблюдателе модели: данные всегда свежие ценой связки кэша с моделью. Тегированный кэш через Cache::tags() сбрасывает группы ключей разом, но работает только на redis и memcached.

С версии 11.23 есть еще Cache::flexible(), он убирает толпу на протухшем ключе:

Код: Выделить всё

$menu = Cache::flexible('catalog.menu', [3600, 21600], fn () => Category::tree());
Первый час значение считается свежим. С первого по шестой час remember() пересчитал бы его прямо в запросе невезучего пользователя, а flexible() мгновенно отдаст старое и пересчитает уже после отправки ответа.

И не зовите Cache::flush() или php artisan cache:clear на проде по привычке: они сносят стор целиком, не глядя на префиксы, включая локи планировщика из главы 16.

Логирование:

Каналы описаны в config/logging.php. По умолчанию активен stack, который пишет в один файл storage/logs/laravel.log. На проде первым делом переключайтесь на daily, тот же лог, но с ротацией по дням и автоудалением старых файлов:

Код: Выделить всё

LOG_CHANNEL=daily
LOG_DAILY_DAYS=14
Писать в лог умеет фасад Log, уровни стандартные, от debug до emergency:

Код: Выделить всё

Log::info('Заказ оплачен', ['order_id' => $order->id, 'amount' => $order->total]);
Log::channel('slack')->critical('Платежный шлюз недоступен');
Второй аргумент, контекст, попадает в запись как JSON. Кладите туда id сущностей, а не клейте их в строку сообщения, по JSON потом удобно грепать. Канал slack из коробки шлет в вебхук (LOG_SLACK_WEBHOOK_URL) все от уровня critical, для алертов самое то. А фасад Context (появился в Laravel 11) добавляет данные сразу во все записи текущего запроса и, что особенно приятно, протаскивает их в задачи очереди:

Код: Выделить всё

// в middleware
Context::add('request_id', (string) Str::uuid());
Теперь по request_id собирается вся история одного запроса, включая то, что писали воркеры.

Свои исключения:

Генерятся командой php artisan make:exception. У класса исключения могут быть методы report() и render(), фреймворк найдет их сам: report() решает, как событие попадет в лог, render() какой ответ увидит пользователь.

Код: Выделить всё

class PaymentGatewayException extends Exception
{
    public function report(): void
    {
        Log::channel('slack')->critical($this->getMessage());
    }

    public function render(Request $request): Response
    {
        return response()->view('errors.payment', [], 502);
    }
}
Глобальная настройка обработки в Laravel 11 переехала из app/Exceptions/Handler.php (его больше нет) в bootstrap/app.php:

Код: Выделить всё

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->dontReport(PaymentDeclinedException::class);
    $exceptions->level(PDOException::class, LogLevel::CRITICAL);
})
Запомните и хелпер report($e): он отправляет исключение в лог, не прерывая выполнение. Незаменим в catch, где ошибка некритична, но знать о ней надо.

Разбор ошибок на проде:

APP_DEBUG=false на проде, всегда. С true Laravel отдает любому посетителю трассировку, переменные окружения и куски кода. Пользователь видит страницу 500, а вы идете в storage/logs/laravel-2026-06-11.log. Запись начинается строкой с датой, уровнем и сообщением, дальше стектрейс. Читайте трейс сверху вниз до первого файла из app/, это почти всегда и есть виновник, остальное служебные вызовы фреймворка. Локально удобен laravel/pail (ставится с флагом --dev): php artisan pail --level=error показывает лог в реальном времени с фильтрами. На сервере те же задачи закрывают tail -f и grep.

Типичные грабли:

Массовые операции мимо наблюдателей. Product::where('old', true)->update(['archived' => true]) не создает моделей и не бросает событий Eloquent: наблюдатель молчит, кэш не сброшен. Касается и update, и delete через билдер. Либо обходите записи коллекцией, либо сбрасывайте кэш руками рядом с запросом. Обратная сторона той же медали: saveQuietly() сохраняет модель, не будя наблюдателей, выручает в сидерах и миграциях данных.

Слушатель в очереди и старый код. Queued-слушатель, это обычная задача очереди: после деплоя воркер выполняет старую версию класса, пока не сделаете php artisan queue:restart. Грабли из главы 10, но на слушателях про них стабильно забывают.

Секреты в логах. Не логируйте request()->all() на формах с паролями и картами. Один забытый Log::debug, и пароли пользователей лежат в laravel.log открытым текстом.

debug на проде. LOG_LEVEL=debug пишет все подряд, диск кончается в самый неудачный момент. Для прода info, а лучше warning, если info-шума много.

Итог: события развязывают код, наблюдатели вешают реакции на жизненный цикл моделей, кэш экономит базу, а нормально настроенные логи отвечают на вопрос "что случилось ночью" за минуты, а не часы. На этом курс действительно закончен. Дальше практика: возьмите пет-проект и прогоните его через все восемнадцать глав, от миграций до деплоя с очередями и алертами в Slack.
👍4 ❤️3 🔥1 😄 🤔1
✔ Лучший ответ сформирован автоматически — ber113
Грабли с транзакцией подтверждаю, у нас это был плавающий баг месяца. Локально не воспроизводилось вообще никак, воркер банально не успевал схватить задачу раньше коммита. На проде примерно раз на двести заказов прилетал ModelNotFoundException из недр SerializesModels. Поставили after_commit => true сразу на все соединение в config/queue.php и симптом ушел. Совет: не лечите точечно интерфейсом, г…
Перейти к ответу →
Аватара пользователя
ansible_kun
Сообщения: 1
Зарегистрирован: 19 май 2026, 09:31

Re: События и слушатели, кеширование, логирование

Сообщение ansible_kun »

oleg_php писал(а):flexible() мгновенно отдаст старое и пересчитает уже после отправки ответа
а кто конкретно пересчитывает? ответ ушел, запрос закончился, откуда возьмется процесс на пересчет? или это втихую в очередь улетает?
👍 ❤️ 🔥2 😄 🤔
Аватара пользователя
beograd
Сообщения: 1
Зарегистрирован: 14 май 2026, 22:46

Re: События и слушатели, кеширование, логирование

Сообщение beograd »

Прикрутил Context с request_id за вечер, теперь grep по uuid собирает всю цепочку запроса вместе с джобами, разбор инцидентов ускорился раза в три. Один нюанс, который сам не сразу понял: Log::withContext добавляет контекст только в записи текущего процесса, в задачи очереди он не едет. Едет именно через фасад Context, не перепутайте.
👍1 ❤️1 🔥1 😄 🤔
Аватара пользователя
ber113
Сообщения: 2
Зарегистрирован: 08 июн 2026, 13:34

Re: События и слушатели, кеширование, логирование

Сообщение ber113 »

✔ Лучший ответ — сформирован автоматически
Грабли с транзакцией подтверждаю, у нас это был плавающий баг месяца. Локально не воспроизводилось вообще никак, воркер банально не успевал схватить задачу раньше коммита. На проде примерно раз на двести заказов прилетал ModelNotFoundException из недр SerializesModels. Поставили after_commit => true сразу на все соединение в config/queue.php и симптом ушел. Совет: не лечите точечно интерфейсом, глобальная настройка спасает и от будущих слушателей, про которые никто не вспомнит.
👍 ❤️ 🔥1 😄 🤔
Аватара пользователя
rkay
Сообщения: 1
Зарегистрирован: 30 май 2026, 06:22

Re: События и слушатели, кеширование, логирование

Сообщение rkay »

не в очередь. под php-fpm ларавель зовет fastcgi_finish_request, соединение с клиентом закрывается, а сам процесс еще жив и дорабатывает отложенные колбеки через defer(). пользователь ничего не ждет. минус один: если пересчет упадет, ключ останется протухшим и его попробует пересчитать следующий запрос. тяжелую логику я бы все равно уносил в очередь, а flexible держал для запросов на секунду-две
👍 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
Деплой в продакшен и обзор современного фронтенда (Vite, Livewire, Inertia)

Все главы курса «Laravel с нуля»

Поделиться темой: ✈ Telegram VK
  • Похожие темы

Вернуться в «Laravel с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость