<? phpukraine СПІВБЕСІДИ
Пошук по платформі

Питання на співбесіду Senior PHP

Архітектура, памʼять у довгих процесах, ідемпотентність, компроміси й обґрунтування рішень. Питають не факти, а спосіб мислення.

Тема
Рівень
21 питання
LR
Laravel·Senior ·Livewire ·SPA ·Inertia

Вибір визначають три речі, а не смак: чи є другий клієнт (мобілка, партнери) — тоді API все одно доведеться зробити, і SPA стає майже безкоштовним; яка зернистість взаємодій — Livewire платить мережевим round-trip за кожен крок, тому годиться для форм, таблиць і фільтрів, а не для перетягування, канви чи офлайну; і хто це супроводжуватиме. Livewire тримає стан на сервері й ганяє снапшот компонента туди-сюди, SPA тримає стан у браузері й вимагає версіонованого контракту; Inertia — середина, де є клієнтські компоненти, але немає публічного API.

Адмінка на 60 CRUD-екранів, команда — три бекендери й пів фронтендера. Livewire чи Vue з REST?
Кажуть, Livewire повільний, бо кожен клік іде на сервер. Це так, і в чому саме ціна?
Що конкретно летить у браузер, коли в компонента є `public User $user`, і чи можу я це підмінити з devtools?
Livewire чи Inertia — якщо і там, і там немає окремого API, у чому тоді різниця?
У нас уже є мобільний застосунок. Це щось змінює у виборі?

Спершу механіка, бо без неї суперечка зводиться до смаків. Livewire — це серверні компоненти зі станом, який подорожує разом із клієнтом. При першому рендері в HTML вкладається снапшот: JSON з усіма публічними властивостями, іменем класу, ідентифікатором компонента й memo-даними, підписаний HMAC-чексумою від APP_KEY. Кожна взаємодія — це POST на /livewire/update з парою snapshot плюс calls/updates; кілька компонентів на сторінці за замовчуванням батчаться в один запит. Сервер не памʼятає ваш обʼєкт — він створює новий і гідратує його зі снапшота (властивості-моделі зберігаються як клас плюс первинний ключ і перезапитуються з БД), виконує метод, повністю перерендерює Blade і повертає HTML, який клієнтський морфер накладає на наявний DOM. Звідси випливає все інше: застосунок лишається stateless і масштабується звичайними інстансами, але ціна кожної взаємодії — RTT плюс бутстрап фреймворку плюс рендер, а розмір публічних властивостей — це трафік, помножений на кількість кліків.

Протилежний варіант — API плюс SPA — переносить стан у браузер. Сервер віддає JSON через контролери й JsonResource, клієнт має власний роутер, власний кеш і власну модель даних; взаємодії всередині екрана нічого не коштують серверу, зате зʼявляється другий контракт, який треба версіонувати, документувати, тестувати й окремо авторизувати. Автентифікація роздвоюється: laravel/sanctum у SPA-режимі на тому самому домені працює на сесійних куках із CSRF, а мобільний клієнт ходить з токенами. Третій варіант — Inertia — навмисно не є API: контролер повертає Inertia::render('Orders/Index', $props), роутинг і авторизація лишаються на сервері, а Vue чи React рендерять сторінку з props як зі звичайного JSON. Це дає справжні клієнтські компоненти без публічного контракту, але вимагає фронтенд-стека й окремого SSR-процесу (php artisan inertia:start-ssr), якщо потрібен індексований HTML.

Тепер критерії, за якими насправді ухвалюють рішення. Перший і найважливіший: чи існує другий клієнт. Якщо є мобільний застосунок, партнерські інтеграції або публічний API — контракт у кошторисі вже стоїть, і SPA поверх нього коштує додатково небагато; будувати ж API «на майбутнє», якого немає в роадмапі, — це подвоєння роботи без споживача. Другий: зернистість взаємодій. Форми, таблиці, фільтри, майстри, адмінки — усе, де користувач робить дію й чекає результату, — чудово живуть на round-trip. Перетягування, канва, редактор, зум графіка, офлайн і оптимістичні оновлення — не живуть: там кожен рух миші перетворився б на HTTP-запит. Третій: команда й чергування. Три бекендери без фронтенд-компетенції на SPA платитимуть щодня — збіркою, дублюванням типів, розсинхроном контракту; для них Livewire з wire:navigate дає SPA-подібну навігацію без другого стека.

Практика зводиться до кількох дисциплін, які й відрізняють senior-відповідь. У Livewire публічні властивості — це клієнтський контракт: їх видно в devtools і їх можна виставляти через updates, тому ідентифікатори власника позначають #[Locked], секрети туди не кладуть узагалі, а права перевіряють у самій дії (або через #[Authorize]), бо mount() виконується лише при першому рендері. Похідні дані — це #[Computed], а не public: результат кешується в межах запиту й не роздуває снапшот. Кількість запитів зменшують wire:model без .live там, де вистачає синхронізації з сабмітом, .debounce.300ms там, де потрібен живий пошук, #[Renderless] для дій без змін у розмітці, #[Lazy] для важких блоків і острівці Livewire 4 (@island), що перерендерюють частину компонента замість усього дерева. У @foreach обовʼязковий wire:key з реальним ідентифікатором, інакше після сортування морфер зіставить не ті вузли. У SPA-варіанті дисципліни інші: білий список полів у ресурсах, стабільна пагінація, версія в шляху й тести, які ловлять зміну контракту, — деталі цього розібрані в картці про JsonResource.

Межі й компроміси варто назвати вголос, бо на них перевіряють зрілість. Livewire не усуває JavaScript — на сторінці є його рантайм і Alpine, і будь-який складний віджет усе одно доведеться писати руками, загорнувши сторонню бібліотеку в wire:ignore. Livewire множить кількість PHP-запитів на кількість кліків, тож планувати потужність треба від взаємодій, а не від сторінок; персоналізовані POST-відповіді на CDN не кешуються, а Octane прибирає бутстрап, але вимагає чистих статичних станів і синглтонів. SPA, у свою чергу, платить за SEO рендером на сервері, за автентифікацію — двома різними схемами, а за кожну зміну поля — координацією двох релізів. І головне: вибір не бінарний і не остаточний. Типова здорова архітектура — Livewire на CRUD і адмінці, два-три ізольовані клієнтські віджети на Alpine або на власних компонентах із синхронізацією через $wire.entangle, і API рівно для тих споживачів, які справді існують. Якщо на співбесіді ви покажете саме цей шлях міграції — від сторінки до острівця, а не від фреймворку до фреймворку, — питання закрите.

use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Livewire\Attributes\{Computed, Locked, Url};
use Livewire\{Component, WithPagination};

final class OrderList extends Component
{
    use WithPagination;

    // Публічна властивість = частина снапшота: їде в браузер і назад на кожну дію.
    // Без #[Locked] клієнт легально виставляє її через updates у payload —
    // чексума від цього не рятує, вона захищає лише те, що надіслав сервер.
    #[Locked]
    public int $merchantId;

    #[Url(as: 'q', history: true)]
    public string $search = '';

    public function mount(int $merchantId): void
    {
        // Виконується лише при першому рендері: наступні запити гідратують
        // снапшот і одразу йдуть у дію, тому авторизація тут нічого не захищає.
        $this->merchantId = $merchantId;
    }

    public function updatedSearch(): void
    {
        $this->resetPage(); // інакше новий фільтр застосується до сторінки 7
    }

    public function markShipped(int $orderId): void
    {
        $order = Order::where('merchant_id', $this->merchantId)->findOrFail($orderId);
        $this->authorize('ship', $order); // права перевіряємо в дії, а не в mount()
        $order->markShipped();
    }

    // Не властивість: не серіалізується у снапшот і не їде клієнту.
    #[Computed]
    public function orders(): LengthAwarePaginator
    {
        return Order::query()
            ->where('merchant_id', $this->merchantId)
            ->when($this->search !== '', fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
            ->with('customer') // рендер Blade так само породжує N+1
            ->paginate(20);
    }
}

// Blade: <input wire:model.live.debounce.300ms="search">
// .live обовʼязково: з Livewire 3 біндинг відкладений і синхронізується з дією.
Що рішення виводиться з вимог (другий клієнт, зернистість взаємодій, склад команди, вимоги до офлайну й SEO), а не з тези «Livewire простіший».
Розуміння протоколу Livewire: снапшот із публічних властивостей їде в браузер і назад на кожну дію, підписаний HMAC від `APP_KEY`, сервер перерендерює Blade і повертає HTML, який морфиться в DOM.
Що публічні властивості — це клієнтський контракт: їх видно й можна змінювати з payload, тому `#[Locked]`, жодних секретів і авторизація в дії, а не в `mount()`.
Що Inertia — це третій, а не проміжний варіант: серверний роутинг і контролери, клієнтські компоненти, props як JSON і жодного публічного API для підтримки.
Розрахунок навантаження: у Livewire кожна взаємодія — це PHP-запит із бутстрапом фреймворку, тоді як у SPA більшість взаємодій не доходить до сервера; для авторизованих сторінок CDN не рятує ні там, ні там.
Що гібрид — нормальний результат: Livewire на CRUD, Alpine/JS-острівці на важкі віджети, API — лише там, де є справжній споживач.
Продавати Livewire як «фронтенд без JavaScript»: на сторінці все одно є рантайм Livewire і Alpine, а `wire:key` у циклах треба ставити руками, інакше морфінг переплутає рядки після сортування.
Класти в публічну властивість колекцію на тисячі рядків або результат запиту: він серіалізується у снапшот і їде по мережі двічі на кожен клік. Похідні дані — це `#[Computed]`, а не `public`.
Вважати, що чексума снапшота захищає значення: вона захищає від підміни того, що надіслав сервер, але `updates` у payload легально виставляє будь-яку публічну властивість — без `#[Locked]` `merchantId` переписується з консолі.
Перевіряти права в `mount()`: він виконується лише при першому рендері, наступні запити гідратують снапшот і одразу викликають дію — авторизація має бути в самій дії або в `#[Authorize]`.
Будувати повноцінний REST/GraphQL «на випадок майбутньої мобілки», якої немає в роадмапі: це другий контракт, другий набір тестів і другий шар авторизації без жодного споживача.
Обирати SPA заради SEO, а потім місяць прикручувати SSR: Livewire і Inertia з SSR віддають готовий HTML, чистий CSR — ні.
Дивуватися, що `wire:model="search"` не оновлює список: з Livewire 3 біндинг за замовчуванням відкладений, потрібен `wire:model.live` (і `.debounce`, щоб не слати запит на кожну літеру).
Ставити Livewire на високочастотні взаємодії — drag-and-drop, редактор, графік із зумом: кожен рух миші перетворюється на HTTP-запит замість локального стану.
ПОРАДА

Не відповідайте «залежить». Назвіть три критерії вголос і пройдіться по них: (1) чи існує другий клієнт — якщо так, API вже в кошторисі, і SPA дешевшає; (2) чи є взаємодії частіші за клік — якщо так, вони не переживуть round-trip; (3) хто чергує вночі — команда без фронтенд-компетенції платитиме за SPA щодня. І одразу додайте, що вибір не бінарний: Livewire на CRUD плюс два ізольовані JS-віджети — типова, а не компромісна архітектура.

Сторінка питання →
SF
Symfony·Senior ·API Platform ·DTO ·state provider

API Platform виправданий, коли потрібен машиночитний контракт (OpenAPI/JSON-LD), фільтри, пагінація й HTTP-кеш «з коробки»; головна архітектурна зміна — контролер зникає, а його місце займають state provider і processor, тому ресурс має бути окремим DTO, а не Doctrine-сутністю.

У нас 40 CRUD-ендпоінтів на Doctrine-сутностях — навіщо тягнути ще один шар?
Чому в API Platform немає контролерів і де тоді жити бізнес-логіці?
Ми поставили #[ApiResource] на сутність, а тепер не можемо змінити колонку в БД без ламання клієнтів. Що зробили не так?
Чим state provider і state processor відрізняються від DataProvider і DataPersister?

API Platform (4.x, вимагає PHP 8.2+ і Symfony 6.4/7.x) — це не «генератор CRUD», а машина, що з одних метаданих виводить кілька представлень одразу: специфікацію OpenAPI, гіпермедіа-формат JSON-LD/Hydra за замовчуванням, за потреби JSON:API, HAL і GraphQL, а разом з ними — узгоджену пагінацію, фільтри, обробку помилок валідації у форматі RFC 7807 і заголовки кешу. Виправданий він тоді, коли ця машиночитність комусь потрібна: є фронтенд або мобільний клієнт, що генерує собі SDK з OpenAPI; є десятки read-моделей із фільтрами й сортуванням, які інакше довелося б писати руками; є вимога до стабільного, задокументованого контракту. Якщо ж API — це десяток викликів-команд («створити», «підтвердити», «скасувати») і жодної колекції з фасетним пошуком, звичайні контролери плюс Messenger дадуть менше магії й менше шарів.

Головна архітектурна зміна — зникнення контролера як точки розширення. У 3.x і 4.x кожна операція має свій provider (клас із ApiPlatform\State\ProviderInterface::provide()) і processor (ProcessorInterface::process()); вони замінили DataProviderInterface і DataPersisterInterface з 2.x разом із їхнім supports(), бо тепер прив'язка явна, прямо в атрибуті операції. Дефолтні реалізації для Doctrine ORM — ItemProvider, CollectionProvider, PersistProcessor, RemoveProcessor — можна декорувати, коли треба лише додати крок до стандартного шляху. Далі йдуть Doctrine-розширення (QueryCollectionExtensionInterface, QueryItemExtensionInterface), які втручаються у сам QueryBuilder і тому підходять для наскрізних умов на кшталт multi-tenancy. Кастомний controller: в операції теж існує, але він обходить денормалізацію, валідацію й security-експресії, тому це шлях для виняткових відповідей, а не для бізнес-логіки.

Друга зміна — де проходить межа контракту. Найшвидший старт — повісити #[ApiResource] на Doctrine-сутність: фільтри SearchFilter, OrderFilter, RangeFilter, DateFilter через #[ApiFilter] працюють одразу, eager-loading-розширення саме будує join-и за групами серіалізації. Ціна — схема бази стає публічним контрактом: перейменування колонки ламає клієнтів, а групи серіалізації й #[Assert]-обмеження для API розповзаються по доменних класах. Дисципліноване рішення — ресурс як окремий клас у src/Api/Resource, свій provider для читання (аж до простого DBAL-запиту з проєкцією рівно потрібних полів) і processor, що викликає команду домену. Тоді сутність вільно змінюється, а API — ні.

Безпека й продуктивність теж переїжджають у метадані, і це місце типових помилок. security: "is_granted('ROLE_CUSTOMER')" виконується до денормалізації, тому бачить лише користувача й request; усе, що залежить від надісланого тіла, перевіряється в securityPostDenormalize, де доступні object і previous_object. Обмеження видимості колекції краще робити не security-виразом, а розширенням запиту: воно дає 404 замість 403 і не витягує з бази чужий рядок, щоб потім його відкинути. З продуктивністю тримайте в голові дві речі: maxJoins (за замовчуванням 30) обмежує автоматичний eager loading, а COUNT для пагінації на великих таблицях знімається pagination_partial: true або курсорною пагінацією через paginationViaCursor.

Межі варто називати чесно. API Platform не має вбудованого версіонування API — його роблять окремими ресурсами під різними uriTemplate або через content negotiation, і це ручна робота. Дії-команди лягають на HTTP-семантику погано: POST /orders/{id}/confirm з окремим input-DTO працює, але виглядає як вправа на обхід моделі ресурсів. Серіалізація нормалізатором коштує помітного CPU, тому широкі групи на гарячих ендпоінтах доводиться замінювати вузькими read-моделями й HTTP-кешем (cacheHeaders плюс інвалідація через Varnish або Souin). І, нарешті, команді потрібно розуміти сам фреймворк: помилку в конфігурації операції видно не в стектрейсі контролера, а у відповіді, якої ніхто не очікував.

// Ресурс API — окремий DTO, а не Doctrine-сутність: схема БД лишається приватною
#[ApiResource(
    shortName: 'Order',
    operations: [
        new GetCollection(provider: OrderCollectionProvider::class),
        new Get(provider: OrderItemProvider::class, security: "is_granted('ORDER_VIEW', object)"),
        new Post(
            processor: PlaceOrderProcessor::class,
            security: "is_granted('ROLE_CUSTOMER')",       // до денормалізації: перевірка ролі
            validationContext: ['groups' => ['order:create']],
        ),
    ],
    normalizationContext: ['groups' => ['order:read']],
    denormalizationContext: ['groups' => ['order:write']],
    paginationItemsPerPage: 30,
)]
final class OrderResource
{
    #[Groups(['order:read'])]
    public ?string $id = null;

    #[Assert\NotBlank(groups: ['order:create'])]
    #[Groups(['order:read', 'order:write'])]
    public string $sku = '';

    #[Assert\Positive(groups: ['order:create'])]
    #[Groups(['order:read', 'order:write'])]
    public int $quantity = 1;
}

// Processor замінює контролер: HTTP-шар лише передає команду в домен
final readonly class PlaceOrderProcessor implements ProcessorInterface
{
    public function __construct(private PlaceOrder $placeOrder) {}

    /** @param OrderResource $data */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderResource
    {
        $order = $this->placeOrder->handle(new PlaceOrderCommand($data->sku, $data->quantity));
        $data->id = $order->id()->toString();   // повернутий ресурс серіалізується автоматично

        return $data;
    }
}
Що API Platform дає не «CRUD швидше», а генерований контракт: OpenAPI, JSON-LD/Hydra, JSON:API, GraphQL, фільтри, пагінацію, ETag і валідацію з одних метаданих.
Що в 3.x/4.x шар даних це ProviderInterface::provide() і ProcessorInterface::process(), які замінили DataProviderInterface та DataPersisterInterface з 2.x, — контролер більше не точка розширення.
Що #[ApiResource] на Doctrine-сутності перетворює схему БД на публічний контракт, і межа між моделлю персистентності та представленням зникає; на serious-проєкті ресурс роблять окремим класом-DTO.
Що операції описуються поокремо (Get, GetCollection, Post, Patch, Delete) і кожна має власні security, validationContext і групи серіалізації — це і є заміна набору контролерів.
Що межі відомі: складні дії-команди, версіонування API і нестандартні відповіді доводиться робити руками, і в цих випадках звичайний контролер чесніший.
Ставити #[ApiResource] прямо на сутність і потім не мати змоги перейменувати поле в БД без міграції клієнтів.
Писати бізнес-логіку в кастомному контролері операції: у 3.x/4.x правильна точка розширення — processor, а контролер обходить серіалізацію, валідацію й security-експресії.
Плутати security і securityPostDenormalize: перевірку, що залежить від надісланих даних чи від об'єкта після денормалізації, треба робити другим, а не першим.
Обмежувати вибірку в кастомному провайдері замість Doctrine-розширення (QueryCollectionExtensionInterface), через що multi-tenancy не діє на всі операції одразу.
Вважати, що фільтри вмикаються самі: без #[ApiFilter] або #[QueryParameter] параметр у query string просто ігнорується, а не фільтрує.
Не дивитися на кількість запитів: eager-loading-розширення робить join-и автоматично, але має ліміт maxJoins (за замовчуванням 30) і легко ламається на глибоких групах серіалізації.
ПОРАДА

Формулюйте вибір через контракт, а не через швидкість: «API Platform беремо, коли клієнтам потрібен стабільний машиночитний контракт і купа read-моделей з фільтрами; ресурси при цьому — DTO, а сутності лишаються всередині». Одна фраза про DTO-ресурси одразу відрізняє того, хто це підтримував у проді.

Сторінка питання →
ARC
Архітектура·Senior ·DDD ·bounded context ·компроміси

DDD має сенс там, де складність саме в домені: багато правил, які часто змінюються, і потрібна спільна мова з бізнесом; для CRUD-адмінки чи інтеграційного сервісу агрегати й репозиторії додають шарів без вигоди.

Як зрозуміти, що домен достатньо складний для DDD?
Що таке bounded context на прикладі магазину?
Чому папки Domain, Application, Infrastructure ще не DDD?

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

Найкорисніша частина DDD стратегічна. Bounded context — це межа, всередині якої слово має одне значення. Product у каталозі, на складі й у замовленні означає різні речі, і спроба зробити одну модель на всіх дає гігантський клас, який ніхто не розуміє. Три невеликі моделі з явними інтеграціями через події простіші, навіть якщо тактичних патернів у них мінімум.

Шкодить DDD там, де складності немає. CRUD-адмінка, звіти, інтеграційний сервіс, який перекладає один JSON в інший, не потребують агрегатів і репозиторіїв: пʼять класів на одну форму лише сповільнюють команду. Так само шкодить підміна суті формою: папки Domain, Application, Infrastructure без моделі та мови це не DDD, а бюрократія.

Зріла відповідь містить обидва приклади: контекст, де модель окупилась, і контекст, де від DDD свідомо відмовились на користь транзакційного скрипта й Eloquent. DDD можна застосовувати до одного складного контексту всередині звичайного застосунку, і саме так він найчастіше працює.

// Складний контекст: правила живуть у моделі, мова та сама, що в бізнесу
final class Subscription
{
    private function __construct(
        public private(set) SubscriptionId $id,
        private Plan $plan,
        private Period $period,
        private SubscriptionStatus $status,
    ) {}

    public function renew(Clock $clock): void
    {
        if ($this->status === SubscriptionStatus::Cancelled) {
            throw new CannotRenewCancelledSubscription($this->id);
        }
        if ($this->period->endsAfter($clock->now()->modify('+14 days'))) {
            throw new RenewalTooEarly($this->id, $this->period);   // правило з договору, не з таблиці
        }
        $this->period = $this->period->extendBy($this->plan->billingCycle());
        $this->record(new SubscriptionRenewed($this->id, $this->period));
    }
}

// Простий контекст поруч: CRUD без агрегатів, і це свідомий вибір
final class TagController
{
    public function store(Request $request): RedirectResponse
    {
        Tag::create($request->validate(['name' => ['required', 'max:50']]));
        return back();
    }
}

// Межа контекстів: той самий «продукт», різні моделі, інтеграція через подію
// Catalog\Product   — опис, фото, SEO
// Warehouse\StockItem — sku, залишок, комірка
// Orders\OrderLine   — назва й ціна, зафіксовані на момент покупки
Що цінність DDD у моделі й ubiquitous language, а не в структурі каталогів або в наборі патернів Entity, Repository, Value Object.
Критерії доречності: правила, які змінюються з бізнесом, кілька команд або підсистем із різними значеннями одних слів, довгий горизонт життя продукту.
Що стратегічна частина, тобто bounded contexts і context map, дає найбільше навіть без тактичних патернів, і що межі контекстів визначаються мовою, а не таблицями.
Приклад, де DDD не застосували свідомо: адмінка, звіти, інтеграція, і чому анемічна модель з транзакційним скриптом там була правильним вибором.
Розуміння ціни: більше коду, повільніший старт, потреба в дисципліні команди, і що DDD можна застосовувати до одного контексту, а не до всього застосунку.
Ототожнювати DDD зі структурою папок або з гексагональною архітектурою.
Застосовувати агрегати й репозиторії до CRUD і отримувати пʼять класів на одну форму.
Робити один величезний домен без bounded contexts, де Product означає одне для складу й інше для каталогу.
Проєктувати агрегати від таблиць бази, а не від інваріантів, і отримувати гігантський Order з усіма звʼязками.
Вважати, що DDD вимагає CQRS, event sourcing і мікросервіси.
ПОРАДА

Найсильніша відповідь — приклад, де ви свідомо не застосували DDD і чому це було правильно. Другий за силою: приклад слова, яке в двох контекстах означало різне.

Сторінка питання →
WP
WordPress·Senior ·headless ·REST API ·WPGraphQL

Headless виправданий, коли контент споживає більше ніж один клієнт або команда вже живе в JS-екосистемі; платити за це доводиться прев'ю, редиректами, формами, CSS блоків, меню, SEO-мета й власною інвалідацією кешу — усім, що раніше безкоштовно робила тема.

Замовник хоче Next.js поверх WordPress — які аргументи ви наведете за і проти?
Чому після переїзду на headless редактори скаржаться, що прев'ю не працює?
Ми зробили headless, а сторінки виглядають зламано — чому CSS блоків не застосувався?
Хто інвалідує кеш фронтенду, коли редактор змінює пост?

Headless WordPress означає, що тема більше не рендерить сторінки: WP лишається редакторським інтерфейсом і сховищем, а HTML будує окремий застосунок, який тягне дані через REST (ядро, з WP 4.7) або WPGraphQL. Ключове рішення тут не технічне, а продуктове — скільки різних клієнтів споживає той самий контент. Якщо клієнт один і це сайт, ви обмінюєте готовий рендер на власний і майже нічого не отримуєте. Якщо клієнтів кілька (веб, мобільний застосунок, екрани в залі, партнерська стрічка), або продукт — інтерактивний застосунок, де WP лише одне з джерел даних, або команда фронтенду вже живе в React і має дизайн-систему, — розділення окупається.

Аргумент «headless заради швидкості» майже завжди хибний. Повільність типового WP створюють запити в WP_Query, autoload-опції та десяток плагінів на кожен хіт, і все це нікуди не зникає — воно просто переїжджає з рендера сторінки в рендер JSON. Різниця в тому, що перед темою повносторінковий кеш ставиться однією настройкою, а перед API його треба проєктувати: REST-відповіді залежать від автентифікації, per_page обмежений сотнею, а без _fields кожен пост тягне за собою рендерений контент, мета й _links. Перш ніж пропонувати headless, чесніше показати профіль запитів і спробувати кеш на рівні edge.

Приховані витрати — це рівно те, що безкоштовно робила тема. Прев'ю чернеток вимагає автентифікованих запитів (Application Passwords з WP 5.6 або JWT) і роботи з wp/v2/posts/{id}/autosaves, бо незбережені зміни лежать саме там. CSS блоків генерується на сервері функціями wp_get_global_stylesheet() і wp_enqueue_block_style() (обидві з WP 5.9), а wp_head() на фронтенді ніхто не викличе. Меню через wp/v2/menus анонімно не читаються: ендпоінти вимагають edit_theme_options. Редиректи плагінів застосовуються на template_redirect, якого немає. Форми, шорткоди, srcset, related posts, канонічні URL і _wp_old_slug_redirect — той самий список. Кожен пункт треба або відтворити, або свідомо викинути.

Окремо живе інвалідація. У класичному WP кеш чистило саме ядро й плагін кешування; у headless це ваш код: transition_post_status або save_post → вебхук на фронтенд, який робить revalidatePath/revalidateTag (Next.js App Router, 13.4+). Пастка не в самому вебхуку, а в повноті: зміна поста впливає на його сторінку, на архіви, на головну, на сусідні пости в «схожих», іноді на меню. Тому інвалідують не URL, а теги, і запит роблять неблокуючим чи через wp_cron, інакше кожне збереження в редакторі затримується на час відповіді фронтенду.

Є ще операційна ціна: два деплої, два середовища, два набори логів і два домени. Бекенд лишається повноцінним сайтом, який віддає ті самі тексти за власними URL, тож його закривають від індексації (X-Robots-Tag: noindex, basic auth, обмеження за IP) — інакше отримаєте дублі контенту. Для SEO треба одне джерело правди: або фронтенд рахує мета-теги сам, або бере yoast_head_json з Yoast (з 14.x) і не сперечається з ним. І окремо — команда: підтримувати headless без постійного фронтенд-розробника не вийде, а більшість «просто контентних» сайтів такого розробника не мають. Проміжні варіанти теж існують: блочна тема з theme.json для контентної частини плюс окремий SPA-розділ для інтерактивного, — і для багатьох проєктів це чесніша відповідь, ніж повний headless.

// 1. Інвалідація фронтенду: тему знято, тож кеш чистить хук, а не WP
add_action('transition_post_status', function (string $new, string $old, WP_Post $post): void {
    if ($new !== 'publish' && $old !== 'publish') {
        return; // чернетка → чернетка фронтенду не стосується
    }
    if (wp_is_post_revision($post->ID) || $post->post_type === 'nav_menu_item') {
        return;
    }
    // HTTP-запит просто в хуку сповільнить збереження в редакторі — відкладаємо
    wp_schedule_single_event(time(), 'shop_revalidate', [get_permalink($post), $post->post_type]);
}, 10, 3);

add_action('shop_revalidate', function (string $url, string $type): void {
    wp_remote_post(SHOP_FRONT_URL.'/api/revalidate', [
        'timeout' => 5,
        'blocking' => false,
        'headers' => ['X-Revalidate-Secret' => SHOP_REVALIDATE_SECRET],
        // Окрім самої сторінки — архіви й головна, інакше список лишиться старим
        'body' => wp_json_encode([
            'path' => wp_parse_url($url, PHP_URL_PATH),
            'tags' => ['home', "archive:{$type}"],
        ]),
    ]);
}, 10, 2);

// 2. CSS блоків генерує сервер: без wp_head() фронтенд його не отримає
add_action('rest_api_init', function (): void {
    register_rest_route('shop/v1', '/block-styles', [
        'methods' => 'GET',
        'permission_callback' => '__return_true', // публічні стилі, не дані
        'callback' => static fn (): array => [
            // з WP 5.9: змінні й пресети з theme.json + стилі блоків
            'css' => wp_get_global_stylesheet(['variables', 'presets', 'styles']),
        ],
    ]);
});
Що headless — це рішення про кількість споживачів контенту, а не про швидкість: звичайний сайт майже завжди швидше вивести повносторінковим кешем перед PHP, ніж переписати фронтенд.
Що разом з темою зникає весь її функціонал: прев'ю чернеток, шорткоди, форми, related posts, srcset, меню, канонічні URL і `wp_head()` — кожен пункт треба відтворити руками.
Що прев'ю потребує автентифікації (Application Passwords з WP 5.6 або JWT) і читання `wp/v2/posts/{id}/autosaves`, бо незбережені зміни редактора живуть саме в автозбереженні, а не в пості.
Що CSS блоків генерує сервер: `wp_get_global_stylesheet()` і `wp_enqueue_block_style()` (обидва з WP 5.9) на фронтенді ніхто не викличе, тому стилі треба віддавати окремим ендпоінтом або збирати на боці JS.
Що інвалідацію кешу тепер пише розробник: хук `transition_post_status` → вебхук на фронтенд (`revalidatePath`/`revalidateTag` у Next.js App Router 13.4+), і що забути про батьківські сторінки, архіви та меню — типово.
Що бекенд лишається публічним сайтом і його треба закрити від індексації, інакше отримаєте дублі контенту на двох доменах.
Аргументувати headless продуктивністю, не спробувавши повносторінковий кеш і CDN: більшість «повільних» WP гальмують не через рендер теми.
Планувати бюджет на API-шар і забути про прев'ю та ролі редакторів — саме на цьому проєкти зупиняються після здачі.
Вважати, що `wp/v2/menus` віддасть меню анонімно: ендпоінти меню (з WP 5.9) вимагають капабіліті `edit_theme_options`, публічного читання там немає.
Тягнути `wp/v2/posts` без `_fields` і дивуватись мегабайтним відповідям, а потім упиратись у ліміт `per_page=100`.
Забувати, що `content.rendered` уже містить розгорнуті шорткоди й oEmbed, а `content.raw` — ні, і рендерити на фронтенді сирий HTML із `[gallery]` всередині.
Лишати два джерела правди для SEO: фронтенд генерує свої title/canonical, а Yoast віддає `yoast_head_json` — і вони розходяться.
ПОРАДА

Сформулюйте критерій одним реченням: headless окупається, коли споживачів контенту більше одного (сайт + мобільний застосунок + екрани в залі) або коли продукт — це застосунок, а WP у ньому лише CMS. Для контентного сайту з блогом чесніше сказати «ні» і показати, скільки коштує відтворити прев'ю, форми й меню.

Сторінка питання →
SQL
SQL·Senior ·партиціонування ·PostgreSQL ·MySQL

Партиціонування потрібне тоді, коли дані природно ріжуться по ключу (найчастіше по часу) і старі шматки треба видаляти чи архівувати цілком: DROP/DETACH PARTITION — це операція над метаданими замість мільйонів DELETE. Прискорення читання — побічний ефект, і лише коли ключ партиціонування є в WHERE.

Таблиця подій виросла до 400 млн рядків, DELETE старих даних не встигає за ніч — що робите?
Чи прискорить партиціонування наші SELECT-и?
Як перевести робочу таблицю на партиції без вікна простою?
Скільки партицій — це забагато?

Партиціонування розбиває одну логічну таблицю на набір фізичних таблиць за значенням ключа: у PostgreSQL це декларативні PARTITION BY RANGE | LIST | HASH (з версії 10), у MySQL — RANGE, RANGE COLUMNS, LIST, HASH, KEY на нативній реалізації InnoDB (з 8.0 інші рушії партиціонування не підтримують). Планувальник під час планування й виконання відкидає партиції, які не можуть містити потрібних рядків — це partition pruning, і саме тут головне непорозуміння: прунінг вимагає, щоб ключ партиціонування був у WHERE чи в умові з’єднання. Запит WHERE user_id = 42 на таблиці, партиціонованій по created_at, читає всі партиції і працює повільніше, ніж на непартиційованій таблиці з нормальним індексом.

Тому справжній мотив партиціонування — не читання, а життєвий цикл даних і обслуговування. Видалення місяця подій через ALTER TABLE ... DETACH PARTITION у PostgreSQL або ALTER TABLE ... DROP PARTITION у MySQL — це операція над метаданими й табличним простором, вона не породжує мільйони версій рядків, не роздуває WAL/undo, не створює лагу реплік і не залишає по собі bloat, який доводиться прибирати pg_repack. Побічно менші стають і VACUUM, ANALYZE, перебудова індексів: вони йдуть по одній партиції, а не по терабайтній таблиці. Плюс холодні партиції можна тримати на дешевшому табличному просторі або взагалі винести з бази.

Плата за це — обмеження й обовʼязкова автоматизація. У PostgreSQL ключ партиціонування має входити в первинний ключ і в кожен UNIQUE, глобальних унікальних індексів немає; зовнішні ключі на партиційовану таблицю зʼявилися лише в PG 12. У MySQL ключ теж мусить входити в усі унікальні ключі, а зовнішніх ключів на партиційованих InnoDB-таблицях немає взагалі. Партиції на майбутнє треба створювати наперед — планувальником, командою Artisan або pg_partman; інакше вставка або впаде, або осяде в DEFAULT-партиції, після чого підключення нової партиції з тим самим діапазоном вимагатиме сканування дефолтної під блокуванням.

Операційна частина архівації виглядає так. Відчепити партицію потрібно неблокуючим способом: DETACH PARTITION ... CONCURRENTLY (PG 14+, поза транзакційним блоком; при перерві залишає стан detach pending, який довершує FINALIZE). Далі відчеплена таблиця — звичайна таблиця: COPY у Parquet/CSV на обʼєктне сховище, перевірка контрольних сум і DROP TABLE. У MySQL аналог — EXCHANGE PARTITION WITH TABLE, щоб вийняти дані в окрему таблицю без копіювання. Обидві операції все одно чекають на метадані-блокування за довгими читаннями, тож нічний скрипт запускають з lock_wait_timeout (MySQL) або lock_timeout (PostgreSQL) і ретраями, а не «наосліп».

Межа застосовності проста: якщо немає ключа, по якому дані і фільтруються, і застарівають, партиціонування додасть складності без вигоди — краще індекси, keyset-пагінація і чистка пачками через pt-archiver. Партиціонування також не є шардингом: воно живе в межах одного сервера і не рятує від нестачі IOPS чи обʼєму, а лише робить обслуговування великої таблиці передбачуваним.

-- PostgreSQL 14+: розбиття по діапазону часу
CREATE TABLE events (
    id          bigserial,
    occurred_at timestamptz NOT NULL,
    payload     jsonb NOT NULL,
    PRIMARY KEY (id, occurred_at)   -- ключ партиціонування має бути в PK
) PARTITION BY RANGE (occurred_at);

CREATE TABLE events_2026_09 PARTITION OF events
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- Прунінг спрацює лише за наявності occurred_at у WHERE
EXPLAIN SELECT count(*) FROM events WHERE occurred_at >= '2026-09-01';

-- Архівація місяця: відчіпляємо без довгого блокування (не в транзакції)
ALTER TABLE events DETACH PARTITION events_2026_03 CONCURRENTLY;
-- відчеплена таблиця живе окремо: вивантажити в холодне сховище і прибрати
DROP TABLE events_2026_03;          -- метадані, а не мільйони DELETE

-- Підключення історичної таблиці без повного сканування під блокуванням
ALTER TABLE events_2026_02 ADD CONSTRAINT events_2026_02_rng
    CHECK (occurred_at >= '2026-02-01'
       AND occurred_at <  '2026-03-01') NOT VALID;
ALTER TABLE events_2026_02 VALIDATE CONSTRAINT events_2026_02_rng;
ALTER TABLE events ATTACH PARTITION events_2026_02
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- MySQL 8.4: та сама ідея, ключ мусить входити в кожен унікальний ключ
-- ALTER TABLE events DROP PARTITION p2026_03;
-- SET SESSION lock_wait_timeout = 5;  -- щоб не висіти за довгим читанням
Що головний мотив — дешеве видалення й обслуговування (DROP PARTITION замість DELETE, VACUUM та REINDEX по одній партиції, бекап лише активної), а не магічне прискорення запитів.
Що прунінг партицій працює лише коли ключ партиціонування є в WHERE або JOIN; без нього план читає всі партиції, і стає гірше, ніж було.
Обмеження на ключі: у PostgreSQL ключ партиціонування має входити в PRIMARY KEY і будь-який UNIQUE; у MySQL — у кожен унікальний ключ, а зовнішні ключі на партиційованих InnoDB-таблицях не підтримуються.
Операційний план архівації: у PostgreSQL 14+ `DETACH PARTITION ... CONCURRENTLY`, вивантаження відчепленої таблиці й `DROP TABLE`; у MySQL — `ALTER TABLE ... DROP PARTITION` або `EXCHANGE PARTITION`.
Що партиції треба створювати наперед (планувальник, pg_partman), інакше вставка в неіснуючий діапазон падає або втрапляє в DEFAULT-партицію, з якої її потім важко дістати.
Якщо партиціонування не варіант — видалення пачками по keyset-діапазону з паузами, з оглядом на реплікаційний лаг, а не один гігантський DELETE.
Партиціонувати «щоб SELECT був швидший», коли проблему вирішує звичайний індекс: партиціонування не замінює індекс і не зменшує кількість читаних рядків, якщо ключ не у WHERE.
Партиціонувати по `created_at`, а фільтрувати в запитах по `user_id`: прунінгу немає, планувальник обходить сотні партицій, латентність зростає.
Робити партицію на день на п’ять років уперед: тисячі партицій роздувають планування, `pg_class` і памʼять під час prepared statements.
Чистити історію одним `DELETE FROM events WHERE created_at < now() - interval '1 year'`: довга транзакція, розпухання undo/WAL, лаг реплік, а в PostgreSQL місце все одно не повертається без VACUUM FULL чи pg_repack.
Викликати `ATTACH PARTITION` на великій таблиці без заздалегідь провалідованого CHECK — база сканує її цілком під блокуванням.
Забути, що `DROP PARTITION` у MySQL і `DETACH` у PostgreSQL чекають на завершення довгих читань: без `lock_wait_timeout`/`statement_timeout` нічний скрипт стає інцидентом.
ПОРАДА

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

Сторінка питання →
PHP
Core PHP·Senior ·WeakMap ·WeakReference ·памʼять

WeakReference (PHP 7.4+) і WeakMap (PHP 8.0+) тримають обʼєкт, не збільшуючи його refcount: як тільки обʼєкт знищується, посилання стає null, а запис у WeakMap зникає сам — це штатний спосіб робити привʼязані до обʼєктів кеші й реєстри в довгоживучих процесах.

Ви кешуєте розраховані права в масиві за spl_object_id — що з цим не так у воркері Octane?
Чим WeakMap відрізняється від SplObjectStorage?
Як привʼязати дані до обʼєкта, не подовживши йому життя?
Чому запис у WeakMap не зник, хоча я зробив unset() ключа?

Основа памʼяті в PHP — підрахунок посилань: кожен обʼєкт має refcount, і щойно він падає до нуля, обʼєкт знищується негайно, з викликом __destruct(). Будь-яка звичайна змінна, властивість чи елемент масиву, що вказує на обʼєкт, цей лічильник збільшує — тобто утримує обʼєкт живим. Слабке посилання — це виняток із правила: WeakReference::create($obj) (PHP 7.4+) запамʼятовує обʼєкт, не чіпаючи refcount. Поки на обʼєкт є хоч одне сильне посилання, $weak->get() повертає його; коли останнє зникає, обʼєкт знищується за звичайним сценарієм, а get() починає повертати null. До 7.4 це вміла лише PECL-розширення weakref, тож на старих проєктах ви цього API не побачите.

WeakMap (PHP 8.0+) — надбудова над тією ж ідеєю: мапа, де ключ — обʼєкт (тільки обʼєкт, скаляр дасть TypeError), значення — будь-що, і слабким є саме ключ. Клас реалізує ArrayAccess, Countable та IteratorAggregate, тому працює як звичайний масив: $map[$obj] = $data, isset($map[$obj]), count($map), foreach ($map as $obj => $data). Головна властивість — запис видаляється автоматично разом зі знищенням ключа, без жодного коду очищення з вашого боку. Це те, чого не дають альтернативи: SplObjectStorage теж мапить обʼєкти на дані, але тримає ключі сильно, а масив, індексований spl_object_id(), обʼєкт не тримає, зате накопичує мертві записи й ризикує колізією — ідентифікатор знищеного обʼєкта PHP може видати новому, і кеш віддасть чужі дані.

Практичний сенс зʼявляється там, де процес живе довго: Octane, RoadRunner, Swoole, queue:work у режимі демона, довгі CLI-команди, ReactPHP/Amp. У класичному FPM-запиті все одно все звільняється наприкінці, тому витік у пер-обʼєктному кеші там непомітний; у демоні той самий статичний масив росте від першого запиту до перезапуску воркера. Канонічний сценарій для WeakMap — метадані, привʼязані до обʼєкта і не потрібні довше за нього: обчислені права користувача, memoized-результат дорогого методу для конкретної сутності, стан гідратації або ознака «цей обʼєкт уже провалідовано» у межах одного job. У прикладі PermissionResolver кеш живе рівно стільки, скільки живе $user: count() сам падає до нуля, коли воркер відпускає сутність.

Слабкість односпрямована, і на цьому найчастіше спотикаються. WeakMap тримає значення сильно, тому будь-який шлях від значення назад до ключа перетворює слабкий запис на вічний: $map[$order] = ['owner' => $order] з останнього блоку коду означає ланцюжок WeakMap → масив → $order, і unset($order) уже нічого не змінює. Лікується це зберіганням даних без зворотного посилання або загорнутим WeakReference усередині значення. Друга межа — цикли: якщо обʼєкт-ключ входить у циклічний граф (батько тримає дитину, дитина тримає батька), refcount ніколи не досягне нуля сам, і запис зникне лише після проходу збирача циклів, тож у тестах перед перевіркою count() варто викликати gc_collect_cycles(). Третя — обидва класи не серіалізуються: serialize() на них кине виняток, тому в сесію, кеш чи payload черги вони не потрапляють.

Нарешті, варто чесно назвати, чим WeakMap не є. Це не кеш із витісненням: у нього немає ані TTL, ані ліміту розміру, ані LRU — єдиний критерій видалення це смерть ключа. Якщо обʼєкт живе весь процес (синглтон із контейнера, case enum, зареєстрований слухач), WeakMap не дасть нічого, крім зайвої непрямості, і звичайна властивість буде чеснішою. І це не заміна аналізу витоків: якщо памʼять воркера росте, спершу треба знайти, хто тримає посилання (php-memprof, підрахунок живих обʼєктів у контрольних точках, memory_get_usage(true) і gc_status() у метриках), і лише потім вирішувати, чи справді проблемне місце — це реєстр, якому слабких ключів вистачить.

final class PermissionResolver
{
    /** @var WeakMap<User, list<string>> ключ тримається слабко */
    private WeakMap $cache;

    public function __construct(private readonly PermissionStorage $storage)
    {
        $this->cache = new WeakMap;          // PHP 8.0+
    }

    /** @return list<string> */
    public function for(User $user): array
    {
        // ??= працює через ArrayAccess: offsetExists → offsetGet → offsetSet
        return $this->cache[$user] ??= $this->storage->load($user->id);
    }

    public function cachedCount(): int
    {
        return count($this->cache);          // WeakMap реалізує Countable
    }
}

$resolver = new PermissionResolver($storage);
$user = new User(42);
$resolver->for($user);
echo $resolver->cachedCount();               // 1

// WeakReference (PHP 7.4+) не інкрементує refcount; new заборонено
$weak = WeakReference::create($user);
unset($user);                                // зникло останнє сильне посилання

var_dump($weak->get());                      // NULL — обʼєкт уже знищено
echo $resolver->cachedCount();               // 0 — запис пішов разом із ключем

// Пастка: значення тримає ключ, слабкість працює лише в один бік
$map = new WeakMap;
$order = new Order;
$map[$order] = ['owner' => $order];          // WeakMap → значення → ключ
unset($order);
echo count($map);                            // 1 — памʼять не звільниться
Що слабке посилання не інкрементує refcount: обʼєкт звільняється в момент, коли зникає останнє *сильне* посилання, а слабке після цього просто перестає працювати.
Що WeakMap — це мапа «обʼєкт → будь-яке значення» зі слабким ключем: запис видаляється автоматично разом із ключем, тому `count($map)` зменшується сам, без вашого коду очищення.
Що SplObjectStorage і звичайний масив, індексований `spl_object_id()`, цю задачу не розвʼязують: перший тримає обʼєкти сильно, другий накопичує мертві записи, а ідентифікатори ще й перевикористовуються після знищення обʼєкта.
Що проблема стає видимою саме в довгих процесах — Octane, RoadRunner, Swoole, `queue:work` у режимі демона, ReactPHP/Amp, — бо між запитами процес не помирає і статичні реєстри ростуть вічно.
Що значення у WeakMap тримається сильно: якщо воно (прямо чи через ланцюжок) посилається на ключ, запис не звільниться ніколи — слабкість односпрямована.
Що WeakMap не скасовує збирач циклів: якщо ключ у циклічному графі, запис зникне лише після проходу gc_collect_cycles, а не миттєво.
Казати «WeakMap — це кеш, який сам чиститься за LRU/TTL»: він нічого не витісняє за розміром чи часом, єдиний критерій — смерть ключа.
Класти у WeakMap рядок або int як ключ: `$map['user-42'] = ...` дає TypeError, ключем може бути тільки обʼєкт.
Робити `new WeakReference($obj)` — пряма інстанціація заборонена, є лише `WeakReference::create($obj)`.
Зберігати у значенні WeakMap сам обʼєкт-ключ (`$map[$o] = ['owner' => $o]`) і дивуватись, що памʼять не звільняється: значення тримає ключ живим.
Використовувати WeakMap для обʼєктів, які й так живуть весь процес — синглтонів із контейнера, case-ів enum, зареєстрованих слухачів: слабкість там не дає нічого, крім накладних витрат.
Вважати `unset($obj)` гарантією звільнення: якщо на обʼєкт лишилось інше сильне посилання (у логері, у події, у замиканні зі звʼязаним `$this`), `WeakReference::get()` і далі поверне обʼєкт.
ПОРАДА

Формулюйте через refcount: «сильне посилання каже обʼєкту жити, слабке — лише питає, чи він ще живий». Далі одне речення практики: у довгому воркері метадані, привʼязані до обʼєкта, тримають у WeakMap, бо масив за `spl_object_id` там перетворюється на витік із перевикористаними ключами.

Сторінка питання →
ARC
Архітектура·Senior ·модульний моноліт ·мікросервіси ·межі модулів

Модулі й сервіси розділяє не код, а межа процесу: усередині моноліта виклик модуля — це синхронний метод у спільній транзакції, між сервісами — мережа з таймаутами, частковими збоями й eventual consistency. Тому за замовчуванням беруть модульний моноліт із твердими межами, а окремий сервіс виділяють лише там, де є конкретна причина — незалежний профіль навантаження, окремий цикл релізу під окрему команду або інший рантайм — і платять за неї розподіленими транзакціями та експлуатацією.

У нас моноліт на 300 тисяч рядків і деплой раз на тиждень. Це вже привід переходити на мікросервіси?
Як ви зрозумієте, що конкретний модуль пора виносити в окремий сервіс?
Ми розпиляли моноліт на вісім сервісів, і тепер релізимо їх усі разом однією командою. Що ми зробили не так?
Чим межа модуля в моноліті відрізняється від межі мікросервісу, якщо в обох випадках це просто інтерфейс?

Різниця між модулем і сервісом лежить не в коді, а в межі процесу. Усередині моноліта звертання одного модуля до іншого — це виклик методу: він або відбувся, або кинув виняток, він синхронний, він швидкий і, найважливіше, він може бути в тій самій транзакції, що й локальний запис. Щойно та сама межа стає мережевою, зникають усі три властивості одразу. Зʼявляється третій стан «невідомо» (запит пішов, відповідь не повернулася, але операція могла виконатися), зʼявляється затримка, яка складається вздовж ланцюжка, і зникає спільний COMMIT. Тому питання «моноліт чи мікросервіси» — це не питання про чистоту архітектури: чисті межі, порти, окремі схеми даних і заборона крос-модульних залежностей цілком досяжні в одному деплої. Це питання про те, чи готові ви заплатити розподіленою узгодженістю за незалежність деплою й масштабування.

Звідси випливає й порядок дій. За замовчуванням береться модульний моноліт: один деплой, кілька модулів із вузьким публічним контрактом, у кожного свої таблиці, звертання одне до одного тільки через порт. Ключове тут — межі мають бути машинно перевіреними, інакше вони не витримають і кварталу. qossmic/deptrac описує шари й дозволені залежності у deptrac.yaml і завалює CI на порушенні; PHPArkitect і arch-тести Pest роблять те саме в термінах правил на неймспейси. Другий рівень захисту — дані: у Doctrine це окремі entity manager-и на модуль, у Laravel — окремі connection-и або принаймні тверда заборона на звʼязки Eloquent між модулями. Без цього перший же звіт із JOIN на чужі таблиці зшиває модулі назад, і «межа» лишається тільки в теці.

Сигнал до виділення сервісу завжди про різницю, а не про розмір. Різний профіль ресурсів: модуль рендерингу PDF або обробки відео тримає CPU й памʼять, і через нього доводиться масштабувати весь застосунок. Різний цикл релізу: команда готова релізити щодня, але стоїть у спільній черзі, і це вимірюється в днях затримки, а не у відчуттях. Інший рантайм: довгоживучий процес, WebSocket, інша версія PHP чи взагалі інша мова. Ізоляція ризику й даних: платіжна частина з окремим доступом і аудитом. Різна вимога до доступності: нестабільна зовнішня інтеграція не має класти основний трафік. «Кодова база велика» і «деплой довгий» у цей список не входять — обидва лікуються модулями, тестами й пайплайном, значно дешевше за мережу між класами.

Ціна розділення концентрується в одному місці: транзакція, що охоплювала два модулі, більше не існує. Двофазний коміт у типовому PHP-стеку не розглядають — практичної підтримки ні в PDO, ні в чергах немає. Замість нього — outbox: у тій самій транзакції, що й локальний запис, зберігається повідомлення, а окремий процес доставляє його далі. У Symfony Messenger цю роль грає доктрин-транспорт, який пише в messenger_messages тим самим зʼєднанням, а messenger:consume забирає; у Laravel — драйвер database разом із after_commit у config/queue.php або ->afterCommit() на джобі, щоб подія не поїхала раніше за COMMIT. Далі приймальна сторона має бути ідемпотентною за ключем повідомлення, а сценарій — мати компенсацію на випадок, коли другий крок не вдався. Якщо на етапі проєктування виявляється, що операція мусить бути атомарною, це найкраща з можливих новин: межу сервісу тут проводити не можна, і ви дізналися це до, а не після винесення.

Найдорожча помилка в цій темі — розподілений моноліт: сервіси є, а незалежності немає. Ознаки перевіряються за півгодини: чи можна зарелізити один сервіс без інших; чи ділять вони базу або міграції; чи ходять один в одного синхронним ланцюжком (три сервіси по 99.9% дають уже близько 99.7% на ланцюжку, і це помітно); чи піднімається локально один сервіс без усього стека. Якщо відповіді погані, правильний рух — не додавати сервісів, а зливати назад ті, що ділять дані й цикл релізу. Коли ж рішення ділити ухвалено свідомо, робиться це поступово, у стилі strangler fig: спершу єдиний порт на всі звертання, потім переведення того, що можна, на асинхронний обмін і відділення даних, потім версійований контракт із таймаутами, ретраями й ідемпотентними ключами, і лише в кінці — перенесення реалізації в окремий деплой, спершу на частині трафіку. Кожен крок має цінність сам по собі, і на будь-якому з них можна зупинитися, якщо виявиться, що причини для окремого процесу насправді не було.

// Публічний контракт модуля Billing: єдина точка входу для інших модулів.
// Решта класів Billing для них закрита — це перевіряє deptrac у CI.
interface Invoices
{
    public function issue(IssueInvoice $command): InvoiceId;
}

// Сьогодні: виклик у тому самому процесі й у тій самій транзакції.
final readonly class LocalInvoices implements Invoices
{
    public function __construct(private IssueInvoiceHandler $handler) {}

    public function issue(IssueInvoice $command): InvoiceId
    {
        return ($this->handler)($command);
    }
}

// Завтра, якщо Billing поїде окремим деплоєм, змінюється лише ця реалізація
// і біндинг у контейнері. Код модуля Hiring не чіпаємо взагалі.
final readonly class HttpInvoices implements Invoices
{
    public function __construct(private PendingRequest $http) {}

    public function issue(IssueInvoice $command): InvoiceId
    {
        // Мережа приносить те, чого не було в методі: таймаут, ретрай, ідемпотентність
        $response = $this->http
            ->timeout(3)                 // краще швидка помилка, ніж зависла черга воркерів
            ->retry(2, 200)              // ретрай безпечний лише завдяки ключу нижче
            ->withHeader('Idempotency-Key', $command->invoiceId) // ULID генерує викликач
            ->post('/invoices', ['order_id' => $command->orderId]);

        return InvoiceId::fromString($response->throw()->json('id'));
    }
}

// Чого цей код не ховає: транзакції, що охоплювала обидва модулі, більше немає.
// Якщо виклик мусив бути атомарним із локальним записом — потрібен outbox
// і компенсація, і саме тут вирішується, чи проходить межа сервісу в цьому місці.
Що мікросервіси — це рішення про межу процесу й деплою, а не про якість коду: чисті межі досяжні в моноліті, і саме там їх треба спершу зробити.
Що причини для виділення сервісу називаються конкретно й перевіряються цифрами: різний профіль масштабування, різний цикл релізу під окрему команду, інший рантайм чи вимоги ізоляції — а не «моноліт великий».
Що ціна розділення — це втрата транзакції між модулями: там, де був один COMMIT, зʼявляються saga, outbox, ідемпотентні хендлери й компенсації.
Що межі всередині моноліта мають бути машинно перевірені (deptrac, PHPArkitect, arch-тести Pest), інакше «модулі» — це просто теки, і за рік вони зростуться.
Що кандидат розуміє антипатерн розподіленого моноліта: сервіси, які релізяться разом і ходять один в одного синхронно ланцюжком, дають ціну мікросервісів без їхньої користі.
Що виносити треба через контракт і strangler-підхід: спершу єдина точка входу в модуль і асинхронний обмін, потім винесення реалізації, а не «перепишемо на сервіси за квартал».
Називати причиною розміру кодової бази або довгий деплой: і те, і те лікується модулями, тестами й пайплайном, а не мережею між класами.
Ділити за технічними шарами («сервіс API», «сервіс бази», «сервіс нотифікацій») замість бізнес-можливостей: тоді будь-яка фіча зачіпає всі сервіси одразу.
Вважати, що спільна база між сервісами — прийнятний компроміс: щойно два деплої пишуть в одну таблицю, це один сервіс із двома процесами і подвійним ризиком міграцій.
Обіцяти «просто загорнемо в HTTP» і не закласти таймаут, ретрай та ідемпотентність: перший же лаг сусіда кладе весь ланцюжок запитів.
Розраховувати на розподілену транзакцію (2PC) через PDO чи чергу: у типовому PHP-стеку її немає, і бізнес-логіку доводиться переписувати на компенсації.
Мірятися кількістю сервісів і забувати про операційну ціну: окремий CI, окремий моніторинг, трасування, версіювання контрактів, локальний запуск усього стека розробником.
ПОРАДА

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

Сторінка питання →
LR
Laravel·Senior ·Octane ·Swoole ·стан між запитами

Octane тримає застосунок у памʼяті між запитами через Swoole, FrankenPHP або RoadRunner: бутстрап відбувається один раз, але стан більше не скидається сам.

Чому під Octane один користувач бачить дані іншого?
Чим scoped відрізняється від singleton під Octane?
Що треба перевірити в застосунку перед переходом на Octane?

Класичний PHP-FPM створює застосунок з нуля на кожен запит: автозавантаження, провайдери, конфігурація, роутинг. Octane завантажує все один раз у воркер на Swoole, FrankenPHP чи RoadRunner і далі обробляє запити тим самим процесом. Звідси виграш: бутстрап Laravel зникає з кожного запиту. Сам PHP-код не стає швидшим.

Ціна — стан, який більше не помирає разом із запитом. Три джерела проблем: singleton у контейнері, статичні властивості класів і глобальні змінні чи memoization у хелперах. Синглтон, який отримав Request у конструкторі, на другому запиті тримає Request першого користувача. Статичний кеш, який раніше жив 50 мс, тепер живе добу й росте.

Octane сам скидає те, про що знає: auth, session, database connections, cache resolvers, translator. Про ваші класи він не знає. Тому перед міграцією потрібен аудит привʼязок: усе, що тримає стан запиту, переводиться на scoped, статичні кеші отримують очищення на RequestTerminated, а Request передається в методи, а не в конструктори довгоживучих сервісів.

Перед переходом варто виміряти, де йде час. Якщо запит триває 500 мс через SQL і зовнішні API, економія 30 мс бутстрапу непомітна, а ризики витоків реальні. Octane виправданий на легких ендпоінтах з великим RPS, де бутстрап є значною частиною часу відповіді.

// НЕБЕЗПЕЧНО під Octane: Request живе в синглтоні між запитами
final class CurrentTenant
{
    private ?Tenant $tenant = null;

    public function __construct(private Request $request) {}

    public function get(): Tenant
    {
        return $this->tenant ??= Tenant::whereHost($this->request->getHost())->firstOrFail();
    }
}
$this->app->singleton(CurrentTenant::class); // другий користувач побачить tenant першого

// БЕЗПЕЧНО: scoped скидається після кожного запиту
$this->app->scoped(CurrentTenant::class);

// Або взагалі не тримати Request у стані: брати його в момент виклику
final class CurrentTenant
{
    public function get(Request $request): Tenant { /* ... */ }
}

// Прихований витік: статичний кеш росте з кожним запитом
final class Pricing
{
    private static array $cache = [];   // ніколи не очищається під Octane
}

// Скидання власного стану між запитами
Octane::tick('flush-pricing', fn () => Pricing::flush())->seconds(60);
Event::listen(RequestTerminated::class, fn () => Pricing::flush());
Що виграш іде саме з відсутності бутстрапу фреймворку на кожен запит, а не з магічного прискорення PHP-коду.
Що singleton тепер живе між запитами, тому синглтон, який зберігає Request, користувача чи будь-які дані запиту, протікає між користувачами.
Що є три джерела витоків стану: singleton у контейнері, статичні властивості класів і глобальні змінні або memoization у хелперах.
Що Octane сам скидає частину стану (auth, session, database, cache resolvers), але не знає про ваші класи.
Практику: замінити singleton на scoped там, де є стан запиту, перевіряти memory через --max-requests, тримати ліміт памʼяті, і памʼятати, що інʼєкція Request у конструктор синглтона це антипатерн.
Казати, що Octane компілює PHP чи робить код швидшим: він лише прибирає повторний бутстрап.
Інжектити Request або Auth у конструктор singleton-сервісу: під FPM це працювало, під Octane дає чужі дані.
Не помічати статичні кеші в пакетах: static::$resolved, memoized конфіги, реєстри, які ростуть з кожним запитом.
Переходити на Octane без бенчмарка власного застосунку: якщо час іде на SQL і зовнішні API, бутстрап це 5 % і виграшу майже немає.
Забувати про конкурентність Swoole: блокуючий sleep або довгий запит тримає воркер, а коррутини змінюють семантику деяких розширень.
ПОРАДА

Обовʼязково згадайте, що синглтони, які приймають Request, треба переписати на scoped, інакше дані одного користувача побачить інший. І назвіть FrankenPHP як актуальний варіант поруч зі Swoole та RoadRunner.

Сторінка питання →
PHP
Core PHP·Senior ·PHP 8.5 ·pipe operator ·clone with

PHP 8.5 (реліз 20 листопада 2025) додав оператор конвеєра `|>`, синтаксис `clone $obj with {...}`, атрибут `#[\NoDiscard]` з приведенням `(void)`, вбудоване розширення URI з класами `Uri\Rfc3986\Uri` та `Uri\WhatWg\Url`, а також дрібніші зручності — `array_first()`/`array_last()`, атрибути на константах, замикання в константних виразах і бектрейси для фатальних помилок.

Ви вже дивилися на 8.5? Що з нового реально взяли б у продакшн, а що ні?
Що робить оператор `|>` і чим він кращий за вкладені виклики функцій?
Навіщо `clone with`, якщо вже є `__clone()` і звичайні `with*()`-методи?
Чому нам більше не радять `parse_url()` і що прийшло на заміну?

PHP 8.5 вийшов 20 листопада 2025 року, і за структурою це типовий мінорний реліз: три зміни, які видно прямо в синтаксисі (|>, clone with, #[\NoDiscard]), одне велике доповнення стандартної бібліотеки (розширення URI) і десяток дрібниць — array_first()/array_last(), атрибути на константах, замикання в константних виразах, final для просунутих у конструкторі властивостей, get_error_handler()/get_exception_handler(), бектрейси для фатальних помилок, персистентні share-хендли cURL. Сильна відповідь на це питання — не перелік, а сортування: що змінює те, як ви пишете код щодня, що закриває конкретний клас багів, а що є нішевим і чекатиме свого випадку.

Оператор конвеєра |> розвертає ланцюжок викликів зліва направо: замість htmlspecialchars(trim($raw)) пишеться $raw |> trim(...) |> htmlspecialchars(...). Механіка проста і має рівно одну пастку: права частина — це вираз, значення якого має бути callable, і викликається він з одним аргументом. Тому там стоїть синтаксис callable першого класу trim(...) (PHP 8.1), замикання або змінна з callable, але не trim() — такий запис викличе функцію без аргументів. Функції з параметром за посиланням у конвеєр не передаються взагалі, а для функцій із кількома обовʼязковими аргументами доводиться загортати виклик у fn, бо часткового застосування (str_replace('a', 'b', ?)) у PHP немає — відповідний RFC відхилили. Пріоритет у |> нижчий за арифметику й конкатенацію, тож 'x' . $y |> f(...) спершу склеїть рядки, а вже потім передасть їх далі; у сумнівних місцях (особливо поруч із ?? і тернарником) простіше поставити дужки. І окремо: жодного «пропускати null», як у ?->, тут немає — null спокійно піде в наступну ланку і впаде вже на її типі.

clone with закриває найнудніший бойлерплейт незмінних обʼєктів. До 8.5 wither писався як new static($this->a, $this->b, $new, $this->d) — і кожна нова властивість вимагала правки всіх таких методів. Тепер return clone $this with {url: $url}; копіює обʼєкт і присвоює лише перелічені властивості. Важливі деталі: конструктор не викликається, тому валідація з нього не спрацює; копіювання лишається поверхневим, тож вкладені мутабельні обʼєкти й далі спільні і глибоку копію робить __clone(), який викликається вже після присвоєння нових значень; readonly-властивість змінити так можна, але лише з коду, який має право її записувати, тобто зсередини класу-власника — ззовні буде Error: Cannot modify readonly property. Це саме той випадок, коли фічу варто брати одразу: вона не змінює семантику, лише прибирає рядки, які й так були помилконебезпечними.

Атрибут #[\NoDiscard] позначає функцію чи метод, чий результат не можна мовчки викидати; разом із ним у мову додали приведення (void), яким ігнорування оформлюють свідомо. Ефект — E_WARNING, а не виняток, тож це підказка, а не гарантія, і користь від неї є там, де попередження реально видно в логах і CI. Найкращі кандидати — withers незмінних обʼєктів і чисті перетворення, у яких виклик без присвоєння не робить нічого. Розширення URI — інша за вагою зміна: воно приносить Uri\Rfc3986\Uri (сувора граматика RFC 3986, resolve(), equals(), toString()/toRawString()) і Uri\WhatWg\Url (алгоритм браузерів, нормалізація хоста й IDN через getAsciiHost()/getUnicodeHost()). Обидва незмінні, з with*()-методами й без __toString(), а невдалий розбір — це Uri\InvalidUriException / Uri\WhatWg\InvalidUrlException у конструкторі або null зі статичного parse(). Практичний сенс — вибір стандарту стає явним: parse_url() не відповідає жодному з них, лише розрізає рядок і мовчки ковтає значну частину сміття, а розбіжність між вашою перевіркою і тим, куди насправді піде браузер, — класичне джерело SSRF-обходів.

Межі варто назвати чесно. Новий синтаксис — це підвищення мінімальної версії: для застосунку, який ви деплоїте самі, це питання CI й версії на серверах, а для бібліотеки з зовнішніми користувачами |> або clone with у коді означає відсікти всіх, хто ще на 8.3–8.4. Перед першим рядком нового синтаксису переконайтесь, що всі інструменти, які парсять код — статичний аналізатор, Pint/PHP-CS-Fixer, генератори документації, — уже вміють 8.5, бо інакше вони не розберуть файл цілком. І тримайте пропорцію: clone with і розширення URI дають користь одразу і майже без ризику, #[\NoDiscard] — точково на кількох ключових API, а |> варто вводити лише там, де ланцюжок справді складається з унарних функцій; конвеєр із трьох fn поспіль читається гірше, ніж три звичайні змінні з іменами.

use Uri\InvalidUriException;
use Uri\Rfc3986\Uri;

final class Link
{
    public function __construct(
        public readonly string $url,
        public readonly string $host,
    ) {}

    /** clone with: решту властивостей не перелічуємо, readonly пишемо зі scope класу */
    public function withUrl(string $url): static
    {
        return clone $this with {url: $url};
    }
}

// Виклик, чий результат відкинули, дасть E_WARNING саме з цим поясненням
#[\NoDiscard('поверніть нормалізований рядок: сам виклик нічого не змінює')]
function normalize(string $raw): string
{
    // праворуч від |> має стояти ВИРАЗ-callable: trim(...) або замикання
    return $raw
        |> trim(...)
        |> fn (string $u): string => rtrim($u, '/');
}

$raw = '  https://Example.COM/docs/?utm_source=x#top  ';

try {
    // сувора граматика RFC 3986; «як у браузері» — Uri\WhatWg\Url
    $uri = new Uri(normalize($raw));
} catch (InvalidUriException $e) {
    throw new InvalidArgumentException('Некоректне посилання', previous: $e);
}

// null прибирає компонент; __toString() у класу немає — лише явний toString()
$canonical = $uri->withQuery(null)->withFragment(null)->toString();
$link = (new Link($uri->toString(), $uri->getHost() ?? ''))->withUrl($canonical);

normalize($raw);        // Warning: return value should either be used...
(void) normalize($raw); // свідоме ігнорування — попередження зникає
Що праворуч від `|>` стоїть вираз, який має дати callable, тому пишуть `trim(...)`, а не `trim()`; часткового застосування (partial application) у PHP немає, і для функцій із кількома аргументами доводиться писати `fn`.
Що `clone with` — це не глибоке копіювання, а звичайний `clone` із присвоєнням перелічених властивостей, і саме він прибирає бойлерплейт `with*()`-методів у readonly-DTO.
Що `#[\NoDiscard]` дає `E_WARNING`, а не помилку, і що свідоме ігнорування результату пишеться приведенням `(void)`.
Що розширення URI дає два різні набори правил — сувору RFC 3986 і браузерну WHATWG — і що `parse_url()` не відповідає жодному стандарту, тому для перевірки користувацького вводу він поганий вибір.
Що senior-відповідь містить план апгрейду: мінімальна версія в `composer.json`, матриця CI, статичний аналіз і форматер, які вміють парсити новий синтаксис, і вікно підтримки версії.
Що кандидат не вигадує фіч: наприклад, у PHP немає ані оператора `|>` з автоматичним пропуском `null`, ані вбудованого часткового застосування.
Писати `$x |> strtoupper()` замість `$x |> strtoupper(...)`: перший варіант викличе `strtoupper()` без аргументів і спробує використати результат як callable.
Вважати, що конвеєр «зупиняється на null», як `?->`: жодного короткого замикання немає — `null` просто піде в наступну ланку і там впаде на типі.
Пробувати `$array |> sort(...)`: функції з параметром за посиланням у конвеєр не передаються.
Розраховувати, що `clone $order with {status: ...}` спрацює ззовні класу для `readonly`-властивості — доступ на запис перевіряється так само, як при звичайному присвоєнні, і буде `Cannot modify readonly property`.
Думати, що `#[\NoDiscard]` кидає виняток або що ним уже позначені всі «небезпечні» функції стандартної бібліотеки — це opt-in-атрибут насамперед для вашого коду.
Інтерполювати обʼєкт URI в рядок: у нових класів немає `__toString()`, є явні `toString()`/`toRawString()` (і `toAsciiString()`/`toUnicodeString()` у WHATWG-версії).
Називати «новинками 8.5» речі з попередніх релізів — property hooks і асиметричну видимість (8.4), readonly-класи (8.2), `never`/enum (8.1).
ПОРАДА

Побудуйте відповідь як рішення, а не як список: «синтаксично найпомітніші три — `|>`, `clone with`, `#[\NoDiscard]`; найкорисніше для продакшну — розширення URI, бо воно прибирає самописну валідацію посилань поверх `parse_url()`; `clone with` беру одразу у withers, `|>` — точково, бо без часткового застосування він швидко перетворюється на ланцюжок `fn`».

Сторінка питання →
ARC
Архітектура·Senior ·outbox ·dual write ·повідомлення

Outbox pattern прибирає проблему подвійного запису: подію зберігають рядком у таблиці `outbox` тією ж транзакцією, що й зміну стану, а окремий процес-релей уже після коміту читає ці рядки й публікує в брокер. База і подія стають атомарними, ціною чого є доставка at-least-once — споживач зобовʼязаний бути ідемпотентним.

Ми пишемо в базу і одразу публікуємо подію в Kafka. Транзакція відкотилась, а подія вже пішла — як це прибрати?
Чому не можна просто дочекатись коміту й після нього викликати publish()?
Що станеться, якщо воркер упаде між COMMIT і відправленням у брокер?
Ви поклали подію в таблицю outbox — скільки разів її отримає споживач?

Проблема називається dual write: у транзакції ви змінюєте базу, а поряд треба сказати про це решті світу — покласти повідомлення в Kafka, RabbitMQ, SQS чи Redis. Це два різні сховища без спільної транзакції, і жоден порядок дій не рятує. Опублікуєте всередині транзакції — ROLLBACK відкотить рядок, а споживачі вже отримали подію про те, чого не сталося: гроші «списані», лист надісланий, склад зарезервував товар. Опублікуєте після COMMIT — між комітом і publish() лишається проміжок у кілька мілісекунд, і падіння процесу, OOM-kill контейнера чи розрив зʼєднання з брокером саме там означають, що стан змінився, а події не буде ніколи, і ніхто про це не дізнається. Двофазний коміт закрив би питання теоретично, але Kafka, SQS і Redis його не пропонують, а MySQL XA історично болісний у відновленні підвішених гілок — на практиці цей шлях не беруть.

Вихід у тому, щоб зробити атомарним не «база + брокер», а «база + база»: подію записують рядком у таблицю outbox тим самим зʼєднанням і тією самою транзакцією, що й зміну стану. Тепер відкат забирає обидва записи разом, а успішний COMMIT означає, що подія існує як факт, зафіксований на диску. Окремий процес-релей після коміту читає невідправлені рядки, публікує їх у брокер і позначає sent_at. Ключова деталь вибірки — SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL 9.5+, MySQL 8.0+): він дозволяє кільком релеям розбирати різні пачки й не дає двом процесам взяти ту саму сотню рядків. Чого робити не варто — тримати позначку id > :last зовні: значення послідовності видаються до коміту, тому рядок з меншим id може стати видимим пізніше за рядок з більшим, і подія тихо випаде з обробки.

Головне, чого outbox не дає — exactly-once. Релей може опублікувати повідомлення і впасти до UPDATE ... SET sent_at, після рестарту він опублікує його вдруге; сам брокер теж працює в режимі at-least-once. Тому дедуплікація не опція, а друга половина патерна: кожна подія несе message_id, згенерований ще в транзакції запису, а споживач в одній транзакції вставляє цей id у таблицю оброблених повідомлень з унікальним індексом і виконує корисну дію. Порушення унікальності = «вже робили», повідомлення підтверджують і йдуть далі. Це та сама ідея ключа ідемпотентності, що й у платежах, тільки застосована до споживача черги. Порядок теж не глобальний: у межах агрегату його дає ключ партиціювання (aggregate_id як ключ повідомлення в Kafka) або номер версії всередині події, який споживач порівнює з уже застосованим.

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

У PHP-стеку є приємна деталь: те, що часто описують як «треба зробити outbox», у багатьох проєктах уже стоїть. Черга Laravel на драйвері database пише завдання в таблицю jobs, а Doctrine-транспорт Symfony Messenger (doctrine://default) — у messenger_messages. Якщо це те саме зʼєднання і диспетч відбувається всередині вашої транзакції, ви вже отримали транзакційний outbox без єдиного власного класу, а воркер грає роль релея. Плутати з цим dispatch()->afterCommit() (чи after_commit => true) і DispatchAfterCurrentBusMiddleware не можна: вони лише відкладають відправлення за коміт, лікуючи «job не бачить щойно створеної моделі», і вікно втрати між COMMIT і publish у них залишається.

Ціна патерна — затримка й експлуатація. Подія доходить не миттєво, а за час циклу релея, і це треба закласти в UX там, де користувач очікує реакції одразу. Таблиця росте: рядки або видаляють одразу після публікації, або позначають і чистять пакетно за розкладом, і в PostgreSQL частковий індекс WHERE sent_at IS NULL тут майже обовʼязковий, бо інакше індекс тягне за собою весь архів, а постійні UPDATE+DELETE роздувають таблицю. Обовʼязковий і моніторинг: алерт на вік найстарішого невідправленого рядка й на розмір черги — застряглий релей інакше виявляють через скаргу «клієнту не прийшов лист», коли минуло вже пів дня. Коли polling впирається в межу або систем-джерел стає багато, наступний крок — CDC: Debezium читає WAL/binlog і публікує рядки outbox без запитів до бази, ціною Kafka Connect, слотів реплікації та ще одного сервісу, за яким треба стежити.

// 1. Запис стану і події — одна транзакція, одне зʼєднання. Брокера тут немає.
DB::transaction(function () use ($order): void {
    $order->markPaid();
    $order->save();

    DB::table('outbox')->insert([
        'message_id'   => (string) Str::ulid(), // ключ дедуплікації для споживача
        'aggregate_id' => $order->id,           // ключ партиції: порядок у межах замовлення
        'type'         => 'order.paid',
        'payload'      => json_encode([        // стан НА момент транзакції, не id
            'order_id' => $order->id,
            'amount'   => $order->amount_cents,
            'currency' => $order->currency,
        ]),
        'occurred_at'  => now(),
        'sent_at'      => null,
    ]);
}); // ROLLBACK відкотить і зміну, і подію — фантомних подій не буває

// 2. Релей: окремий процес, крутиться в циклі. Публікує вже після коміту.
final readonly class OutboxRelay
{
    public function __construct(private Publisher $broker) {}

    public function drainBatch(int $limit = 100): int
    {
        return DB::transaction(function () use ($limit): int {
            // SKIP LOCKED: кілька релеїв беруть різні пачки (PostgreSQL 9.5+, MySQL 8.0+)
            $rows = DB::table('outbox')
                ->whereNull('sent_at')
                ->orderBy('id')
                ->limit($limit)
                ->lockForUpdate()->skipLocked()
                ->get();

            foreach ($rows as $row) {
                // Падіння тут -> рядок лишиться невідправленим -> повтор.
                // Падіння після publish, але до update -> дубль. Звідси at-least-once.
                $this->broker->publish($row->type, $row->aggregate_id, $row->payload, $row->message_id);
                DB::table('outbox')->where('id', $row->id)->update(['sent_at' => now()]);
            }

            return $rows->count();
        });
    }
}
Чітке формулювання проблеми: база й брокер — два різні сховища без спільної транзакції, тому будь-який порядок «запис + publish» ламається на падінні між ними.
Що outbox не робить доставку exactly-once: він гарантує «подія не загубиться і не зʼявиться без коміту», але дублі неминучі, бо релей може впасти між publish і позначкою sent.
Що релей має брати рядки через `SELECT ... FOR UPDATE SKIP LOCKED` (MySQL 8.0+, PostgreSQL 9.5+), а не через високу водяну позначку по `id` — інакше на PostgreSQL він пропускатиме рядки з меншим id, закомічені пізніше.
Що в outbox кладуть уже серіалізований payload на момент транзакції, а не лише id сутності: релей, який читає поточний стан, віддасть стан пізнішого часу і зруйнує сенс події.
Що на боці споживача пару виду outbox/inbox замикає таблиця оброблених message_id, а не «сподіваємось, брокер не задублює».
Що черга на базі (`messenger_messages` у Symfony Messenger, `jobs` у Laravel) на тому ж зʼєднанні й у тій же транзакції — це вже outbox, і окрему таблицю вигадувати не треба.
Плутати outbox з `dispatch()->afterCommit()` у Laravel чи `DispatchAfterCurrentBusMiddleware` у Symfony: вони лише зсувають відправлення за коміт, але між COMMIT і `publish()` процес усе одно може впасти, і подія зникне назавжди.
Публікувати в брокер усередині транзакції: якщо далі буде ROLLBACK, споживачі вже отримали подію про те, чого не сталося, і компенсувати це нічим.
Обіцяти exactly-once і не робити дедуплікацію на споживачі: дублі — нормальний режим роботи outbox, а не аварія.
Тягнути XA / двофазний коміт: Kafka, Redis, SQS його не підтримують у потрібному вигляді, а MySQL XA історично має проблеми з відновленням підвішених гілок; ціна не окупається.
Забирати рядки простим `SELECT ... LIMIT 100` без блокування і запускати два релеї: обидва прочитають ту саму сотню і опублікують усе двічі без потреби.
Не чистити таблицю: outbox росте вічно, а в PostgreSQL постійні UPDATE+DELETE ще й дають роздування таблиці й тиск на autovacuum.
ПОРАДА

Скажіть одним реченням, звідки проблема: «база й брокер — два сховища без спільної транзакції, тож атомарним може бути лише запис у ту саму базу». І одразу назвіть ціну: at-least-once, тому дедуплікація на споживачі — частина патерна, а не окрема опція.

Сторінка питання →
OPS
DevOps·Senior ·масштабування ·stateless ·сесії

Треба винести назовні весь стан, який зараз лежить на локальному диску чи в памʼяті процесу: сесії — у Redis/БД, завантажені файли — в обʼєктне сховище, кеш і локи — у спільний Redis, черги й планувальник — в окремі процеси з дедуплікацією; липкі сесії це не рішення, а спосіб не помітити проблему.

Додали другий вузол за балансувальником — і користувачів почало кожен другий запит викидати з логіну. Чому?
Ми ввімкнули липкі сесії на балансувальнику, і все запрацювало. Що з цим рішенням не так?
Куди дівати файли, які завантажують користувачі, коли серверів більше ніж один?
Що саме у вашому застосунку заважає прямо зараз запустити три копії контейнера замість однієї?

Сам PHP-процес між запитами стану не тримає — після кожного запиту він усе забуває, і в цьому сенсі мова масштабується краще за більшість інших. Проблема в тому, що застосунок роками писався в припущенні «диск і памʼять у нас одні на всіх», і стан осідає в кількох конкретних місцях: у каталозі сесій, у завантажених файлах, у кеші й локах, у чергах і cron, у логах. Питання, яке треба поставити до кожного з них, одне: що станеться, якщо наступний запит того самого користувача прийде на інший вузол, а цей вузол просто зникне. Все, що на це питання відповідає «зламається», і є список робіт.

Найпершим стріляє сесія. Дефолтний session.save_handler=files кладе серіалізований масив у session.save_path, тобто в локальну ФС конкретного вузла; балансувальник розкидає запити, і користувач через раз бачить форму логіну. Спільним сховищем робить або сам PHP (session.save_handler=redis з session.save_path="tcp://host:6379?database=2" від phpredis, або memcached), або фреймворк: у Laravel SESSION_DRIVER=redis|database (з 11-ї версії дефолт скелета — database), у Symfony — framework.session.handler_id з RedisSessionHandler. Тут є дві деталі, на яких валяться. Перша: нативний files тримає flock на файлі до кінця запиту, тому паралельні AJAX-запити одного користувача виконуються послідовно; у phpredis це поведінка вимикається за замовчуванням і вмикається redis.session.locking_enabled=1, а власна сесія Laravel не блокує нічого взагалі — там останній запис перемагає. Друга: сховище спільне, а ключі мають бути однакові, бо cookie з ідентифікатором сесії зашифрована APP_KEY (у Symfony APP_SECRET підписує signed URI та remember-me); згенерований під час деплою ключ дає вузол, який не може прочитати чужу cookie.

Далі файли й кеш. Завантаження користувачів переїжджають у обʼєктне сховище — S3 чи сумісне (MinIO, DigitalOcean Spaces) через диск s3 і league/flysystem-aws-s3-v3; storage:link і public/storage при цьому втрачають сенс, приватні файли віддаються через Storage::temporaryUrl(). З кешем тонше: opcache і зібрані артефакти (config:cache, route:cache, view:cache) — це похідні від коду, вони мають бути на кожному вузлі й будуються під час збірки образу, це нормально. А от кеш даних локальним бути не може: APCu живе в межах процесу FPM, файловий — у межах вузла, і скидання ключа на вузлі A залишає вузол B зі старими даними. Разом із кешем переїжджає все, що на ньому побудоване: Cache::lock(), RateLimiter, мітка queue:restart, локи withoutOverlapping() і onOneServer(). Найнеприємніше, що вони не падають — вони просто перестають бути глобальними гарантіями й стають локальними.

Черги й планувальник із «ще одного процесу на вебсервері» стають окремою одиницею розгортання. Воркер не потребує nginx і масштабується власним темпом, зате потребує тієї самої черги (redis, SQS, database) і коректного завершення: на SIGTERM queue:work дороблює поточну задачу й виходить, тому terminationGracePeriodSeconds має бути більшим за найдовший job, інакше задача обірветься посередині. schedule:run навпаки має спрацьовувати рівно один раз: або окремий CronJob в оркестраторі, або ->onOneServer() на кожній задачі з обовʼязково спільним лок-стором. У контейнері до цього додається загальне правило: писати можна лише в змонтовані томи й у зовнішні сервіси, бо шар запису викидається разом із контейнером — тому логи йдуть у stderr (LOG_CHANNEL=stderr) і збираються збирачем, а не ротуються всередині. І окремо: за балансувальником треба оголосити довірені проксі (trustProxies у bootstrap/app.php, framework.trusted_proxies у Symfony), інакше X-Forwarded-For і X-Forwarded-Proto ігноруються, у логах усі запити приходять з однієї адреси, а згенеровані посилання стають http://.

Липкі сесії (ip_hash у nginx, cookie ... insert у HAProxy, стікі target group в ALB) варто вміти назвати запахом і пояснити чому. Вони не усувають локальний стан, а лише роблять його непомітним: навантаження розподіляється нерівномірно (один NAT — один вузол), автоскейлінг перестає допомагати, бо нові вузли не забирають наявні сесії, виведення вузла на деплой означає розлогінених користувачів, а падіння — втрачені кошики. Плюс тестування деградує: помилка «стан не спільний» проявиться не одразу, а через півроку під час першого ж інциденту. Легітимні винятки є — WebSocket і SSE справді привʼязані до процесу, і липкість тут не милиця, а вимога; так само вона нормальна як тимчасовий місток на час міграції сесій.

Межа підходу в тому, що абсолютно stateless застосунків не буває — стан не зникає, а переїжджає, і те, що переїхало, стає новою точкою відмови. Redis із сесіями, кешем і чергами покладе весь кластер швидше, ніж це зробив би один вебвузол, тож за ним іде реплікація чи Sentinel, і окреме питання — чи можна пережити його недоступність (сесії — ні, кеш — так, якщо код готовий до промаху). Другий компроміс — латентність: те, що було читанням з локального диска чи з APCu за мікросекунди, стає мережевим викликом, тому гарячі незмінні дані іноді свідомо лишають у локальному APCu з коротким TTL, приймаючи розузгодженість у кілька секунд. Третій — вартість переходу: якщо застосунок писався десять років з session_start() і move_uploaded_file() у public/, чесна відповідь на співбесіді включає етапність — спершу сесії й ключі, потім файли, потім кеш і локи, і лише в кінці вимкнення липкості як фінальна перевірка, що стану на вузлах більше немає.

# ── /etc/php/8.4/fpm/conf.d/90-session.ini ────────────────────────────
# Було (типовий одиничний сервер): сесія — файл у локальній ФС вузла.
#   session.save_handler = files
#   session.save_path    = "/var/lib/php/sessions"
# Стало: спільне сховище через розширення redis (phpredis 6.x).
session.save_handler = redis
session.save_path    = "tcp://redis.internal:6379?database=2"
redis.session.locking_enabled = 1   # відтворює блокування, яке давав files
redis.session.lock_expire     = 30  # щоб зависла вимога не тримала сесію вічно
session.gc_maxlifetime        = 7200 # для redis це TTL ключа, GC не потрібен

# ── .env (Laravel 12) — увесь локальний стан переїжджає у сервіси ─────
SESSION_DRIVER=redis        # було file → storage/framework/sessions
CACHE_STORE=redis           # було file; APCu теж живе в межах процесу
QUEUE_CONNECTION=redis      # було sync або database «бо один сервер»
FILESYSTEM_DISK=s3          # було local → storage/app/public
LOG_CHANNEL=stderr          # логи в stdout/stderr, не у файл контейнера
APP_KEY=base64:...          # ОДИН на всі вузли, інакше cookie не розшифрувати

# ── Перевірка, що стан справді спільний, а не «здається спільним» ────
# на вузлі A:
php artisan tinker --execute 'Cache::put("probe", gethostname(), 60);'
# на вузлі B — має вивести імʼя вузла A, а не null:
php artisan tinker --execute 'echo Cache::get("probe") ?? "null";'

# ── Планувальник: cron на всіх вузлах, дедуплікація в коді ───────────
# * * * * * php /app/artisan schedule:run >> /dev/null 2>&1
# Schedule::command('reports:send')->daily()->onOneServer();
#   ↑ потрібен redis/memcached/database/dynamodb: файлові локи локальні,
#     винятку не буде, а звіт піде стільки разів, скільки вузлів.

# ── Запах, а не рішення: липкі сесії на балансувальнику ──────────────
# nginx:   upstream app { ip_hash; server web1; server web2; }
# haproxy: cookie SRVID insert indirect nocache
# Якщо після їх вимкнення застосунок ламається — стан досі локальний.
Перелік місць, де ховається стан: `session.save_path`, завантажені файли, кеш і локи, черги й cron, логи — і що кожне з них має окреме рішення.
Що сесія за замовчуванням це `session.save_handler=files` у локальній ФС вузла, а спільною вона стає через `session.save_handler=redis` з `session.save_path="tcp://..."` або через драйвер застосунку (`SESSION_DRIVER=redis|database`).
Що спільним має бути не лише сховище, а й ключі: один `APP_KEY` (Laravel) чи `APP_SECRET` (Symfony) на всі вузли, інакше вузол B не розшифрує cookie, видану вузлом A.
Розуміння, що APCu і файловий кеш живуть у межах процесу чи вузла, тому `Cache::lock()`, rate limiter, `queue:restart` і `->onOneServer()` без спільного стора мовчки перестають гарантувати те, що обіцяють.
Що липкі сесії — тимчасовий милиць: вони ламають рівномірність навантаження, автоскейлінг і виведення вузла з ротації, а падіння вузла все одно втрачає сесії.
Що планувальник і воркери — це окремі процеси, а не щось, що запускається на кожному вебвузлі «за компанію».
Змонтувати NFS у `session.save_path` і вважати задачу закритою: додається мережева затримка на кожен запит і блокування файлу сесії через `flock` по мережі.
Вирішити проблему липкими сесіями (`ip_hash` у nginx, `cookie SRVID insert` у HAProxy) і залишити стан локальним — після перезапуску вузла всі його користувачі розлогінені.
Генерувати `php artisan key:generate` під час деплою або в `Dockerfile`: у кожного вузла (чи в кожній збірці) свій `APP_KEY`, і сесії з cookie перестають розшифровуватись.
Лишити `FILESYSTEM_DISK=local` і `storage:link`: аватарка, завантажена на вузол A, дає 404 при відкритті з вузла B.
Запускати `schedule:run` з cron на кожному вузлі без `->onOneServer()` — щоденний звіт розсилається стільки разів, скільки вузлів.
Забути про довірені проксі: без `trustProxies` (Laravel) чи `framework.trusted_proxies` (Symfony) `$request->ip()` повертає адресу балансувальника, а `url()` генерує `http://` за HTTPS-балансувальником.
ПОРАДА

Скажіть так: «Горизонтальне масштабування — це не про сервери, а про пошук стану. Я проходжу по списку: сесія, файли, кеш і локи, черги, cron, логи — і для кожного питаю, що станеться, якщо наступний запит прийде на інший вузол, а цей вузол зараз зникне». Далі окремо додайте, що липкі сесії — це індикатор незакритого пункту зі списку, а не рішення.

Сторінка питання →
OPS
DevOps·Senior ·міграції ·zero-downtime ·деплой

Схема змінюється у фази expand і contract: спершу зміни, сумісні зі старим кодом, потім деплой коду, який працює з обома варіантами, і лише після повного викочування прибирання старого.

Як перейменувати колонку без простою?
Чому не можна додавати NOT NULL без default на великій таблиці?
Що таке expand and contract?

Під час zero-downtime деплою стара й нова версії коду певний час працюють одночасно з однією базою. Тому кожна міграція має бути сумісна з попереднім кодом, а кожна версія коду з попередньою і наступною схемою. Цей принцип називають expand and contract: спершу розширення схеми, сумісне з усім, потім переключення коду, і лише після повного викочування прибирання старого.

Безпечні операції: додати nullable-колонку, нову таблицю, індекс через CONCURRENTLY. Небезпечні: видалення, перейменування, зміна типу, NOT NULL на існуючій колонці. Небезпечні операції розкладаються на кілька релізів. Перейменування колонки займає чотири: додати нову, писати в обидві й читати стару, читати нову й писати в обидві, писати лише в нову й видалити стару. Між другим і третім кроком іде backfill батчами по первинному ключу, щоб не тримати довге блокування й не роздувати WAL.

Окрема категорія — DDL, який блокує таблицю. У PostgreSQL додавання NOT NULL сканує таблицю під блокуванням; обхід через CHECK ... NOT VALID, VALIDATE CONSTRAINT без блокування запису і потім SET NOT NULL. Індекс без CONCURRENTLY зупиняє запис на час побудови. Будь-який DDL запускається з lock_timeout, щоб не чекати блокування вічно й не ставити в чергу всі запити за собою.

Міграції виконуються один раз, окремим кроком до перемикання трафіку, а не в entrypoint кожної репліки, і мають план відкату. Успіх міграції є умовою розкатки нового образу.

-- Задача: перейменувати users.name у users.full_name без простою

-- Реліз 1 (expand): нова nullable-колонка, миттєво
ALTER TABLE users ADD COLUMN full_name text;

-- Реліз 2: код пише в обидві колонки, читає стару. Backfill батчами у фоні
UPDATE users SET full_name = name
WHERE id BETWEEN 1 AND 10000 AND full_name IS NULL;
-- ... наступні діапазони з паузою

-- Реліз 3: код читає full_name. NOT NULL без довгого блокування (PostgreSQL)
ALTER TABLE users ADD CONSTRAINT users_full_name_nn CHECK (full_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_full_name_nn;   -- сканує без блокування запису
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;      -- PG 12+ використає CHECK, без скану
ALTER TABLE users DROP CONSTRAINT users_full_name_nn;

-- Індекс без блокування запису
CREATE INDEX CONCURRENTLY users_full_name_idx ON users (full_name);

-- Реліз 4 (contract): код більше не торкається name
ALTER TABLE users DROP COLUMN name;

-- Запобіжник для будь-якого DDL: не чекати блокування вічно
SET lock_timeout = '3s';
SET statement_timeout = '30s';
Назву патерну expand and contract і розуміння, що під час деплою одночасно працюють стара й нова версія коду з однією базою.
Що кожна міграція має бути сумісна з попередньою версією коду: додавання nullable-колонки або нової таблиці безпечне, видалення й перейменування ні.
Що перейменування робиться в кілька релізів: нова колонка, подвійний запис, backfill батчами, перемикання читання, видалення старої.
Що деякі DDL блокують таблицю або переписують її: ALTER з NOT NULL без default, зміна типу, індекс без CONCURRENTLY у PostgreSQL, і як це обійти.
Що міграції запускаються один раз до перемикання трафіку, окремим кроком із lock_timeout, а не всередині кожного контейнера на старті.
Видаляти або перейменовувати колонку в тому ж релізі, що й код: під час перекриття версій одна з них падає.
Додавати NOT NULL колонку без default на таблиці з мільйонами рядків і ловити повне переписування або блокування.
Створювати індекс без CONCURRENTLY у PostgreSQL і блокувати запис на хвилини.
Робити backfill одним UPDATE на всю таблицю замість батчів і тримати блокування й WAL.
Запускати migrate у entrypoint кожного контейнера й отримувати гонку між репліками.
ПОРАДА

Ключове слово — expand and contract. Назвіть його, опишіть обидві фази і наведіть приклад з перейменуванням колонки на чотири релізи.

Сторінка питання →
SQL
SQL·Senior ·deadlock ·блокування ·InnoDB

Deadlock — це цикл очікувань блокувань: база сама його виявляє й відкочує одну транзакцію (MySQL 1213/40001, PostgreSQL 40P01), тому застосунок зобовʼязаний повторити її, а зменшують частоту єдиним порядком блокування, короткими транзакціями та індексами під UPDATE.

Раз на добу в логах «Deadlock found when trying to get lock; try restarting transaction» — що робити?
Чим deadlock відрізняється від lock wait timeout?
Два запити оновлюють різні рядки — звідки взагалі взявся deadlock?
Чи можна позбутися deadlock-ів повністю, а не ретраїти їх?

Deadlock виникає, коли транзакція A тримає блокування, потрібне B, а B тримає блокування, потрібне A: у графі очікувань зʼявляється цикл, який не розірветься сам ніколи. Тому бази не чекають, а виявляють його. InnoDB перевіряє граф щоразу, коли транзакція стає в чергу за блокуванням (innodb_deadlock_detect увімкнено за замовчуванням), і відкочує жертву — транзакцію, яка змінила найменше рядків, тобто найдешевшу для скасування. PostgreSQL робить це ліниво: сесія, яка прочекала довше за deadlock_timeout (1 секунда), запускає перевірку, і якщо цикл знайдено, саме вона отримує помилку 40P01 deadlock detected із DETAIL про те, який процес на що чекав.

Це принципово інша подія, ніж lock wait timeout, і плутанина тут коштує найдорожче. Lock wait timeout exceeded (MySQL 1205) означає лінійне очікування без циклу, яке впʼялося в innodb_lock_wait_timeout — 50 секунд за замовчуванням; при дефолтному innodb_rollback_on_timeout=OFF відкочується лише останній оператор, і транзакція лишається відкритою з половиною змін, тож застосунок мусить явно зробити ROLLBACK. Deadlock натомість відкочує транзакцію-жертву повністю: повторювати треба весь блок від BEGIN, а не оператор, що впав. Звідси вимога до архітектури — блок транзакції має бути ідемпотентним, а листи, платіжні виклики та HTTP до зовнішніх систем живуть за межами COMMIT.

Друга поширена хиба — вважати, що без явного FOR UPDATE deadlock неможливий. Блокування бере кожен UPDATE, DELETE та INSERT, і причини найчастіше буденні: два сценарії оновлюють ті самі рядки в різному порядку; пакетний upsert з несортованим набором ключів; INSERT, який на конфлікті унікального індексу спершу бере shared-лок на існуючому записі; вставка в дочірню таблицю, що тримає лок на батьківському рядку за зовнішнім ключем. Окремо варто памʼятати, що блокування беруться по індексних записах: UPDATE ... WHERE status = 'pending' без індексу на status заблокує все, що просканував, а в MySQL на REPEATABLE READ ще й проміжки між записами через gap- і next-key-локи.

Діагностика зводиться до трьох кроків. У MySQL: SHOW ENGINE INNODB STATUS показує секцію LATEST DETECTED DEADLOCK з обома операторами, іменами індексів і режимами блокувань — але лише останній випадок, тому на проді вмикають innodb_print_all_deadlocks, і всі події падають в error log; поточні очікування видно в performance_schema.data_lock_waits і data_locks. У PostgreSQL текст помилки вже містить обидва процеси, а живу картину дають pg_blocking_pids() разом із pg_stat_activity, плюс log_lock_waits для очікувань довших за deadlock_timeout. Мета читання — знайти два місця в коді, які беруть ті самі обʼєкти в різному порядку.

Профілактика працює в такому порядку. Єдиний порядок блокування (найпростіше — завжди за зростанням первинного ключа: ORDER BY id ... FOR UPDATE, сортований і дедуплікований масив у пакетних операціях). Коротші транзакції: усе, що можна порахувати до BEGIN, рахується до нього, мережеві виклики виносяться назовні. Індекси під умови UPDATE, щоб площа блокування збігалася з набором рядків, які справді змінюються. Там, де вистачає одного оператора, блокування не потрібне взагалі — UPDATE ... WHERE balance >= :amount із перевіркою affected rows атомарний за визначенням, а для черг є FOR UPDATE SKIP LOCKED. І межа чесності: повністю прибрати deadlock-и в конкурентному застосунку неможливо, тому ретрай із невеликим backoff лишається обовʼязковим — правильна ціль не «нуль deadlock-ів», а рідкісні й непомітні для користувача.

-- Класичний цикл: сесії беруть ті самі рядки у зворотному порядку
-- Сесія A                                   | Сесія B
BEGIN;                                    -- | BEGIN;
UPDATE accounts SET balance = balance - 10
  WHERE id = 1;   -- X-лок на рядку 1     -- | UPDATE ... WHERE id = 2;  -- X-лок на 2
UPDATE accounts SET balance = balance + 10
  WHERE id = 2;   -- чекає на B           -- | UPDATE ... WHERE id = 1;  -- чекає на A
-- MySQL: ERROR 1213 (40001) Deadlock found; вся транзакція-жертва відкочена
-- PostgreSQL: ERROR 40P01 deadlock detected (детектор спрацював за deadlock_timeout)

-- Виправлення: єдиний порядок блокування в усіх сценаріях
BEGIN;
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;  -- завжди за зростанням
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;

-- Діагностика MySQL 8.0
SHOW ENGINE INNODB STATUS;                    -- секція LATEST DETECTED DEADLOCK, лише останній
SET GLOBAL innodb_print_all_deadlocks = ON;   -- усі випадки — в error log
SELECT * FROM performance_schema.data_lock_waits;  -- хто кого чекає прямо зараз

-- Діагностика PostgreSQL
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type, left(query, 60)
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- ALTER SYSTEM SET log_lock_waits = on;  -- очікування довші за deadlock_timeout (1 с) — у лог

-- Часто блокувань узагалі не треба: один атомарний оператор замість read-modify-write
UPDATE accounts SET balance = balance - 10 WHERE id = 1 AND balance >= 10;
Що deadlock — це виявлений цикл у графі очікувань, а не таймаут: InnoDB перевіряє граф одразу і відкочує транзакцію, яка змінила менше рядків, PostgreSQL запускає детектор після deadlock_timeout (1 с) і вбиває ту сесію, чиє очікування спричинило перевірку.
Різницю з lock wait timeout: у MySQL deadlock (1213) відкочує всю транзакцію-жертву, а «Lock wait timeout exceeded» (1205) при дефолтному innodb_rollback_on_timeout=OFF відкочує лише оператор, лишаючи транзакцію відкритою з половиною змін.
Що deadlock можливий без жодного FOR UPDATE: звичайні UPDATE, INSERT з унікальним ключем, ON DUPLICATE KEY UPDATE, вставка в дочірню таблицю з FK і навіть один пакетний UPDATE конфліктують блокуваннями.
Конкретні інструменти діагностики: SHOW ENGINE INNODB STATUS і innodb_print_all_deadlocks, performance_schema.data_lock_waits; у PostgreSQL pg_blocking_pids(), pg_locks, log_lock_waits і DETAIL у тексті помилки.
Що блокування беруться по індексних записах: UPDATE без придатного індексу блокує все, що просканував, тому індекс — це не лише швидкість, а й менша площа конфлікту.
Плутати deadlock із lock wait timeout і «лікувати» його підняттям innodb_lock_wait_timeout: цикл не розсмокчеться за жодний час очікування.
Вважати deadlock багом бази або наслідком неправильного рівня ізоляції й шукати рішення в SERIALIZABLE, який навпаки додає конфліктів.
Ретраїти транзакцію, всередині якої є Mail::send або запит до платіжного шлюзу: після повтору клієнт отримає два листи і два списання.
Ловити 40001 і повторювати лише останній оператор: транзакція-жертва вже відкочена цілком, повторювати треба її всю з самого BEGIN.
Робити пакетний UPDATE або upsert з масиву id у довільному порядку: два воркери з перетинними наборами блокують рядки в різній послідовності й регулярно зустрічаються в циклі.
Дивитись лише SHOW ENGINE INNODB STATUS і робити висновок про частоту: там зберігається тільки останній deadlock, без innodb_print_all_deadlocks історії немає.
ПОРАДА

Скажіть, що deadlock — штатна подія розподіленого доступу, а не аварія: правильна архітектура це «повторюваний блок транзакції + єдиний порядок блокування». І одразу назвіть різницю 1213 і 1205 — вона показує, що ви бачили ці помилки в проді, а не читали про них.

Сторінка питання →
LR
Laravel·Senior ·профілювання ·Telescope ·Debugbar

Спершу розкласти час: браузерний waterfall відділяє TTFB від фронтенду, а далі Debugbar/Clockwork/Telescope локально й Blackfire або Pulse у проді показують, куди йдуть мілісекунди всередині PHP. Оптимізують за спаданням внеску: кількість запитів (N+1) → важкі запити з `EXPLAIN` → синхронні зовнішні виклики в чергу → гігієна деплою (`opcache`, `php artisan optimize`, `composer -o`), і лише потім кеш і Octane.

Сторінка відкривається 4 секунди. З чого почнете і чим будете міряти?
Профайлер показує, що 70% часу — в `PDO::execute`. Що це означає і які наступні кроки?
Debugbar каже 1.8 с, а користувач скаржиться на 8 с. Хто з них бреше?
Ми зробили `php artisan optimize` і винесли розсилку в чергу — сторінка все одно повільна. Чому такий порядок дій був неправильний?

Перш ніж називати інструменти, треба відповісти на питання «повільно де». Час до появи сторінки складається щонайменше з пʼяти шматків: мережа й TLS, час до першого байта (тобто робота PHP), завантаження статики, виконання JavaScript у браузері й запити, які цей JavaScript робить далі. Перший крок — вкладка Network у DevTools: якщо TTFB документа 180 мс, а сторінка «думає» 6 секунд, оптимізовувати Laravel безглуздо, і чесна відповідь на співбесіді починається саме з цього розмежування. Якщо ж повільний TTFB, час усередині PHP розкладається на завантаження фреймворку (автозавантаження, реєстрація провайдерів, читання конфігу), запити до БД, синхронні виклики зовнішніх сервісів, рендер шаблонів і серіалізацію. Дешевий спосіб зробити цю декомпозицію видимою навіть без профайлера — заголовок Server-Timing із кількома мітками або власне логування, як у прикладі коду: пара чисел total_ms, db_ms, queries майже завжди одразу каже, куди дивитись.

Інструменти відрізняються не «зручністю», а тим, що вони бачать і скільки коштують. Laravel Debugbar (barryvdh/laravel-debugbar) — це панель, що вбудовується в HTML-відповідь: він показує список запитів із часом і дублікатами, таймлайн, вʼюхи, події й памʼять. Це dev-інструмент: для JSON-API він не працює звичним чином, у проді його не вмикають, а його власні накладні витрати помітні, тож абсолютні числа з нього — орієнтир, а не вимір. Clockwork (itsgoingd/clockwork) збирає ті самі дані, але віддає їх не в HTML, а окремим ендпоїнтом і заголовками, тому працює для API, artisan-команд і job у чергах — там, де Debugbar сліпий. Telescope — це вже не панель, а журнал: watchers пишуть запити, HTTP-запити, кеш, пошту, job і винятки в таблиці telescope_entries, і саме тому він однаково корисний для розслідування «що сталося о 14:07» і небезпечний як постійно ввімкнена штука на продакшені. Laravel Pulse — протилежний за дизайном інструмент: він семплить і агрегує, показуючи повільні запити, повільні маршрути, повільні job і споживачів ресурсів, тобто відповідає на питання «що болить загалом», а не «що сталося в цьому конкретному запиті».

Коли треба спуститися нижче рівня «який запит повільний» і зрозуміти, який саме PHP-код зʼїдає час, беруть справжній профайлер. Xdebug 3 із xdebug.mode=profile і xdebug.start_with_request=trigger (запуск через XDEBUG_TRIGGER=PROFILE, щоб не профілювати все підряд) пише cachegrind-файли в xdebug.output_dir, які читають у KCachegrind, Webgrind або PhpStorm; звідти видно дерево викликів, кількість викликів і власний час функцій. Ціна — сповільнення в рази й спотворення пропорцій: очікування на БД і мережу лишається таким, як було, а PHP-частина роздувається, тому за профілем Xdebug легко «оптимізувати» те, що в проді не важить нічого. Blackfire і Tideways зроблені саме для того, щоб цього уникнути: вони інструментують вибірково, дають розбивку по wall time, CPU, I/O, памʼяті й кількості SQL-запитів та HTTP-викликів, працюють на продакшені й уміють blackfire run для CLI та assertions у CI («ця сторінка не має робити більше N запитів»). Окремо варто знати про семплінг-профайлери (php-spx, excimer) — вони дешеві настільки, що їх тримають увімкненими постійно. І базова гігієна вимірювання: якщо у вас локально завантажений Xdebug, увімкнений Debugbar і APP_DEBUG=true з логуванням запитів — ваші числа не мають нічого спільного з продом.

Тепер про порядок оптимізацій, бо саме за нього ставлять плюс. Оптимізують за спаданням внеску в загальний час, і на реальних Laravel-проєктах рейтинг майже завжди такий. Перше — кількість запитів до БД: сторінка на 200–2000 запитів — це N+1, і тут виграш вимірюється не відсотками, а порядками (механіка й лікування — в окремій картці про N+1). Друге — окремі важкі запити: коли запитів десяток, але один іде 900 мс, далі говорить EXPLAIN/EXPLAIN ANALYZE, індекси, порядок колонок у складеному індексі, глибокий OFFSET і COUNT(*) заради пагінації. Третє — синхронні зовнішні виклики: відправка листа, звернення до платіжки, генерація PDF чи ресайз усередині HTTP-запиту роблять час відповіді заручником чужого сервера; це виносять у чергу (ShouldQueue), а дрібне — у dispatch(...)->afterResponse(). Четверте — гігієна деплою, яку часто пропускають, хоч вона одноразова: composer install --no-dev --optimize-autoloader (за можливості --classmap-authoritative), php artisan optimize (кеш конфігу, роутів, подій і вʼюх), APP_DEBUG=false, кеш і сесії не на файлах, а в Redis. Останнє звучить дрібно, але файловий драйвер сесій блокує файл на час запиту, і паралельні запити одного користувача шикуються в чергу — типовий «повільний дашборд», де кожен віджет чекає на попередній.

Окремо — OPcache, бо це єдина інфраструктурна річ, яку варто вміти перевіряти руками. Без нього PHP компілює тисячі файлів на кожен запит; з ним — читає готовий байткод із розділюваної памʼяті. Але «увімкнений» не означає «налаштований»: дефолтний opcache.max_accelerated_files=10000 для Laravel із залежностями замалий, і переповнений кеш дає плаваючу деградацію, яку в профайлері видно як розмазаний час компіляції. Дивляться на opcache_get_status(): num_cached_keys проти max_cached_keys, wasted_percentage, hit rate. У проді opcache.validate_timestamps=0 прибирає перевірку часу зміни файлів, але деплой зобовʼязаний перезапускати php-fpm; opcache.jit для типового веб-навантаження дає близько нуля, бо код чекає на БД, а не рахує; preload має практичний сенс разом із FrankenPHP або Octane. І тут же межа: php artisan config:cache після себе вимагає, щоб env() не викликався ніде, крім config/, а route:cache не переживе замикань у роутах — обидві помилки зазвичай виявляються на проді й виглядають як зламана функціональність, а не як проблема продуктивності.

І про компроміси, які варто назвати вголос. Кеш (Cache::remember, кеш фрагментів, HTTP-кеш) — потужний, але це не діагностика: він ховає вартість, додає інвалідацію й перетворює деградацію на рідкісну та непередбачувану (перший запит після скидання, cache stampede на популярному ключі). Octane прибирає bootstrap із кожного запиту, але приносить свій клас багів зі станом між запитами, і братися за нього до того, як прибрано N+1, — це прискорювати те, що займає 5% часу. Черга не робить нічого швидшим — вона переносить роботу й додає вам стан «в обробці», ідемпотентність і моніторинг воркерів. Нарешті, метрика: орієнтуватися треба на p95/p99, а не на середнє, і міряти на реальному обсязі даних, бо сторінка, що літає на 100 рядках, помирає на 100 тисячах через відсутній індекс. Дисципліна проста й саме вона відрізняє senior-відповідь: одна зміна — один повторний вимір, інакше через тиждень ніхто не скаже, що саме допомогло, і чи допомогло взагалі.

final class PerformanceServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Ліниве завантаження звʼязку — головне джерело N+1: локально падаємо одразу,
        // у проді не ламаємо сторінку, а пишемо модель і звʼязок у лог.
        Model::preventLazyLoading(! $this->app->isProduction());
        Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
            Log::channel('perf')->warning('lazy load', [
                'model' => $model::class,
                'relation' => $relation,
            ]);
        });

        $queries = 0;
        $queryMs = 0.0;

        DB::listen(function (QueryExecuted $query) use (&$queries, &$queryMs): void {
            $queries++;
            $queryMs += $query->time; // мілісекунди, вимір самого драйвера

            if ($query->time >= 200) {
                // $query->sql — з плейсхолдерами, значення не потрапляють у лог
                Log::channel('perf')->warning('slow query', ['ms' => $query->time, 'sql' => $query->sql]);
            }
        });

        // terminating() виконується вже після відправлення відповіді — нічого не сповільнює.
        $this->app->terminating(function () use (&$queries, &$queryMs): void {
            // Під Octane константа ставиться раз на воркер: там потрібна власна мітка часу.
            $totalMs = (microtime(true) - LARAVEL_START) * 1000;

            if ($totalMs < 1000) {
                return;
            }

            Log::channel('perf')->warning('slow request', [
                'total_ms' => round($totalMs),
                'db_ms' => round($queryMs),   // db_ms ≈ total_ms -> винна база, а не PHP
                'queries' => $queries,        // 200 запитів по 2 мс -> це N+1, а не «повільна БД»
                'peak_mb' => round(memory_get_peak_usage(true) / 1048576, 1),
            ]);
        });
    }
}
Що починають з вимірювання й декомпозиції часу, а не з гіпотези: DevTools waterfall або `Server-Timing` спершу відповідають на питання «повільно на сервері чи в браузері».
Що кандидат розрізняє інструменти за ціною й місцем: Debugbar — лише dev і лише HTML-відповіді, Clockwork — ще й API/CLI/черги, Telescope — запис у БД з watchers і `telescope:prune`, Xdebug-профайлер — офлайн-аналіз із кратним сповільненням, Blackfire/Tideways/Pulse — продакшен.
Що Xdebug у режимі `profile` спотворює пропорції (роздуває виклики функцій відносно I/O) і що сам факт завантаженого розширення в dev робить локальні числа непорівнянними з продом.
Що оптимізують за внеском у загальний час, а не за красою: 300 запитів по 1 мс і один запит на 900 мс лікуються по-різному, і `EXPLAIN` відрізняє одне від одного.
Що `php artisan optimize`, `composer install --no-dev --optimize-autoloader` й правильно налаштований OPcache — це дешева одноразова гігієна на деплої, а не оптимізація алгоритму, і вона не рятує від N+1.
Що в чергу виносять роботу, результат якої не потрібен у цій відповіді (лист, PDF, вебхук, ресайз), і що черга нічого не пришвидшує — вона переносить час на воркер.
Що дивляться на p95/p99 і на медіану, а не на середнє, і міряють на прогрітому кеші й реальних обсягах даних.
Починати з `Cache::remember()` навколо повільного місця: кеш ховає симптом, а перший запит після інвалідації, деплой і кожен новий фільтр повертають ті самі 4 секунди.
Тримати Xdebug увімкненим локально (`xdebug.mode=debug,develop`) і робити з локальних мілісекунд висновки про продакшен — накладні витрати там кратні.
Вважати профайл із `xdebug.mode=profile` картою реального часу: cachegrind добре показує, що викликається мільйон разів, і погано — скільки насправді чекали на мережу й диск.
Ставити `config:cache` і залишати `env('SOMETHING')` у сервіс-провайдері чи моделі: після кешування конфігу `env()` поза файлами `config/` повертає `null`, і це зазвичай виглядає як «зламався інший модуль», а не як помилка деплою.
Робити `route:cache` при закритих у роутах замиканнях і не розуміти `LogicException: Unable to prepare route ... Uses Closure`.
Оптимізувати PHP, коли TTFB — 200 мс, а 6 секунд дає 4 МБ JS і десяток запитів до сторонніх скриптів у браузері.
Тримати Telescope увімкненим у проді з усіма watchers і без `telescope:prune`: кожен запит починає писати десятки рядків у ту саму базу, яку ви й намагаєтесь розвантажити.
Міряти середній час відповіді: одна сторінка адмінки на 12 секунд у середньому по 200 тис. запитів не видно взагалі.
Змінювати п'ять речей одночасно, а потім не мати змоги сказати, що саме допомогло.
ПОРАДА

Скажіть, що у вас є фіксована послідовність: розкласти час (браузер → TTFB → PHP → БД → зовнішні виклики), знайти найбільший доданок, змінити одну річ, повторно виміряти. І одразу назвіть, що дає найбільше на реальних проєктах: кількість запитів, а не їхня швидкість. Фраза, яку запамʼятовують: «кеш і Octane — це не діагностика, а анестезія; спершу треба знати, що болить».

Сторінка питання →
SF
Symfony·Senior ·Messenger ·retry ·failed transport

Транспорт налаштовується з retry_strategy: кількість спроб, початкова затримка й множник; вичерпані повідомлення йдуть у failed transport, а логічні помилки відкидаються одразу через UnrecoverableMessageHandlingException.

Що відбувається з повідомленням, яке впало в handler?
Як відрізнити тимчасову помилку від логічної в черзі?
Як не втратити повідомлення після вичерпання спроб?

Повтори в Messenger — частина конфігурації транспорту, а не коду обробника. retry_strategy задає кількість спроб, початкову затримку, множник і максимальну затримку, тобто експоненційний backoff. При помилці Messenger додає до повідомлення RedeliveryStamp з номером спроби й публікує його назад у транспорт із затримкою, тому лічильник живе в самому повідомленні й працює однаково з AMQP, Doctrine чи Redis.

Надійність починається з класифікації помилок. Тимчасові — таймаут, обрив зʼєднання, deadlock — повторювати є сенс. Логічні — сутність не існує, дані невалідні, картка відхилена — повторювати шкідливо: пʼять спроб із backoff лише відкладають той самий результат. Для них є UnrecoverableMessageHandlingException, яка одразу відправляє повідомлення у failed transport. Зворотний випадок — RecoverableMessageHandlingException, яка змушує повторити навіть після ліміту.

Failed transport — не смітник, а черга на ручний розбір. Після виправлення причини повідомлення повертають через messenger:failed:retry, безнадійні видаляють. На розмір цієї черги має бути алерт, бо саме він, а не кількість retry, показує реальні втрати.

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

// config/packages/messenger.yaml
// framework:
//   messenger:
//     failure_transport: failed
//     transports:
//       payments:
//         dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
//         retry_strategy: { max_retries: 5, delay: 2000, multiplier: 3, max_delay: 300000 }
//       failed: 'doctrine://default?queue_name=failed'
//     routing:
//       App\Message\CapturePayment: payments

#[AsMessageHandler]
final class CapturePaymentHandler
{
    public function __invoke(CapturePayment $message): void
    {
        $order = $this->orders->find($message->orderId)
            ?? throw new UnrecoverableMessageHandlingException('Order gone'); // не повторювати

        if ($order->isCaptured()) {
            return; // ідемпотентність: повтор після часткового виконання безпечний
        }

        try {
            $this->gateway->capture($order->paymentId(), $order->total());
        } catch (GatewayTimeout | ConnectionException $e) {
            throw new RecoverableMessageHandlingException('Gateway unavailable', previous: $e); // повторити з backoff
        } catch (CardDeclined $e) {
            $order->markDeclined($e->reason());
            throw new UnrecoverableMessageHandlingException('Declined', previous: $e);
        }

        $order->markCaptured();
    }
}
Що retry живе в конфігурації транспорту, а не в try/catch усередині handler: max_retries, delay, multiplier, max_delay дають експоненційний backoff.
Розділення помилок на два класи: тимчасові (мережа, таймаут, deadlock) повторюємо, логічні (невалідні дані, сутність не існує) відкидаємо одразу через UnrecoverableMessageHandlingException.
Що RecoverableMessageHandlingException навпаки змушує повторити, навіть якщо ліміт вичерпано.
Що failed transport це не смітник, а черга для ручного розбору: messenger:failed:show, retry, remove, і на її розмір має бути алерт.
Що handler мусить бути ідемпотентним, бо між спробами частина роботи могла виконатись, і що для власної логіки можна реалізувати RetryStrategyInterface.
Реалізовувати повтори циклом try/catch у handler: воркер блокується на sleep, і немає ні backoff, ні обмеження спроб, ні failed transport.
Повторювати все підряд: валідаційна помилка після п'яти спроб з backoff лише відкладає неминуче й засмічує логи.
Не налаштувати failed transport: після вичерпання спроб повідомлення просто зникає з ack.
Ставити однакову затримку без множника: під час падіння зовнішнього API всі повідомлення повертаються одночасно і добивають його.
Ігнорувати, що повідомлення після redelivery може отримати інший воркер: локальний стан у handler між спробами не зберігається.
ПОРАДА

Сильна відповідь містить моніторинг: алерт на розмір failed-черги, а не тільки на кількість retry. І приклад, як ви розділили Unrecoverable і Recoverable у реальному обробнику.

Сторінка питання →
PHP
Core PHP·Senior ·OPcache ·JIT ·preload

OPcache тримає в спільній памʼяті вже скомпільовані опкоди, прибираючи парсинг і компіляцію з кожного запиту — це і є основний виграш; preload (PHP 7.4+) додатково тримає звʼязані класи фреймворку, а JIT (PHP 8.0+, вимкнений за замовчуванням через `opcache.jit_buffer_size=0`) компілює гарячі опкоди в машинний код і майже нічого не дає типовому веб-застосунку, бо той упирається в I/O, а не в CPU.

У нас увімкнули JIT на проді, а сторінки не стали швидшими — чому?
Що саме кешує OPcache: результат виконання скрипта чи щось інше?
Ми задеплоїли новий код, а сервер віддає старий — що не так із налаштуваннями?
Що таке preload і чим він відрізняється від звичайного кешу опкодів?

PHP лишається мовою «кожен запит з нуля»: інтерпретатор читає файл, проганяє його через лексер, парсер і компілятор, отримує масив опкодів і лише тоді починає їх виконувати. Для фреймворку це сотні файлів на кожен запит — і вся ця компіляція викидається одразу після відповіді. Zend OPcache (у ядрі з PHP 5.5) прибирає саме цей крок: скомпільовані опкоди складаються у спільну памʼять (shared memory), доступну всім воркерам PHP-FPM того ж майстра, і наступний запит бере готове. Важливо правильно назвати межу: OPcache кешує опкоди, а не результат — код усе одно виконується щоразу, запити до бази робляться щоразу, шаблони рендеряться щоразу. Саме тому це «безкоштовні» десятки відсотків, а не чарівна кнопка.

Другий, менш очевидний виграш — файлові перевірки. За замовчуванням opcache.validate_timestamps=1 і opcache.revalidate_freq=2: раз на дві секунди OPcache робить stat() на кожен включений файл, щоб помітити зміни. На локальній машині це необхідно, у продакшені — чистий податок на syscalls, тим більший, чим повільніша файлова система (класично боляче на NFS і на монтованих томах у Docker). У продакшені ставлять opcache.validate_timestamps=0, і разом із цим приймають зобовʼязання: код у скомпільованому вигляді відтепер змінює тільки перезапуск. Деплой має закінчуватись graceful reload PHP-FPM (SIGUSR2) або підняттям нового контейнера; opcache_reset() теж працює, але скидає кеш різко для всіх воркерів одночасно, даючи сплеск CPU на перекомпіляції. Пропущений reload — це та сама класична історія «задеплоїли, а на сайті старий код»; жодних помилок у логах при цьому не буде.

Preload (opcache.preload, PHP 7.4+) іде на крок далі. Указаний файл виконується один раз під час старту майстер-процесу — до появи будь-якого запиту, від користувача opcache.preload_user, якщо PHP стартує від root. Усе, що цей файл завантажив через require або opcache_compile_file(), лишається в памʼяті звʼязаним: ієрархія класів побудована, інтерфейси й трейти підключені, тож при запиті такі класи доступні без автозавантажувача взагалі. Обмеження варто озвучувати самому, не чекаючи уточнення: класи, чиї батьки або інтерфейси не резолвляться на момент preload, тихо пропускаються (Can't preload unlinked class); preload-скрипт не повинен робити нічого, що залежить від конкретного запиту; на Windows механізм не підтримується; і головне — оновити preload-код можна лише рестартом, opcache_reset() його не чіпає. Через це preload у dev-оточенні — вірний спосіб згаяти день на «мої правки не застосовуються». Реалістична вигода — одиниці відсотків на середньому фреймворку; Symfony генерує preload.php із prod-контейнера з коробки, Laravel — ні.

JIT (PHP 8.0+) — теж частина OPcache, і в цьому вже половина відповіді. Він працює не з вихідним кодом, а з опкодами: гарячі ділянки транслюються в машинний код у буфер opcache.jit_buffer_size. Найчастіша плутанина на співбесіді — opcache.jit=tracing виглядає як «увімкнено», але за замовчуванням opcache.jit_buffer_size=0, і JIT просто вимкнений; вмикає його саме буфер. Чому веб від нього майже нічого не отримує: у типовому запиті більшість часу — це очікування бази й мережі, а гарячі внутрішні операції (функції масивів, рядків, PCRE, серіалізація) і так виконуються в C, куди JIT не дотягується; додайте до цього короткий час життя запиту, за який трасувальний JIT ледве встигає прогрітись. Виграш у рази реальний на іншому профілі навантаження: чисельні розрахунки, попіксельна обробка зображень, парсери, довгі CLI-процеси. У PHP 8.4 бекенд JIT переписали на нову проміжну репрезентацію (IR framework) — код підтримувати стало легше, порядок величини для веба не змінився.

Практичний висновок для продакшену виглядає нудно і саме тому правильно: увімкнений OPcache із memory_consumption під розмір кодової бази, max_accelerated_files із запасом над реальною кількістю .php-файлів (значення округлюється вгору до наступного числа з ряду 3907/7963/16229/32531/65407/130987), validate_timestamps=0 плюс reload на деплої, save_comments=1 — бо докблоки читає і рефлексія, і низка бібліотек. Далі — метрики з opcache_get_status() у моніторингу: hit rate нижче 95%, ненульові oom_restarts або num_cached_keys під стелею означають, що кеш регулярно скидається і застосунок половину часу живе на холодному. Preload — наступний крок, коли базове вже налаштоване й ви готові платити рестартом. JIT — останній і найчастіше зайвий: якщо профіль не показує, що понад половина часу запиту йде в самому PHP-коді, вмикати його — це додати ризик без прискорення. І, як завжди, найбільші виграші лежать поза цим списком — у запитах до бази, кількості HTTP-викликів і кешуванні відповідей.

<?php
// preload.php — виконується один раз при старті php-fpm (opcache.preload=/app/preload.php)
// php.ini прод:
//   opcache.enable=1
//   opcache.memory_consumption=256          ; SHM під опкоди
//   opcache.interned_strings_buffer=16      ; буфер під інтерновані рядки, МБ
//   opcache.max_accelerated_files=32531     ; більше за реальну кількість .php у релізі
//   opcache.validate_timestamps=0           ; без stat() — деплой зобовʼязаний робити reload FPM
//   opcache.save_comments=1                 ; докблоки потрібні рефлексії; не вимикати
//   opcache.preload=/app/preload.php
//   opcache.preload_user=www-data
//   opcache.jit_buffer_size=0               ; JIT вимкнено: веб-запит упирається в I/O, не в CPU

require __DIR__.'/vendor/autoload.php';     // автозавантажувач потрібен, щоб звʼязати батьків

$classmap = require __DIR__.'/vendor/composer/autoload_classmap.php';

foreach ($classmap as $class => $file) {
    // виключаємо те, що не має бути звʼязаним назавжди або тягне побічні ефекти
    if (str_contains($file, '/tests/') || str_contains($file, '/Database/Migrations/')) {
        continue;
    }

    // compile_file кладе опкоди в SHM; клас, чий батько не резолвиться,
    // буде пропущено з попередженням "Can't preload unlinked class"
    @opcache_compile_file($file);
}

// Перевірка на проді: hit rate має бути 99%+, а рестарти — нульові.
// php -r '$s = opcache_get_status(false); var_dump(
//     $s["opcache_statistics"]["opcache_hit_rate"],
//     $s["opcache_statistics"]["oom_restarts"],   // > 0 => замало memory_consumption
//     $s["opcache_statistics"]["num_cached_keys"], // близько до max => підняти max_accelerated_files
//     $s["preload_statistics"]["memory_consumption"] ?? null
// );'
Що PHP компілює файл при кожному запиті, і OPcache прибирає саме крок «читання файлу → лексер → парсер → компілятор», а не виконання опкодів.
Що `opcache.validate_timestamps=0` у продакшені знімає `stat()` з кожного включеного файлу, але тоді деплой зобовʼязаний завершуватись перезавантаженням FPM (або `opcache_reset()`), інакше сервер віддаватиме старий код.
Що `opcache.preload` (PHP 7.4+) виконується один раз при старті master-процесу, зберігає класи вже звʼязаними і робить їх доступними без автозавантажувача, а оновити їх можна лише рестартом — `opcache_reset()` preload не чіпає.
Що JIT — це частина OPcache, вмикається `opcache.jit_buffer_size`, і дає відчутний ефект тільки на CPU-bound коді (математика, обробка зображень, довгі CLI-процеси), а не на типовому запиті з базою й шаблонами.
Що діагностика ведеться числами з `opcache_get_status()`: hit rate, `num_cached_keys` проти `max_cached_keys`, `wasted_memory`, `oom_restarts` — а не відчуттям «наче швидше».
Казати, що OPcache «кешує результат виконання скрипта» або «кешує сторінки» — він кешує опкоди, і код усе одно виконується при кожному запиті.
Вмикати `opcache.jit=tracing` і вважати, що JIT працює: без ненульового `opcache.jit_buffer_size` він лишається вимкненим.
Ставити `opcache.validate_timestamps=0` і продовжувати деплоїти простим `git pull` без перезавантаження FPM — далі йде година пошуку «чому не застосувались зміни».
Вмикати preload на дев-машині й потім не розуміти, чому правки в класі не видно до рестарту php-fpm.
Лишати `opcache.max_accelerated_files` за замовчуванням у великому проєкті: файлів більше за ліміт, частина класів компілюється щоразу заново, а в статусі росте `misses`.
Вимикати `opcache.save_comments`, щоб «зекономити памʼять»: ламаються всі бібліотеки, які читають докблоки (Doctrine annotations, частина рефлексії в PHPUnit).
Обіцяти бізнесу «PHP 8 з JIT — це вдвічі швидше», спираючись на синтетичні бенчмарки Mandelbrot.
ПОРАДА

Одна фраза, яка закриває питання: «Реальні гроші лежать в OPcache — прибрати компіляцію з кожного запиту й прибрати `stat()` через `validate_timestamps=0`; preload — ще кілька відсотків для фреймворку ціною рестарту на деплої; JIT для веба — статистична похибка, він для CPU-bound коду». Далі одразу додайте, чим замінюєте валідацію таймстемпів: graceful reload FPM у кінці деплою.

Сторінка питання →
PHP
Core PHP·Senior ·памʼять ·GC ·воркери

PHP звільняє памʼять через підрахунок посилань, а окремий збирач циклів періодично прибирає обʼєкти, які посилаються одне на одного.

Чому воркер черги росте в памʼяті, хоча кожен job невеликий?
Що таке refcount і чому його недостатньо?
Коли в PHP взагалі запускається збирач сміття?

Основний механізм керування памʼяттю в PHP — підрахунок посилань. Кожен zval знає, скільки змінних чи властивостей на нього вказують; щойно лічильник падає до нуля, памʼять звільняється негайно, без участі збирача. Саме тому у звичайному запиті про GC можна не думати.

Refcount не справляється з циклами. Якщо два обʼєкти тримають один одного, після unset зовнішніх змінних у кожного лишається одне посилання від сусіда, і нуль недосяжний. Для таких випадків існує збирач циклів: можливі корені накопичуються в root buffer, і коли він заповнюється, алгоритм обходить граф і звільняє недосяжні підграфи. Запуск не привʼязаний до часу, а з PHP 7.3 поріг адаптивний.

Проблемою GC стає в довгих процесах: воркери черг, Octane, RoadRunner, демони. Процес живе години, тому все, що не звільнилось, накопичується: цикли між обʼєктами, статичні кеші, реєстри слухачів, identity map ORM. Виглядає це як повільний ріст RSS воркера до OOM.

Лікування починається з діагностики, а не з gc_collect_cycles. Метрики memory_get_usage у логах воркера, ліміт памʼяті й кількості завдань для контрольованого перезапуску, WeakMap для кешів, привʼязаних до обʼєктів, і gc_status для розуміння, чи взагалі збирач знаходить сміття. Якщо не знаходить, витік у ваших статичних структурах, а не в циклах.

final class Node
{
    public ?Node $peer = null;
}

$a = new Node;
$b = new Node;
$a->peer = $b;
$b->peer = $a;      // цикл: refcount кожного = 2

unset($a, $b);      // refcount кожного = 1, памʼять не звільнена
gc_collect_cycles(); // лише збирач циклів прибере обидва

// Типова пастка у довгому воркері: статичний кеш росте вічно
final class Registry
{
    private static array $seen = [];
    public static function remember(object $o): void { self::$seen[] = $o; }
}

// Безпечна альтернатива: WeakMap не тримає обʼєкт живим
$cache = new WeakMap;
$cache[$order] = computeTotals($order); // зникне разом з $order

// Контроль у воркері
if (memory_get_usage(true) > 256 * 1024 * 1024) {
    exit(12); // супервізор перезапустить процес
}
Що основний механізм це refcount у zval: обʼєкт звільняється миттєво, щойно лічильник посилань падає до нуля.
Що refcount не бачить циклів: два обʼєкти, які тримають одне одного, ніколи не досягнуть нуля, тому існує окремий збирач циклів на root buffer.
Що збирач запускається не за таймером, а коли root buffer заповнюється (10 000 можливих коренів), і в PHP 7.3+ поріг адаптивний.
Практику для довгих процесів: gc_collect_cycles у воркері, memory_get_usage у метриках, обмеження max-jobs або memory для перезапуску воркера, обережність зі статичними кешами.
Розуміння, що витік у воркері частіше спричинений не GC, а вашим кодом: статичні масиви, реєстри подій, накопичені слухачі, identity map ORM.
Казати, що PHP звільняє памʼять лише наприкінці запиту: більшість обʼєктів звільняється одразу при refcount = 0.
Вважати, що unset($obj) гарантовано звільняє обʼєкт: якщо на нього є інші посилання або цикл, памʼять лишається.
Плутати збирач циклів з opcache або з памʼяттю Zend MM: memory_get_usage показує аллокатор PHP, а не RSS процесу.
Лікувати витік через gc_collect_cycles на кожен job, не знайшовши, хто саме тримає посилання.
ПОРАДА

Сильна відповідь містить практику: gc_collect_cycles у довгих воркерах, memory_get_usage у метриках і перезапуск воркера після N запитів. Ще краще розповісти про реальний витік, який ви знайшли.

Сторінка питання →
YII
Yii·Senior ·міграція ·легасі ·strangler

Найбезпечніший підхід — strangler pattern: новий застосунок ставиться поруч, роутинг поступово перекидається на нього, спільною лишається база даних; переписувати все одразу означає роками тримати дві версії продукту.

З якого модуля починати міграцію?
Як тримати дві системи в парі під час переходу?
Переписати з нуля чи мігрувати поступово?

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

Порядок має значення. Спершу характеризаційні тести на критичні сценарії поточної системи: оформлення замовлення, оплата, розрахунок звіту. Вони фіксують поведінку, яку треба зберегти, і закривають суперечки про те, чи баг був раніше. Потім інфраструктура переходу: маршрутизація на рівні nginx або фронт-контролера, спільна автентифікація через Redis-сесію або підписаний токен, спільна база з чіткими правилами володіння таблицями. І лише потім перший модуль: малий, ізольований, але реальний, щоб перевірити весь ланцюжок від деплою до моніторингу.

Далі модулі беруть за пріоритетом бізнесу й частотою змін: те, що змінюється часто, вигідніше мати в новій системі раніше. Логіку з ActiveRecord-моделей спершу виділяють у сервіси ще всередині Yii, а схему бази змінюють лише тоді, коли таблицею володіє одна система. Продукт при цьому не зупиняється: нові фічі пишуться в новій системі, а стара отримує лише виправлення.

// Фронт-контролер перехідного періоду: один вхід, два застосунки
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

$migrated = [
    '#^/api/v2/#',
    '#^/reports/#',
    '#^/account/invoices#',
];

foreach ($migrated as $pattern) {
    if (preg_match($pattern, $path)) {
        require __DIR__.'/../new/public/index.php';   // Symfony або Laravel
        return;
    }
}

(new yii\web\Application(require __DIR__.'/../config/web.php'))->run();

// Той самий підхід на рівні nginx: маршрути перекидаються без релізу PHP-коду
// location ~ ^/(api/v2|reports)/ { proxy_pass http://new-app; }
// location / { fastcgi_pass yii-fpm; }

// Спільна автентифікація: обидві системи перевіряють один підписаний cookie
final class SharedSessionGuard
{
    public function userId(string $cookie): ?int
    {
        [$payload, $signature] = explode('.', $cookie, 2) + [null, null];
        return hash_equals(hash_hmac('sha256', $payload, $this->secret), $signature)
            ? (int) json_decode(base64_decode($payload), true)['uid']
            : null;
    }
}
Порядок: спершу характеризаційні тести на критичні сценарії, потім міграція, а не навпаки.
Що strangler pattern це маршрутизація на рівні проксі або фронт-контролера: нові й перенесені маршрути йдуть у новий застосунок, решта в Yii, і бізнес не помічає переходу.
Що спільна база даних на перехідний період є нормою, а спільні сесії, автентифікація й кеш вимагають окремого рішення від першого дня.
Критерії вибору першого модуля: ізольований, з невеликою кількістю залежностей, але з реальною цінністю, щоб перевірити весь ланцюжок від деплою до моніторингу.
Тверезу оцінку: повне переписування рідко закінчується, бо фічі в старій системі не зупиняються, а різниця між системами росте.
Переписати все за одну ітерацію й переключити в один день.
Починати з найскладнішого модуля або з того, який усі ненавидять, замість малого ізольованого.
Мігрувати без тестів на поточну поведінку й потім сперечатись, чи баг був у старій системі.
Ігнорувати спільні сесії й авторизацію: користувач логіниться в Yii і виявляється анонімним у новій частині.
Зупиняти розвиток продукту до завершення міграції або, навпаки, дублювати кожну нову фічу в обидві системи.
ПОРАДА

Наголосіть на порядку: спершу тести на критичні сценарії, потім міграція, а не навпаки. І поясніть, як вирішили спільну сесію між Yii і новим застосунком.

Сторінка питання →
ARC
Архітектура·Senior ·ідемпотентність ·платежі ·черги

Клієнт передає idempotency key, сервер зберігає його разом із результатом операції під унікальним індексом і на повторний запит повертає збережений результат замість другого платежу.

Клієнт натиснув «Оплатити» двічі: як не списати гроші двічі?
Де зберігати idempotency key і скільки?
Що робити, якщо вебхук від платіжки приходить повторно?

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

Базовий механізм — idempotency key. Клієнт генерує унікальний ключ на бізнес-операцію, а не на HTTP-запит, і передає його заголовком. Сервер атомарно резервує ключ у таблиці з унікальним індексом, виконує операцію й зберігає результат разом із ключем. Повторний запит із тим самим ключем отримує збережену відповідь. Той самий ключ передається платіжному провайдеру, тому навіть падіння між викликом провайдера і збереженням результату не створює другого платежу.

Гонку закриває саме унікальний індекс, а не перевірка в коді: два одночасні запити проходять if (! exists) разом, але лише один пройде INSERT. Стан ключа має проміжне значення processing, щоб повтор під час виконання отримав 409 з Retry-After, а не чужий результат і не другу спробу. Ключ із тим самим значенням, але іншим тілом запиту повертає 422.

Вебхуки провайдера обробляються за тим самим принципом: дедуплікація по event id через унікальний індекс і перевірка стану агрегату перед кожною дією, бо доставка гарантована щонайменше один раз, а не рівно один. Ключі мають час життя, зазвичай від доби до трьох, і чистяться фоновим процесом.

// CREATE TABLE idempotency_keys (key text, client_id bigint, request_hash text,
//   status text, response_code int, response_body jsonb, created_at timestamptz,
//   PRIMARY KEY (client_id, key));

final class ChargeAction
{
    public function __invoke(ChargeRequest $request): JsonResponse
    {
        $key = $request->header('Idempotency-Key') ?? abort(400, 'Idempotency-Key required');
        $hash = hash('sha256', $request->getContent());

        // 1. Атомарне резервування ключа: унікальний індекс закриває гонку
        try {
            IdempotencyKey::create(['key' => $key, 'client_id' => $request->clientId(), 'request_hash' => $hash, 'status' => 'processing']);
        } catch (UniqueConstraintViolationException) {
            $existing = IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)->firstOrFail();

            if ($existing->request_hash !== $hash) {
                abort(422, 'Idempotency-Key reused with a different payload');
            }
            if ($existing->status === 'processing') {
                return response()->json(['status' => 'processing'], 409)->header('Retry-After', '2');
            }

            return response()->json($existing->response_body, $existing->response_code); // збережений результат
        }

        // 2. Сама операція: провайдер отримує той самий ключ, повтор не створить другий платіж
        $charge = $this->gateway->charge($request->amount(), $request->card(), idempotencyKey: $key);

        // 3. Результат зберігається разом з ключем в одній транзакції
        DB::transaction(function () use ($key, $request, $charge) {
            Payment::create(['charge_id' => $charge->id, 'amount' => $request->amount()]);
            IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)
                ->update(['status' => 'succeeded', 'response_code' => 201, 'response_body' => ['charge' => $charge->id]]);
        });

        return response()->json(['charge' => $charge->id], 201);
    }
}
Що ідемпотентність означає однаковий результат при повторі, і що повтори неминучі: подвійний клік, таймаут із retry, redelivery з черги, повторний вебхук.
Що ключ генерує клієнт на одну бізнес-операцію, а не на запит, і сервер зберігає його разом із результатом під унікальним індексом.
Що унікальний індекс у базі є останньою лінією захисту: перевірка існування в коді не закриває гонку двох одночасних запитів.
Що обробка йде через стани: pending, processing, succeeded, failed, і повтор під час processing отримує 409 або чекає, а після завершення отримує збережену відповідь.
Що вебхуки платіжного провайдера дедуплікуються по event id, а стан агрегату перевіряється перед кожною дією, бо доставка щонайменше один раз.
Перевіряти if (! exists) у коді й вважати, що цього досить: два запити проходять перевірку одночасно.
Генерувати ключ на сервері на кожен запит: повторний запит отримує новий ключ і створює другий платіж.
Повертати на повтор новий результат замість збереженого, або повертати 200 без тіла, ламаючи клієнта.
Зберігати ключі вічно без TTL або, навпаки, лише в Redis без гарантій, втрачаючи їх при рестарті.
Не розрізняти повтор того самого запиту й інший запит з тим самим ключем: другий має отримувати 422, а не чужий результат.
ПОРАДА

Згадайте унікальний індекс у БД як останню лінію захисту: перевірка в коді гонку не закриває. І назвіть, як обробляєте повтор під час processing, а не лише після завершення.

Сторінка питання →
LR
Laravel·Senior ·архітектура ·actions ·DTO

Стандартної структури вистачає, доки логіка сценарію вміщається в контролер плюс модель; далі вузьке місце — не назви тек, а те, що бізнес-правило не має одного власника. Дія з одним публічним методом і незмінним DTO на вході дає цього власника без DDD, а тактичні патерни DDD додають лише там, де є інваріанти, які треба захищати, і мова, якою говорить бізнес.

У нас `app/Services` розрісся до шістдесяти класів, `UserService` на 900 рядків — з чого починати рефакторинг?
Коли ви заводите теку `Domain` чи `modules/`, а коли достатньо стандартних `app/Models` і `app/Http`?
Чим Action відрізняється від сервісу і навіщо поруч ще DTO, якщо є Form Request?
Навіщо репозиторій над Eloquent, якщо Eloquent сам по собі Active Record?

Почніть з того, чого стандартна структура насправді не робить. Laravel не нав'язує архітектури: app/ — це просто PSR-4-неймспейс App\, і в Laravel 11 скелет ще й помітно схуднув — зникли app/Http/Middleware/*, app/Console/Kernel.php та app/Exceptions/Handler.php, їхні налаштування переїхали в bootstrap/app.php (withRouting(), withMiddleware(), withExceptions()), а список провайдерів застосунку — у bootstrap/providers.php. Тобто фреймворк уже не диктує навіть тек: php artisan make:controller, make:class, make:enum створюють їх на вимогу, а App\Domain\Billing працюватиме без жодного рядка конфігурації. Через це «великий проєкт розвалився» майже ніколи не означає «не вистачило папок». Означає воно інше: у бізнес-сценарію немає власника. Створення підписки живе трьома рядками в контролері, п'ятьма в Observer, ще трьома в джобі й окремою гілкою в консольній команді, яка робить те саме для імпорту — і коли правило змінюється, треба знайти всі чотири місця.

Стандартної структури — тонкий контролер, Form Request, модель зі скоупами, ресурс на виході — вистачає рівно доти, доки сценарій вміщається в один читабельний метод контролера. Це не «для маленьких проєктів»: у великому застосунку більшість ендпоїнтів так і виглядають, і загортати Model::create($request->validated()) у три класи — чиста втрата. Симптоми, за якими справді пора рухатись далі, конкретні: той самий сценарій викликається більш ніж з одного входу (HTTP, черга, консоль, вебхук); у контролері з'явилася транзакція разом із зовнішнім викликом; ви не можете відповісти на питання «де в коді створюється підписка» одним файлом. Зверніть увагу, що жоден із них не про кількість рядків.

Наступна сходинка — дії. Дія це клас на один сценарій із дієсловом у назві та одним публічним методом: StartSubscription::handle(), CancelOrder::handle(). Laravel сам користується цим патерном — Jetstream кладе app/Actions/Fortify/CreateNewUser.php, і це той самий підхід. Ключова відмінність від звичного app/Services: там клас іменують за сутністю (UserService), тому він приймає все, що стосується користувача, і за рік перетворюється на god-клас без будь-якої межі всередині. Дія такої межі не втрачає — щоб додати логіку, треба або змінити наявний сценарій, або завести новий клас. Дія ж володіє і небезпечними частинами: DB::transaction() живе всередині неї, а не в контролері, бо межа консистентності — властивість сценарію; події диспатчаться після коміту, інакше черга підхопить джобу раніше, ніж транзакція завершиться (для черг це лікується опцією after_commit у config/queue.php або $afterCommit на слухачі, але надійніше просто не кидати подію з Observer моделі).

Вхід дії варто описувати DTO, і це не бюрократія, а різниця в тому, звідки сценарій можна викликати. Form Request — HTTP-об'єкт: він валідує вхід, авторизує, живе в Illuminate\Foundation\Http і потребує реального запиту; передати його в консольну команду чи в ShouldQueue-джобу неможливо. DTO — це final readonly class з іменованими типізованими властивостями (PHP 8.2 дав readonly class, 8.4 — property hooks і асиметричну видимість, якщо потрібні обчислювані поля), який будують з validated(), з CSV-рядка чи з payload вебхука однаково. Дає він три речі: сигнатуру, яку перевіряє PHPStan замість array $data, стабільність при рефакторингу (перейменування поля ламає компіляцію, а не мовчки віддає null) і можливість тестувати сценарій без HTTP взагалі. Якщо ручних DTO багато, spatie/laravel-data замінює їх класами Data з from(), мапінгом імен, виведенням правил валідації з типів і генерацією TypeScript — ціною ще однієї залежності та трохи магії.

Модулі — це вже про залежності між частинами, а не про сценарії. Технічно модуль у Laravel дешевий: другий PSR-4-префікс у composer.json, composer dump-autoload, власний сервіс-провайдер із loadRoutesFrom(), loadMigrationsFrom(), loadViewsFrom() і біндінгами в register(). Дорога частина — дисципліна: без явного правила «Billing спілкується з Catalog лише через опублікований контракт або подію» модулі за півроку зростуться в один. Тому правило має бути виконуваним — pest-plugin-arch (expect('App\Domain')->not->toUse('Illuminate')) для шарів усередині модуля, Deptrac для міжмодульних залежностей, і обидва в CI. Ділити варто за бізнес-спроможностями (Billing, Catalog, Hiring), а не за шарами: modules/Controllers — це стандартна структура з зайвим рівнем вкладеності.

Межа з DDD проходить не там, де з'являються теки Domain/Application/Infrastructure — це шарувата архітектура, і сама по собі вона до DDD відношення не має. Тактичні патерни (агрегати з інваріантами, value objects, репозиторії над власними доменними об'єктами, доменні події) окуповуються за двох умов одночасно: у домені є правила, які треба захищати від некоректних станів, і є мова, якою бізнес справді говорить і яка живе в коді. Тоді ціна — подвійна модель (доменний об'єкт плюс Eloquent для персистентності), ручний мапінг і заборона на Illuminate у домені — купує безпеку змін. Якщо ж правило звучить як «статус із чотирьох значень, при переході в paid шлемо лист», агрегат нічого не захищає, а лише додає класів. Практичний компроміс для великого Laravel-застосунку зазвичай такий: більшість модулів — звичайний Laravel з діями й DTO, Eloquent прямо як модель даних; один-два контексти, де складність реальна, — з повноцінним доменним шаром і арх-тестом, що не пускає туди фреймворк. Про те, як розпізнати такий контекст і що дає стратегічна частина DDD, докладніше в картці [[architecture/when-ddd-helps]].

// DTO: незмінний контракт сценарію. Без Illuminate — його однаково будує
// контролер, консольна команда й споживач черги.
final readonly class NewSubscription
{
    public function __construct(
        public int $customerId,
        public string $planCode,
        public ?string $promoCode = null,
    ) {}
}

// Дія: один сценарій, один публічний метод, залежності через конструктор.
final class StartSubscription
{
    public function __construct(private PaymentGateway $gateway) {}

    public function handle(NewSubscription $input): Subscription
    {
        // Межа транзакції належить дії: не контролеру й не обзерверу моделі.
        $subscription = DB::transaction(function () use ($input): Subscription {
            $customer = Customer::query()
                ->whereKey($input->customerId)
                ->lockForUpdate()   // паралельний запит не створить другу підписку
                ->firstOrFail();

            $charge = $this->gateway->charge($customer, $input->planCode);

            return $customer->subscriptions()->create([
                'plan_code' => $input->planCode,
                'charge_id' => $charge->id,
            ]);
        });

        // Після коміту: слухач не прочитає стан, якого ще немає в базі.
        SubscriptionStarted::dispatch($subscription);

        return $subscription;
    }
}

final class SubscriptionController
{
    // Контейнер сам збудує дію за тайп-хінтом — фасади й new тут не потрібні.
    public function store(StoreSubscriptionRequest $request, StartSubscription $action): JsonResponse
    {
        $data = $request->validated();
        $subscription = $action->handle(new NewSubscription(
            customerId: $request->user()->id,
            planCode: $data['plan'],
            promoCode: $data['promo'] ?? null,
        ));

        return SubscriptionResource::make($subscription)->response()->setStatusCode(201);
    }
}
Що стандартна структура — це не «для маленьких проєктів», а розумний дефолт, і замінюють її за симптомом (сценарій розповзся по контролеру, обзервері, джобі й команді), а не за розміром репозиторію.
Що `app/Services` без правил швидко стає набором god-класів: критерій — один клас на один сценарій із дієсловом у назві (`StartSubscription`), а не один клас на сутність (`UserService`).
Що DTO і Form Request вирішують різні задачі: Form Request валідує HTTP-вхід і живе в `Illuminate`, DTO — типізований контракт сценарію, який однаково приходить з HTTP, консольної команди й черги.
Що дія володіє межею транзакції й побічними ефектами: `DB::transaction()` усередині дії, події — після коміту, а не з обзервера моделі.
Що технічно модуль у Laravel — це PSR-4-неймспейс у `composer.json` плюс власний сервіс-провайдер із `loadRoutesFrom()`/`loadMigrationsFrom()`, і що межі тримаються тестами (`pest-plugin-arch`, Deptrac), а не домовленістю.
Де межа з DDD: агрегати, репозиторії й доменні події виправдані там, де є інваріанти й ubiquitous language; для CRUD це п'ять класів на одну форму.
Починати рефакторинг з тек: створити `Domain/Application/Infrastructure`, перекласти туди ті самі моделі й вважати, що це DDD.
Робити `UserService`, `OrderService`, `ProductService` — один клас на таблицю; через рік це ті самі контролери, лише без HTTP.
Вважати Form Request доменним DTO і тягнути `App\Http\Requests\...` у джобу чи консольну команду — сценарій стає невикликуваним поза HTTP.
Загортати Eloquent у репозиторій, який повертає `Builder`: абстракція протікає повністю, а `with()` і скоупи стають недоступними в місці виклику.
Ставити `DB::transaction()` у контролері й кидати події з `Observer` моделі — слухач стартує до коміту й читає стан, якого ще немає.
Створити теку `modules/` і чекати, що Laravel її підхопить: без запису в `autoload.psr-4` і `composer dump-autoload` жоден клас не завантажиться.
Робити модулі «за шаром» (`modules/Controllers`, `modules/Models`) — це стандартна структура з зайвим рівнем вкладеності.
Заводити інтерфейс до кожного класу «щоб мокати»: одна реалізація й один мок замість тесту на реальному сценарії.
Переносити доменний код у `Domain`, але лишати в ньому `auth()`, `config()` і `now()` — глобальні хелпери прив'язують шар до фреймворка не гірше за `use Illuminate\...`.
ПОРАДА

Сформулюйте критерій, а не структуру: «у кожного бізнес-сценарію має бути рівно одне місце, куди по нього приходять». Далі покажіть сходинки — контролер плюс модель → дія з DTO → модуль зі своїм провайдером і арх-тестом → тактичний DDD у тому одному контексті, де є інваріанти. І назвіть приклад, де ви свідомо зупинилися на першій сходинці.

Сторінка питання →
SQL
SQL·Senior ·транзакції ·ізоляція ·PostgreSQL

MySQL за замовчуванням REPEATABLE READ, PostgreSQL — READ COMMITTED; рівень визначає, які аномалії можливі, але втрачене оновлення закривають не рівнем, а атомарним UPDATE або SELECT FOR UPDATE.

Що таке phantom read і на якому рівні він можливий?
Чому два паралельні запити списали з балансу більше, ніж там було?
Чому SERIALIZABLE вимагає повторювати транзакцію?

Рівень ізоляції визначає, що транзакція бачить із паралельних змін. READ UNCOMMITTED допускає читання незафіксованих даних і в PostgreSQL фактично дорівнює READ COMMITTED. READ COMMITTED бачить лише закомічені дані, але між двома своїми запитами може побачити різні значення. REPEATABLE READ фіксує знімок на початку транзакції; у PostgreSQL це також закриває фантоми, у MySQL InnoDB звичайні читання йдуть зі знімка, а locking reads захищені gap-локами. SERIALIZABLE гарантує результат, еквівалентний послідовному виконанню. За замовчуванням MySQL працює на REPEATABLE READ, PostgreSQL на READ COMMITTED, і для більшості застосунків дефолт лишають.

Практична проблема, яку рівень ізоляції не вирішує, — втрачене оновлення. Два запити читають баланс 100, обидва рахують у PHP і обидва записують результат: одне списання зникає. На READ COMMITTED і REPEATABLE READ це відбувається мовчки. Рішення — не читати й записувати окремо: UPDATE ... SET balance = balance - :amount WHERE balance >= :amount виконує обидві дії атомарно під блокуванням рядка, а нуль affected rows означає недостатньо коштів. Коли між читанням і записом потрібна логіка, застосовують SELECT ... FOR UPDATE у короткій транзакції.

SERIALIZABLE у PostgreSQL реалізований без блокувань: база відстежує залежності й відкидає одну з конфліктних транзакцій із помилкою serialization_failure. Це штатна ситуація, і код має повторити транзакцію цілком. Так само треба обробляти deadlock. У відповіді найкраще працює реальний випадок: подвійне списання, двічі використаний промокод, і те, як саме гонку закрили.

-- Lost update: обидві транзакції читають 100, обидві пишуть 100 - 70 = 30.
-- Рівень ізоляції нижче SERIALIZABLE цього не ловить.
SELECT balance FROM accounts WHERE id = 1;        -- 100 в обох сесіях
UPDATE accounts SET balance = 30 WHERE id = 1;    -- списали 140, баланс 30

-- Рішення 1: атомарний UPDATE з умовою, перевіряємо affected rows
UPDATE accounts
SET balance = balance - 70
WHERE id = 1 AND balance >= 70;                   -- друга сесія отримає 0 rows

-- Рішення 2: блокування рядка на час короткої логіки
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- друга сесія чекає
-- бізнес-перевірки в PHP
UPDATE accounts SET balance = balance - 70 WHERE id = 1;
INSERT INTO ledger (account_id, amount, operation_key) VALUES (1, -70, 'op-9f1c'); -- UNIQUE(operation_key)
COMMIT;

-- Рішення 3: SERIALIZABLE, готові повторити при 40001
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... на serialization_failure застосунок повторює всю транзакцію

-- Черга на базі: воркери беруть різні рядки без очікування
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
Чотири рівні й аномалії, які кожен допускає: dirty read, non-repeatable read, phantom read, і lost update як окрема практична проблема.
Що READ COMMITTED бачить зміни інших транзакцій між своїми запитами, а REPEATABLE READ фіксує знімок на початку і в PostgreSQL також закриває фантоми.
Що патерн прочитати в PHP, порахувати, записати ламається на будь-якому рівні нижче SERIALIZABLE, а рішення це UPDATE з виразом і умовою або SELECT FOR UPDATE.
Що SERIALIZABLE у PostgreSQL це SSI без блокувань, який відкидає транзакцію з помилкою serialization_failure, тому код мусить бути готовий повторити її.
Досвід реальної гонки: подвійне списання, дублікат промокоду, перевищення ліміту, і як саме її закрили.
Вважати, що REPEATABLE READ гарантує відсутність будь-яких аномалій, зокрема lost update.
Вважати, що вищий рівень ізоляції автоматично захищає від подвійного списання: без блокування або атомарного UPDATE дві транзакції все одно прочитають той самий баланс.
Використовувати SELECT FOR UPDATE без транзакції або з довгою логікою всередині, тримаючи блокування на час HTTP-запиту.
Не обробляти deadlock і serialization failure: замість повтору транзакції показувати користувачу 500.
Не знати, що в MySQL REPEATABLE READ фантоми частково закриті gap-локами, а в PostgreSQL знімком, і поведінка при UPDATE конфліктних рядків відрізняється.
ПОРАДА

Наведіть реальний випадок гонки, який ловили в продакшені — це переконує краще за перелік рівнів. І покажіть, що знаєте різницю дефолтів MySQL і PostgreSQL.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти