Laravel Octane: что ревьюить в коде под долгоживущий воркер
Octane держит приложение в памяти между запросами, и код, который годами работал в PHP-FPM, начинает течь и отдавать чужие данные. Разбираем, на что смотреть в pull request.

В PHP-FPM каждый запрос стартует с чистого листа: интерпретатор поднимает фреймворк, обрабатывает запрос и умирает. Laravel Octane (проверялось на Octane 2.x с воркерами Swoole и RoadRunner) меняет модель: приложение бутстрапится один раз, а дальше воркер обрабатывает тысячи запросов подряд, не сбрасывая память. Производительность растёт в разы, но вместе с ней всплывают классы багов, которых в FPM физически не бывает: утечка данных между запросами, накопление памяти, синглтоны с застрявшим состоянием. Ниже — что искать в ревью кода, который поедет на Octane.
Почему обычное ревью здесь не спасает
Код проходит тесты, работает на стейдже под FPM, ревьюер ставит апрув. На Octane тот же код на третьем часу отдаёт пользователю A имя пользователя B. Причина в том, что переменная, которую вы считали «локальной для запроса», на самом деле пережила запрос: она застряла в синглтоне, статическом свойстве или замыкании, которое Octane закешировал.
Ревью под Octane — это не про красоту кода. Это про один вопрос к каждому куску состояния: переживёт ли эта переменная границу запроса, и что будет, если да.
В FPM утечка состояния между запросами невозможна физически — процесс умирает. На Octane это самая частая и самая незаметная ошибка: она проявляется только под трафиком и только иногда.
Флаг первый: состояние в синглтонах
Сервис, зарегистрированный через $this->app->singleton(), создаётся один раз на воркер и живёт между запросами. Если он хранит внутри данные конкретного запроса — это утечка.
// Опасно: синглтон запоминает пользователя первого запроса
class CurrentContext
{
protected ?User $user = null;
public function setUser(User $user): void
{
$this->user = $user; // застрянет до перезапуска воркера
}
public function user(): ?User
{
return $this->user;
}
}
Первый запрос положил сюда своего пользователя, второй запрос эту же переменную читает — и получает чужие данные. В ревью ищите поля с изменяемым состоянием у любого сервиса, зарегистрированного как singleton. Если состояние привязано к запросу, его место либо в scoped-биндинге, либо вообще не в контейнере.
Что предложить вместо этого
Octane даёт $this->app->scoped() — такой биндинг пересоздаётся на каждый запрос, но остаётся синглтоном внутри одного запроса. Для контекста запроса это правильный инструмент.
// В AppServiceProvider::register()
$this->app->scoped(CurrentContext::class, function () {
return new CurrentContext();
});
Замечание в PR формулируйте конкретно: «Этот сервис хранит User в поле и зарегистрирован как singleton — на Octane он вернёт чужого пользователя со второго запроса. Переведите на scoped или уберите поле».
Флаг второй: статические свойства и глобальные переменные
Статическое свойство класса живёт столько же, сколько воркер. Классический паттерн «ленивый кеш в статике» на Octane превращается в утечку памяти или в отдачу устаревших данных.
class SettingsCache
{
protected static array $cache = [];
public static function get(string $key): mixed
{
// Наполняется один раз и живёт до перезапуска воркера.
// Если настройки поменялись в БД — воркер этого не увидит.
return static::$cache[$key] ??= DB::table('settings')
->where('key', $key)->value('value');
}
}
Здесь два разных бага сразу. Первый: если ключей много и они зависят от входных данных, массив растёт без границы — воркер медленно съедает память. Второй: обновление настройки в базе не долетит до воркера, пока его не перезапустят. В ревью помечайте любую статическую коллекцию, которая наполняется по ходу запросов.
Флаг третий: слушатели, которые регистрируются повторно
Код внутри жизненного цикла запроса, который вызывает Event::listen(), Queue::before() или добавляет middleware на лету, на Octane регистрирует новый слушатель на каждый запрос. К тысячному запросу у вас тысяча одинаковых слушателей, и каждый срабатывает.
- Симптом: событие обрабатывается по нарастающей — сначала один раз, потом два, потом десять.
- Где искать: регистрация слушателей и макросов внутри контроллеров, middleware, методов сервисов — всего, что выполняется на каждый запрос.
- Правильное место: метод
boot()сервис-провайдера, который на Octane выполняется один раз на старте воркера.
Флаг четвёртый: замыкания с захваченным состоянием
Octane кеширует часть замыканий (например, зарегистрированные как обработчики). Если замыкание захватило по ссылке объект запроса или пользователя через use ($request), оно продолжит держать первый захваченный экземпляр. Ревьюя биндинги в провайдерах, проверяйте, что фабрики не захватывают request-scoped объекты в замыкание, живущее на уровне воркера.
Таблица: где хранить состояние
| Что храните | FPM | Octane |
|---|---|---|
| Конфиг, неизменяемые справочники | singleton | singleton — ок, живёт весь воркер |
| Контекст текущего запроса (user, tenant) | singleton работает случайно | только scoped или контейнер запроса |
| Кеш с изменяемыми данными из БД | статика «прощается» | Redis/Cache::, не статика |
| Слушатели событий, макросы | любое место | только boot() провайдера |
| Временные данные внутри запроса | локальная переменная | локальная переменная — ок |
Флаг пятый: пакеты, которые не рассчитаны на Octane
Часть сторонних пакетов кеширует состояние в статике внутри себя, и вы этого в своём коде не увидите. Laravel Octane предоставляет механизм сброса — секцию warm и flush в конфиге, а также интеграции для сброса состояния фреймворка между запросами. Точный список сбрасываемых сервисов и синтаксис секций уточняйте в официальной документации Octane для вашей версии: набор менялся между релизами. В ревью PR, который подключает новый пакет, стоит задать вопрос прямо: проверяли ли, что пакет не держит request-состояние в статике.
Границы: где эти правила не работают
- Очереди и планировщик. Воркеры очередей в Octane — отдельная история; часть проблем со state там ведёт себя иначе. Здесь разбираем только HTTP-запросы.
- Долгоживущие соединения. Открытое соединение с внешним API, положенное в singleton, может протухнуть по таймауту и не переоткрыться. Это не утечка данных, а отдельный класс багов — стоит отдельного ревью.
- Память как метрика. Даже корректный код накапливает память постепенно из-за фрагментации. Настройка
max_requests(перезапуск воркера после N запросов) — не костыль, а штатный предохранитель.
Чек-лист ревьюера
- Есть ли у синглтонов изменяемые поля с request-данными? Если да — scoped.
- Наполняются ли статические свойства по ходу запросов? Если да — вынести в Cache или ограничить размер.
- Регистрируются ли слушатели, макросы, middleware вне
boot()? Перенести в boot. - Захватывают ли долгоживущие замыкания request-объекты через
use? - Новые пакеты проверены на статическое состояние?
- Настроен ли
max_requestsкак предохранитель от медленной утечки?
Что делать дальше
Одного ревью мало — часть утечек видна только под трафиком. Поднимите приложение под Octane в стейджинге и погоняйте нагрузочным тестом на несколько тысяч запросов подряд, следя за RSS воркеров: если память растёт монотонно и не выходит на плато — где-то накапливается состояние. Включите в CI прогон тестов через Octane-режим, а не только через обычный тест-раннер: часть багов с общим состоянием ловится уже там. И держите max_requests включённым в проде как страховку на время, пока код не вычищен полностью.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel Octane — официальная документация
Частые вопросы
А если у меня Octane на RoadRunner, а не на Swoole — правила те же?
Как понять, что утечка вообще есть, если под тестами всё зелёное?
Обязательно ли переписывать все singleton на scoped?
Что делать, если проблемный код в стороннем пакете, а не у меня?
max_requests с перезапуском воркера — это ведь убивает выигрыш от Octane?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.