<? phpukraine СТАТТІ
Пошук по платформі
ПРОДУКТИВНІСТЬ 6 вересня 2026 · 9 хв читання

PHP-FPM, FrankenPHP чи Octane: що обрати для Laravel і Symfony у 2026

Порівняння трьох моделей виконання без чужих бенчмарків: що саме випливає з архітектури кожної — які межі паралелізму вона задає, що ламається, коли застосунок перестає вмирати після запиту, і як змінюється деплой. У кінці — чекліст, за яким рішення приймається за пів години, а не за спринт.

РP
Редакція phpukraine
Редакція платформи

«Перейдіть на Octane і буде вдвічі швидше» — порада, яка нічого не варта без відповіді на два питання: скільки у вашому запиті займає бутстрап фреймворку і чи витримає ваш код життя між запитами. Перше вимірюється профайлером за годину. Друге — це аудит, який може з'їсти тижні. Нижче — те, що випливає з самої архітектури трьох моделей: межі паралелізму, поведінка стану, деплой. Цифр з голови не буде: будь-яке порівняння швидкості має сенс тільки на вашому коді й вашому залізі.

Три моделі виконання

PHP-FPM. Майстер-процес тримає пул воркерів, кожен воркер обробляє один запит від початку до кінця, після чого інтерпретатор чистить усе, що запит нажив: змінні, статичні властивості класів, відкриті з'єднання (крім PDO::ATTR_PERSISTENT), зареєстровані shutdown-функції. Наступний запит стартує з чистого аркуша й наново бутстрапить фреймворк. Це модель shared-nothing: між запитами не переживає нічого, крім опкодів у shared memory OPcache. Паралелізм задається pm.max_children — це жорстка стеля одночасних запитів; коли всі воркери зайняті, решта чекає в черзі бекенду.

FrankenPHP. Це вебсервер Caddy зі вбудованим PHP (потрібна ZTS-збірка), тобто один бінарник замість зв'язки nginx + php-fpm. У класичному режимі він поводиться так само, як FPM: запит — повний бутстрап — очищення. Виграш тут не в моделі виконання, а в інфраструктурі: HTTP/2 і HTTP/3, автоматичний TLS, роздача статики самим сервером, 103 Early Hints. Окремо є worker mode: PHP-скрипт бутстрапить застосунок один раз і далі крутиться в циклі, приймаючи запити. Виглядає це так:

// public/frankenphp-worker.php
$handler = static function (): void {
    // тіло запиту: сюди FrankenPHP передає керування
};

while (frankenphp_handle_request($handler)) {
    gc_collect_cycles();
}

Laravel Octane. Octane — не сервер. Це супервізор і адаптер, який запускає ваш застосунок під одним із бекендів (FrankenPHP, RoadRunner, Swoole) і бере на себе те, чого голий воркер-скрипт не робить: конфіг warm/flush для контейнера, події RequestReceived / RequestTerminated, --max-requests, octane:reload. Тобто Octane — це worker mode плюс набір інструментів для боротьби з наслідками worker mode. Для Symfony аналога Octane немає й не потрібно: там ту саму роль виконує компонент Runtime — composer require runtime/frankenphp-symfony і APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime в .env, після чого public/index.php лишається без змін, а перезбирання стану бере на себе рантайм.

Що ламається, коли застосунок живе між запитами

Перехід у worker mode — це не налаштування, а зміна контракту. Усе, що раніше гарантовано вмирало наприкінці запиту, тепер живе, поки живий процес.

Статичний і синглтонний стан. Класика — мемоізація без ключа життєвого циклу:

final class TenantSettings
{
    /** @var array<int, Settings> */
    private static array $cache = [];   // росте, поки живий воркер
}

У FPM цей масив ніколи не перевищить одного орендаря. У worker mode він накопичує всіх, кого обслужив процес, — це не «витік» у сенсі забутих посилань, це нескінченний кеш, який ніхто не обмежив.

Сервіси, які тримають запит. Найгірший клас багів — не памʼять, а неправильні дані:

final class AuditLogger
{
    public function __construct(private readonly Request $request) {}
}

Якщо це синглтон, він отримає Request першого запиту і носитиме його IP і користувача далі. Документація Octane прямо забороняє інжектити контейнер або Request у синглтони; замість цього беріть їх у момент виклику методу. У config/octane.php для решти випадків є масив flush — сервіси, які викидаються з контейнера після кожного запиту, і warm — ті, що резолвляться на старті воркера.

У Symfony це вирішено на рівні контейнера: сервіс зі станом реалізує Symfony\Contracts\Service\ResetInterface, автоконфігурація вішає на нього тег kernel.reset, і рантайм викликає reset() між запитами.

final class TenantContext implements ResetInterface
{
    private ?Tenant $tenant = null;

    public function reset(): void
    {
        $this->tenant = null;
    }
}

Глобальні побічні ефекти. putenv(), setlocale(), date_default_timezone_set(), ini_set(), Carbon::setTestNow() — усе це процесне, а не запитове. Виклик у середині одного запиту змінює поведінку наступних.

Ресурси й дескриптори. З'єднання з БД тепер довгоживуче: воно впаде за wait_timeout на стороні сервера, і ви отримаєте «server has gone away» на випадковому запиті замість передбачуваного перепідключення. Хендл файлу лога, відкритий один раз, після ротації писатиме у видалений inode. Doctrine EntityManager після винятку закривається назавжди — у shared-nothing це коштувало одного запиту, у worker mode це коштує всіх наступних запитів того воркера, доки його не перезапустять.

Портативність API. fastcgi_finish_request() існує лише у FPM. Код, який на нього спирається, поза FPM просто перестане віддавати відповідь раніше — обгортайте його в function_exists() або замінюйте на terminable middleware чи defer().

Обробка фаталів. У FPM фатальна помилка вбиває один запит. У worker mode вона вбиває воркер: Octane і FrankenPHP його підіймуть, але всі, хто чекав на цей процес, отримають розрив. Тому --max-requests — не косметика, а страховка: перезапуск воркера кожні N запитів обмежує накопичення будь-яких витоків, про які ви ще не знаєте.

Хороший маркер готовності: якщо у вас уже є queue:work, який доводиться перезапускати частіше, ніж «раз на деплой», ті самі проблеми чекають на вас у веб-шарі — просто з користувачами замість джоб.

Памʼять і паралелізм: арифметика до міграції

Дві формули, які корисно порахувати ще до вибору.

Стеля одночасних запитів у FPM — pm.max_children, у worker mode — кількість воркерів. В обох випадках процес блокується на I/O: якщо запит чекає три секунди на зовнішній API, воркер увесь цей час не приймає нічого іншого. Виняток — Swoole з корутинами, але це окрема модель зі своїм списком несумісностей. Тому «зменшимо кількість процесів, бо кожен став швидшим» — небезпечний хід для застосунку, який багато чекає на мережу: латентність відповіді впаде, а стеля одночасності разом із нею.

Памʼять: у FPM ліміт memory_limit застосовується до одного запиту, і після нього все звільняється. У worker mode memory_limit — це стеля для процесу за весь час його життя, і будь-який неочищений кеш з'їдає її поступово. Тому бюджет рахується не як «пік одного запиту», а як «пік одного запиту плюс усе, що воркер накопичив від старту».

Деплой і перезапуск воркерів

У FPM код підхоплюється тим, як налаштований OPcache. Якщо opcache.validate_timestamps=0 (а в контейнері це правильне значення), новий код не з'явиться сам: потрібен SIGUSR2 майстру для graceful reload або новий контейнер. Класична пастка деплою через symlink — старий realpath-кеш і старі опкоди після перемикання посилання; лікується тим самим reload.

У worker mode до цього додається бутстрап. Перезапуск воркера означає повторний бутстрап фреймворку, тобто короткий провал пропускної здатності саме в момент релізу. Octane робить це керовано: php artisan octane:reload дає воркерам догасити поточні запити й піднімає нові. FrankenPHP без Octane перезапускає воркерів на caddy reload; директива watch у блоці worker призначена для локальної розробки — у продакшені вона не потрібна, бо код в образі не змінюється.

Три наслідки, про які згадують уже після першого інциденту:

  • Зміни в .env і конфігах не підхоплюються. У worker mode конфіг прочитаний один раз при старті. Будь-яка зміна змінної оточення вимагає перезапуску воркерів, а не тільки config:cache.
  • Міграції мають бути сумісні в обидва боки. Під час викочування старі й нові воркери працюють одночасно — схема повинна влаштовувати обидві версії коду. Це справедливо і для FPM, але там вікно перекриття вимірюється тривалістю запиту, а не тривалістю життя процесу.
  • SIGTERM треба обробити. Воркер має догасити поточний запит, а не обірватися. У Kubernetes це terminationGracePeriodSeconds, більший за ваш найдовший запит, плюс readiness-проба, яка виводить под із балансування до зупинки.

Як обирати: чекліст

Пройдіть по пунктах згори вниз і зупиніться на першому, що описує вашу ситуацію.

  1. Ви не знаєте, скільки часу займає бутстрап. Зніміть профіль (Excimer, APM, будь-що семплінгове) на реальному ендпоінті. Якщо бутстрап не в топі — жодна зміна моделі виконання вам не допоможе, ідіть до запитів у БД і зовнішніх викликів. Це найчастіший результат.
  2. У кодовій базі є код, який ви не контролюєте — старі пакети, спадщина з глобальними змінними, статичні реєстри без очищення, і немає тестів, щоб це перевірити. Лишайтесь на shared-nothing. FPM або FrankenPHP у класичному режимі — і сон спокійніший.
  3. Вам болить інфраструктура, а не бутстрап: хочеться HTTP/3, автоматичного TLS, роздачі статики й одного бінарника замість двох сервісів. FrankenPHP без worker mode. Модель виконання не змінюється, аудит стану не потрібен.
  4. Бутстрап справді дорогий, кодова база під контролем, є тести. Тоді worker mode. Для Laravel — Octane з драйвером FrankenPHP: ви отримуєте flush/warm, події життєвого циклу, octane:reload і --max-requests з коробки. Для Symfony — runtime/frankenphp-symfony плюс ревізія сервісів на ResetInterface.
  5. Ваш профіль навантаження — довге очікування на зовнішні сервіси. Спершу порахуйте стелю одночасності за кількістю воркерів; можливо, вам потрібні не швидші запити, а винесення викликів у чергу.
  6. Перед вмиканням worker mode на проді пройдіть мінімальний аудит: пошук static властивостей-кешів, синглтонів із Request/Auth/користувачем у конструкторі, викликів putenv/setlocale/ini_set у рантаймі, fastcgi_finish_request(), довгоживучих з'єднань і файлових хендлів. Виставте --max-requests, увімкніть моніторинг RSS процесів і викочуйте на частину трафіку.

І окремо про порівняння цифр: публічні бенчмарки моделей виконання майже завжди міряють синтетичний ендпоінт, де бутстрап — це 100% роботи. У застосунку з базою й зовнішніми викликами частка бутстрапу інша, тому переносити ці співвідношення на свій продукт не можна. Єдина цифра, яка щось означає, — знята на вашому коді до і після, за однакових умов.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 5 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті