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

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

Питання для Junior, Middle та Senior PHP-розробників: типи й приведення, ООП, замикання, памʼять і збирач сміття, PHP 8.x-можливості, які вже питають на реальних інтервʼю.

Тема
Рівень
16 питань
PHP
Core PHP·Junior ·порівняння ·приведення типів ·PHP 8

Оператор == порівнює значення з приведенням типів, === порівнює і значення, і тип без жодних перетворень.

Що поверне 0 == 'abc' у PHP 8? А в PHP 7?
Чому in_array знайшов елемент, якого там немає?
Коли ви свідомо використовуєте нестроге порівняння?

Оператор == порівнює значення після приведення до спільного типу, === порівнює значення і тип без перетворень. Через це === завжди передбачуваний: '1' === 1 ніколи не стане true, бо це рядок і ціле число.

У PHP 8 правила приведення для == стали строгішими. Число порівнюється з рядком як число лише тоді, коли рядок числовий ('42', ' 1.5'). Нечисловий рядок більше не перетворюється на 0: замість цього число приводиться до рядка, і порівняння йде посимвольно. Саме тому 0 == 'abc' у PHP 8 повертає false, а в PHP 7 повертало true.

Нестроге порівняння живе не лише в операторі. in_array, array_search, array_keys з другим аргументом і switch за замовчуванням використовують ==. Тому в них потрібно передавати true третім аргументом або замінити switch на match, який порівнює строго.

Окремий випадок: float. === порівнює біти, тому 0.1 + 0.2 === 0.3 дає false через похибку представлення. Тут не допомагає жоден оператор, потрібен допуск або тип без плаваючої точки.

var_dump(0 == 'abc');      // PHP 8: false, PHP 7: true
var_dump('1' == '01');     // true: обидва числові рядки
var_dump('1' === '01');    // false: різні рядки
var_dump(null == false);   // true
var_dump(null === false);  // false: різні типи

// Та сама пастка всередині in_array
var_dump(in_array('1e1', ['10']));        // true: '1e1' == '10' як числа
var_dump(in_array('1e1', ['10'], true));  // false: строгий режим

// switch порівнює нестрого: '0' == false дає true, тому спрацює перша гілка
switch ('0') {
    case false: echo 'false'; break;
    case '0':   echo 'zero';  break;
}

// match порівнює строго
echo match ('0') { false => 'false', '0' => 'zero' };  // zero
Що == спершу приводить операнди до спільного типу, а === не робить цього ніколи, тому воно передбачуване й швидше.
Що PHP 8 змінив правила: число порівнюється з нечисловим рядком як рядок, тому 0 == 'abc' тепер false.
Що ця ж різниця живе всередині in_array, array_search і switch, і там теж потрібен strict-режим.
Що в продакшн-коді ви за замовчуванням пишете === і declare(strict_types=1), а == лишаєте для свідомих випадків.
Казати, що === «просто суворіше», без пояснення, що саме відбувається з типами.
Плутати правила PHP 7 і PHP 8: у PHP 7 0 == 'abc' було true, у PHP 8 уже ні.
Забувати, що in_array і switch за замовчуванням нестрогі: пошук 0 у масиві рядків дає хибне знайдено.
Порівнювати float через === і дивуватись, що 0.1 + 0.2 === 0.3 дає false: тут потрібен допуск, а не оператор.
ПОРАДА

Скажіть, що в продакшн-коді за замовчуванням використовуєте === і declare(strict_types=1), а == лишаєте для випадків, де приведення справді потрібне, і наведіть один такий випадок.

Сторінка питання →
PHP
Core PHP·Junior ·ООП ·interface ·abstract class

Інтерфейс — це публічний контракт без стану, і їх можна реалізувати скільки завгодно; абстрактний клас — частково готова реалізація зі станом, конструктором і protected-методами, але успадкувати можна лише один.

Навіщо інтерфейс, якщо абстрактний клас теж уміє оголошувати методи без тіла?
Скільки інтерфейсів можна реалізувати і скільки класів успадкувати?
Чи можна в інтерфейсі оголосити властивість або protected-метод?
У вас два класи з однаковим кодом усередині — зробите спільного батька чи інтерфейс?

На рівні мови інтерфейс — це оголошення публічного API без жодної реалізації: список сигнатур методів, константи і (з PHP 8.4) вимоги до властивостей у формі хуків public string $name { get; }. Усі методи інтерфейсу автоматично публічні, спроба написати protected function дає фатальну помилку «Access type for interface method must be public». Абстрактний клас — це звичайний клас, якому заборонено new: у ньому вільно живуть властивості, конструктор, private/protected-методи, готові реалізації і поряд з ними методи з модифікатором abstract, тобто без тіла. Обидва не інстанціюються, обидва створюють повноцінний тип, який можна вказати в type hint і перевірити через instanceof.

Найважливіша практична різниця — не «є тіло чи немає», а кількість. Клас має рівно одного батька (extends), а інтерфейсів реалізує скільки завгодно (implements A, B, C); сам інтерфейс теж може успадкувати одразу кілька інтерфейсів. Тому наслідування — це ресурс, який витрачається один раз, і витрачати його варто на головну вісь класифікації. Саме звідси випливає критерій вибору: інтерфейс відповідає на питання «що обʼєкт уміє» і легко комбінується (Arrayable, JsonSerializable, PaymentGateway), абстрактний клас відповідає «як він це частково робить» і задає жорстке відношення «is-a».

Найчастіше їх не протиставляють, а поєднують, як у прикладі: інтерфейс PaymentGateway описує контракт, абстрактний HttpGateway implements PaymentGateway тримає спільний стан (HTTP-клієнт, ключ) і спільний алгоритм, а нащадок дописує лише те, що справді відрізняється — це шаблонний метод (Template Method). Абстрактний клас має право реалізувати інтерфейс частково: PHP вимагатиме повноти лише від першого конкретного нащадка. Типізувати в решті коду треба інтерфейс, а не абстрактний клас — тоді контейнер Laravel біндить PaymentGateway::class на потрібну реалізацію, а в тесті ви підставляєте фейк, не тягнучи за собою конструктор батька.

Обмеження абстрактного класу проявляються швидко. Він не може бути final одночасно з abstract, його private-методи не бачать нащадки, а якщо нащадок оголошує власний конструктор і забуває parent::__construct(), типізовані властивості батька лишаються неініціалізованими і код падає з «must not be accessed before initialization». Ще одна пастка — спокуса скласти в базовий клас усі спільні хелпери: чим більше туди потрапляє, тим менше шансів, що нащадок згодом переїде в іншу ієрархію, бо батько єдиний. Коли потрібне саме перевикористання коду без спільного типу, беруть трейт; коли потрібні дві незалежні осі — композицію і два інтерфейси, які з PHP 8.1 можна вимагати разом через intersection type A&B.

Компроміс на боці інтерфейсів теж є, і про нього рідко згадують на джуніорських співбесідах: інтерфейс — це публічна обіцянка, яку дорого міняти. Додавання нового методу ламає всі існуючі реалізації, зокрема чужі, тоді як новий метод із тілом в абстрактному класі просто успадковується. Тому інтерфейси тримають вузькими (принцип Interface Segregation: краще PaymentGateway і RefundsPayments окремо, ніж один роздутий), а розширення оформлюють новим інтерфейсом з перевіркою instanceof. З версійних деталей варто памʼятати: перевизначати константи інтерфейсу в класі дозволено з PHP 8.1, типізовані константи зʼявилися у 8.3, а оголошення властивостей в інтерфейсі — лише у 8.4 і лише через хуки.

interface PaymentGateway            // контракт: лише public-методи, жодного стану
{
    public function charge(int $amountUah, string $token): string;
}

interface RefundsPayments           // окремий вузький контракт: вміє не кожен шлюз
{
    public function refund(string $paymentId): void;
}

// Абстрактний клас реалізує контракт частково: спільний код тут, специфіка — у нащадках
abstract class HttpGateway implements PaymentGateway
{
    public function __construct(
        protected readonly HttpClient $http,   // стан, якого в інтерфейсі бути не може
        private readonly string $apiKey,
    ) {
    }

    abstract protected function endpoint(): string;  // нащадок зобовʼязаний дати URL

    public function charge(int $amountUah, string $token): string
    {
        $response = $this->post($this->endpoint(), ['amount' => $amountUah, 'token' => $token]);

        return $response['id'];
    }

    protected function post(string $url, array $payload): array  // protected в інтерфейсі неможливий
    {
        return $this->http->post($url, $payload + ['key' => $this->apiKey]);
    }
}

final class StripeGateway extends HttpGateway implements RefundsPayments
{
    protected function endpoint(): string
    {
        return 'https://api.stripe.com/v1/charges';
    }

    public function refund(string $paymentId): void
    {
        $this->post('https://api.stripe.com/v1/refunds', ['charge' => $paymentId]);
    }
}

new HttpGateway($http, 'key');   // Error: Cannot instantiate abstract class HttpGateway
new StripeGateway($http, 'key') instanceof PaymentGateway;  // true: інтерфейс успадкувався від батька

function payAndAllowRefund(PaymentGateway&RefundsPayments $gateway): void {}  // intersection type, PHP 8.1+
Ключове обмеження: `extends` — тільки один клас, `implements` — скільки завгодно інтерфейсів, а інтерфейс може успадковувати одразу кілька інтерфейсів.
Що інтерфейс не має стану: до PHP 8.4 властивості в ньому взагалі заборонені, а в 8.4 оголошення `public string $name { get; }` — це вимога до хука, а не поле.
Що методи інтерфейсу можуть бути лише `public`, а абстрактний клас вільно тримає `protected`/`private`, конструктор і готові методи.
Правило вибору: спільний код і відношення «is-a» → абстрактний клас; точка підміни, яку типізуємо й мокаємо → інтерфейс. Часто разом: інтерфейс як контракт, абстрактний клас як зручна база.
Що обидва не інстанціюються, обидва працюють у type hint та `instanceof`, а DI-контейнер біндять саме на інтерфейс.
Що додати метод у published-інтерфейс — це BC break для всіх реалізацій, а додати метод з тілом в абстрактний клас — ні.
Казати «інтерфейс — це просто клас без реалізації, різниці немає»: різниця в тому, що абстрактних батьків може бути лише один.
Оголошувати `protected function` чи `private function` в інтерфейсі — фатальна помилка: «Access type for interface method must be public».
Пробувати покласти в інтерфейс властивість `public array $items;` — до PHP 8.4 це фатальна помилка, у 8.4 працює тільки форма з хуками `{ get; }`.
Вважати, що абстрактний клас зобовʼязаний мати хоч один `abstract`-метод: клас без жодного абстрактного методу теж може бути `abstract`, просто його не можна створити через `new`.
Звужувати видимість або типи в нащадку (`protected` замість `public`, вужчий тип параметра) — PHP відмовиться завантажити клас.
Забути `parent::__construct()` у нащадка абстрактного класу — властивості батька лишаться неініціалізованими і впадуть на typed property.
Ліпити абстрактний клас-«помийку» зі спільними хелперами замість трейта або окремого сервіса — потім із нього неможливо вилізти, бо батько лише один.
ПОРАДА

Скажіть формулою: «інтерфейс відповідає на питання *що вміє* обʼєкт, абстрактний клас — *як він це частково робить*». І одразу додайте практичний критерій: якщо в спільній частині зʼявляється стан або protected-метод — це абстрактний клас; якщо потрібна лише точка підміни для контейнера і тестів — інтерфейс.

Сторінка питання →
PHP
Core PHP·Junior ·enum ·PHP 8.1 ·backed enum

Enum (PHP 8.1+) — тип із фіксованим переліком case-ів; pure enum не має скалярного значення, backed enum привʼязаний до int або string і вміє from()/tryFrom().

Навіщо enum, якщо є константи класу?
Що поверне tryFrom, якщо такого значення в enum немає?
Чому json_encode падає на вашому enum, а на сусідньому працює?
Можна в enum додати властивість, щоб зберігати стан кожного case?

Enum зʼявився в PHP 8.1 і закриває стару проблему «магічних рядків»: замість набору констант класу ви отримуєте окремий тип із фіксованим переліком значень. Кожен case — це обʼєкт-одинак, створений один раз на процес, тому порівняння через === завжди коректне, а new OrderStatus(...) заборонено. Найважливіший практичний наслідок — типізація: сигнатура public function markAs(OrderStatus $status): void фізично не дає передати 'payed' з одруківкою, і перевірку робить не ваш if, а сам PHP.

Різниця pure і backed — у наявності скалярного значення. Pure enum (enum Weekday { case Mon; }) реалізує інтерфейс UnitEnum і має лише властивість name — рядок з іменем case-у. Backed enum оголошується з типом (enum OrderStatus: string), реалізує BackedEnum, додає властивість value і два статичні методи: from() повертає case або кидає \ValueError, tryFrom() повертає case або null. Правило просте: from() — для значень, у яких ви впевнені (константи, дані з власної бази), tryFrom() — для всього, що прийшло від користувача чи зовнішнього API. Метод cases() є в обох видах і повертає масив усіх case-ів у порядку оголошення — з нього роблять селекти, сідери й перевірки.

Enum — це майже повноцінний тип: у ньому можна оголошувати методи, статичні методи, константи, підключати трейти й реалізовувати інтерфейси. Чого не можна — властивостей і будь-якого стану: enum не має конструктора, всі case-и незмінні. Тому «додаткові дані» до case-у оформлюють методом, найчастіше через match ($this), як у прикладі з label(). match тут природний партнер enum: він порівнює строго через ===, а якщо жодна гілка не підійшла, кидає \UnhandledMatchError — це краще за мовчазний default, бо новий case ламає код одразу, а не в проді.

У Laravel backed enum стає типом атрибута моделі: protected function casts(): array { return ['status' => OrderStatus::class]; } (enum-cast доступний з Laravel 9, метод casts() — з Laravel 10; раніше те саме писали у властивості $casts). Читання дає обʼєкт enum, запис приймає обʼєкт і кладе в колонку value. Pure enum так кастити не можна — усередині Eloquent викликає tryFrom(). Так само enum підтримується в implicit route binding і у валідації через Rule::enum(OrderStatus::class).

Межі варто назвати чесно. json_encode серіалізує backed enum у його value автоматично, а на pure enum кидає виняток, бо представлення за замовчуванням у нього немає. У базі колонку зазвичай лишають звичайним varchar замість нативного ENUM MySQL — тоді додавання нового case-у не вимагає ALTER TABLE, а PostgreSQL взагалі не має сумісного синтаксису. І пам'ятайте, що значення backed enum — це частина контракту з базою та API: перейменувати case Paid можна майже безкарно, а змінити 'paid' на 'payed' — уже міграція даних.

enum OrderStatus: string          // backed enum: у кожного case є рядкове значення
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Cancelled = 'cancelled';

    public const Default = self::Pending;   // константа-псевдонім, не новий case

    /** У методі $this — це сам case */
    public function label(): string
    {
        return match ($this) {              // match порівнює через ===
            self::Pending => 'Очікує оплати',
            self::Paid => 'Оплачено',
            self::Cancelled => 'Скасовано',
        };                                  // без default: новий case одразу зламає тест
    }
}

enum Weekday { case Mon; case Tue; }        // pure enum: значення немає

OrderStatus::from('paid');        // OrderStatus::Paid
OrderStatus::tryFrom('deleted');  // null — для даних ззовні
OrderStatus::from('deleted');     // \ValueError
OrderStatus::cases();             // [Pending, Paid, Cancelled]
OrderStatus::Paid->value;         // 'paid'   (лише в backed)
OrderStatus::Paid->name;          // 'Paid'   (є в обох)
Weekday::Mon->value;              // помилка: у pure enum немає value

OrderStatus::Paid === OrderStatus::from('paid'); // true: case — одинак
OrderStatus::Paid === 'paid';                    // false: обʼєкт != рядок

// Eloquent: у колонці лежить 'paid', у моделі — обʼєкт enum
final class Order extends Model
{
    protected function casts(): array
    {
        return ['status' => OrderStatus::class]; // працює тільки з backed enum
    }
}

$order->status->label();          // 'Оплачено'
$order->status = OrderStatus::Paid;
$order->save();                   // у базу піде 'paid'
Що enum зʼявився в PHP 8.1, а кожен case — це обʼєкт-одинак, тому порівнювати їх треба через === без жодних хитрощів.
Що pure enum реалізує UnitEnum і має лише властивість name, а backed enum реалізує BackedEnum, має ще value і методи from()/tryFrom().
Різницю from() і tryFrom(): from() кидає \ValueError на невідомому значенні, tryFrom() повертає null — саме його беруть для даних ззовні.
Що cases() повертає масив усіх case-ів по порядку оголошення і використовується для селектів, валідації та сідерів.
Що enum може мати методи, константи, статичні методи, трейти й реалізовувати інтерфейси, але не може мати власних властивостей і стану.
Практику Laravel: cast моделі на backed enum, match у методі label(), Rule::enum у валідації.
Казати, що enum — це просто набір констант: константи не дають типізації параметра, а enum дає (OrderStatus $status).
Використовувати from() для даних із запиту чи API: невідоме значення кине \ValueError і 500 замість валідаційної помилки.
Порівнювати case зі скаляром: OrderStatus::Paid === 'paid' завжди false, потрібне ->value.
Пробувати додати властивість у enum (public string $label) — це фатальна помилка, enum не має стану; значення дають через backed value або метод.
Кастити в Eloquent pure enum: cast працює лише з backed enum, бо всередині викликається tryFrom().
Очікувати, що json_encode серіалізує будь-який enum: pure enum кидає виняток, бо не має представлення за замовчуванням.
ПОРАДА

Сформулюйте одним реченням: pure enum — це іменований перелік, backed enum — той самий перелік плюс міст до бази й API через value. І одразу додайте, що з зовнішніми даними використовуєте tryFrom(), а не from().

Сторінка питання →
PHP
Core PHP·Junior ·readonly ·immutability ·PHP 8.1

readonly дозволяє записати типізовану властивість рівно один раз зі скоупу класу; з PHP 8.2 модифікатор можна поставити на весь клас.

Як зробити обʼєкт незмінним без приватних властивостей і геттерів?
Чому не можна змінити властивість, хоча вона public?
Чим readonly відрізняється від const і від private з геттером?
Що робити, якщо потрібна копія обʼєкта з іншим значенням readonly-поля?

Модифікатор readonly зʼявився в PHP 8.1 і застосовується до типізованої властивості екземпляра. Правило одне: у таку властивість можна записати значення рівно один раз і лише з коду класу, який її оголосив. Тому найчастіша форма — просування властивостей у конструкторі: public function __construct(public readonly int $amount) {}. Значення за замовчуванням заборонене (public readonly int $x = 5; не скомпілюється), тип обовʼязковий, static readonly не існує. Спроба присвоїти вдруге або ззовні дає Error: Cannot modify readonly property Money::$amount, а unset()Error: Cannot unset readonly property.

Важливо не плутати readonly з const і з «незмінним обʼєктом». const — константа класу, спільна для всіх екземплярів і відома на етапі компіляції; readonly — значення конкретного обʼєкта, обчислене в рантаймі під час створення. І захист тут поверхневий: заборонено перезаписати саму властивість, а не те, що всередині. Масив у readonly-властивості змінити не можна ($this->items[] = $x — це модифікація властивості), а от у вкладеного обʼєкта цілком можна викликати мутуючий метод. Глибока незмінність досягається лише тим, що всередину кладуть скаляри або теж незмінні обʼєкти.

PHP 8.2 додав readonly class: усі властивості екземпляра стають readonly автоматично, нетипізовані й статичні властивості заборонені, динамічні властивості теж (#[AllowDynamicProperties] на такий клас поставити не можна), а нащадок readonly-класу зобовʼязаний бути readonly. Це майже готове визначення value object: readonly class OrderId { public function __construct(public string $value) {} }.

Найболючіше місце readonly — зміна значення в копії. Сам clone працював завжди, але в PHP 8.1–8.2 навіть __clone() не міг перезаписати readonly-властивість, тому глибоке копіювання доводилося емулювати конструктором. PHP 8.3 це виправив: під час __clone() readonly-властивості вважаються неініціалізованими й приймають один новий запис. PHP 8.5 пішов далі й дав синтаксис clone $obj with { amount: 50 }: нові значення застосовуються одразу після копіювання і до виклику __clone(), а перевірки видимості й readonly виконуються за скоупом, у якому стоїть вираз — тобто readonly-поле так само можна змінити лише зсередини класу. Ручні withX()-методи від цього не зникають, але їхнє тіло стискається до одного рядка.

Практичний критерій вибору такий. readonly замінює приватний сеттер (і зв’язку «private-властивість плюс геттер») там, де значення задається під час створення й більше ніколи не змінюється: ідентифікатори, гроші, DTO вхідного запиту, налаштування сервісу. Якщо ж значення має оновлюватися пізніше або обчислюватися ліниво, readonly лише заважає — жодного кешу чи memoize всередині такого обʼєкта не зробити. Для випадку «читають усі, пише лише клас, але не один раз» у PHP 8.4 є асиметрична видимість public private(set) int $x, а для обчислюваних значень — property hooks.

final class Money
{
    public function __construct(
        public readonly int $amount,        // типізована, без default, пишеться раз
        public readonly string $currency,
    ) {}

    public function withAmount(int $amount): self
    {
        // PHP 8.5: копія зі зміненим полем; readonly перезаписується
        // лише зі скоупу класу, який його оголосив
        return clone $this with { amount: $amount };
        // до 8.5: return new self($amount, $this->currency);
    }
}

$price = new Money(100, 'UAH');
echo $price->amount;          // читати можна звідусіль
// $price->amount = 200;      // Error: Cannot modify readonly property
// unset($price->amount);     // Error: Cannot unset readonly property

// PHP 8.2: усі властивості класу readonly автоматично
readonly class OrderId
{
    public function __construct(public string $value) {}
}

final class Order
{
    public function __construct(public readonly Money $total) {}

    public function __clone(): void
    {
        // PHP 8.1–8.2: Error; з 8.3 readonly можна перезаписати в __clone
        $this->total = clone $this->total;
    }
}
Що readonly зʼявився в PHP 8.1 для властивостей, а readonly class — у PHP 8.2, і що readonly-клас робить readonly всі свої властивості.
Що властивість має бути типізованою, без значення за замовчуванням, не static, і що записати її можна лише зі скоупу класу, який її оголосив, — навіть якщо вона public.
Що readonly не означає глибокої незмінності: обʼєкт усередині readonly-властивості можна мутувати, забороняється лише перезапис самого посилання.
Що зміна значення дає Error: Cannot modify readonly property, а не warning, і що unset() readonly-властивості теж кидає Error.
Практику клонування: у 8.1–8.2 __clone не може перезаписати readonly, з 8.3 може, а з 8.5 є `clone ... with { ... }` замість ручних withX()-конструкторів.
Писати `public readonly int $x = 5;` — readonly-властивість не може мати значення за замовчуванням, це помилка компіляції.
Оголошувати `public readonly $x;` без типу — «Readonly property must have type».
Присвоювати readonly-властивість ззовні класу (`$obj->x = 1;`) і чекати, що public це дозволить: ініціалізація можлива лише зі скоупу оголошення.
Вважати readonly аналогом const: const — це константа класу, обчислена на етапі компіляції й спільна для всіх, readonly — значення конкретного екземпляра, задане в рантаймі.
Ставити readonly на властивість, яку треба лениво заповнити або перерахувати пізніше: кеш чи memoize всередині обʼєкта після цього неможливий.
ПОРАДА

Скажіть, що readonly — це не «незмінний обʼєкт», а «властивість, у яку можна записати один раз зі скоупу класу», і одразу назвіть шлях зміни: новий екземпляр, а з PHP 8.5 — `clone $this with { ... }`.

Сторінка питання →
PHP
Core PHP·Junior ·closure ·use ·arrow function

Замикання — це обʼєкт класу Closure; `function` захоплює зовнішні змінні лише явно через `use` (за значенням або за посиланням через `&`), а стрілочна функція `fn` (PHP 7.4+) захоплює їх автоматично й завжди за значенням.

Чому змінна, яку я змінив після оголошення closure, усередині нього стара?
Чим `fn` відрізняється від `function` — це просто коротший запис?
Що таке `use (&$total)` і навіщо там амперсанд?
Чому в замиканні всередині класу працює `$this`, хоча ви його нікуди не передавали?

Замикання в PHP — це не окрема мовна конструкція «функція всередині функції», а звичайний обʼєкт класу Closure. Тому його можна покласти в змінну, передати аргументом, повернути з методу, зберегти у властивості й викликати як $fn(...). Ключова властивість — воно памʼятає частину контексту, у якому було оголошене. Але «памʼятає» не автоматично: анонімна функція, оголошена через function, бачить лише свої параметри, глобальні суперглобали й те, що ви явно перелічили в use. Це принципова відмінність від JavaScript, де замикання затягує всю зовнішню область видимості; у PHP список захоплення пишеться руками.

Найважливіший момент, на якому валяться на співбесідах: use ($x) спрацьовує в момент оголошення замикання, а не в момент виклику. PHP копіює поточне значення $x у внутрішнє сховище обʼєкта Closure, і подальші зміни зовнішньої змінної на нього не впливають — саме це показує перший блок коду. Якщо потрібен звʼязок в обидва боки, пишуть use (&$x): тоді замикання і зовнішній код працюють з однією змінною, і присвоєння всередині видно назовні. Окремо варто розрізняти семантику типів: копіювання змінної, що тримає обʼєкт, копіює лише ідентифікатор, тому зміни властивостей обʼєкта видно й без &; масив же копіюється по-справжньому, і зовнішнє додавання елемента в замикання не потрапить.

Стрілочні функції зʼявилися в PHP 7.4 і закривають найчастіший випадок — коротке перетворення в array_map, usort, колекціях Laravel. Синтаксис fn (аргументи) => вираження має три жорсткі властивості. По-перше, захоплення неявне: усі зовнішні змінні, згадані в тілі, захоплюються автоматично, use для fn не існує і є синтаксичною помилкою. По-друге, захоплення завжди за значенням — накопичувач на fn не побудувати, для цього лишається function () use (&$total). По-третє, тіло — рівно одне вираження, яке автоматично і повертається: ні {}, ні return, ні кількох інструкцій там бути не може. Типи параметрів і повернення при цьому дозволені (fn (int $n): int => ...), і зловживати їх пропуском не варто.

Окрема тема — $this. Замикання, оголошене в методі, автоматично привʼязує поточний обʼєкт і область видимості класу, тому всередині доступні навіть private-властивості — це видно на $withTitle у прикладі. Слово static перед function або fn цю привʼязку знімає: $this стає недоступним, а спроба привʼязати обʼєкт через bindTo() дасть попередження і null. Практичний мотив писати static — час життя: замикання зі звʼязаним $this утримує обʼєкт у памʼяті, і для довгоживучих callback-ів (реєстрація в контейнері, слухачі подій, кеш замикань) це зайве утримання. У зворотний бік працюють Closure::bind() / bindTo() / Closure::call(), які підставляють інший $this і scope; на цьому побудовані макроси Laravel.

Межі варто назвати чесно. Замикання не бачить змінних зовнішньої функції без use, і global тут не заміна — це інший механізм із іншими проблемами. fn не є «кращим function»: щойно потрібні дві інструкції, try/catch або посилання, повертаються до повної форми. І в обох випадках залишається питання читабельності списку захоплення: якщо в use більше двох-трьох змінних, це майже завжди сигнал, що замість замикання просився окремий метод або невеликий клас із __invoke().

$multiplier = 3;

// use копіює ЗНАЧЕННЯ в момент оголошення замикання
$byValue = function (int $n) use ($multiplier): int {
    return $n * $multiplier;
};

// fn (PHP 7.4+) захоплює автоматично і теж лише за значенням
$arrow = fn (int $n): int => $n * $multiplier;

$multiplier = 10;                 // змінюємо ПІСЛЯ оголошення
echo $byValue(2);                 // 6  — захоплено ще трійку
echo $arrow(2);                   // 6  — так само

// use (&$x): замикання і зовнішній код ділять одну змінну
$total = 0;
$accumulate = function (int $price) use (&$total): void {
    $total += $price;             // пишемо назовні
};
$accumulate(100);
$accumulate(50);
echo $total;                      // 150

final class Report
{
    private string $title = 'Звіт';

    /** @return array<int, string> */
    public function render(array $rows): array
    {
        // звичайне замикання само привʼязує $this — приватне поле доступне
        $withTitle = fn (string $row): string => "{$this->title}: {$row}";

        // static прибирає привʼязку: $this всередині недоступний
        $trim = static fn (string $row): string => trim($row);

        return array_map($withTitle, array_map($trim, $rows));
    }
}

$counter = Closure::bind(
    function (): string { return $this->title; },  // $this підставимо ззовні
    new Report(),
    Report::class                 // scope дає доступ до private
);
echo $counter();                  // 'Звіт'
Що замикання — це не «просто функція», а обʼєкт класу `Closure`: його можна покласти в змінну, передати аргументом, повернути з методу і викликати через `$fn()` або `$fn->call()`.
Що `use ($x)` копіює значення в момент оголошення замикання, а не в момент виклику — тому подальші зміни `$x` зовні на замикання не впливають.
Що `use (&$x)` захоплює за посиланням: замикання і зовнішній код працюють з однією змінною, і саме так накопичують суму або збирають результат.
Що обʼєкт, захоплений за значенням, усе одно лишається тим самим обʼєктом — копіюється змінна, а не те, на що вона вказує.
Що `fn` (PHP 7.4+) — це одне-єдине вираження, без `{}`, без `return`, з автоматичним захопленням по значенню, і посилання в ньому неможливе взагалі.
Що звичайне замикання, оголошене в методі, автоматично привʼязує `$this`, а `static function () {}` / `static fn () =>` цю привʼязку прибирають.
Казати, що `use` «передає змінну в функцію»: параметри передають при виклику, `use` захоплює при оголошенні — між цими моментами може пройти будь-який час.
Очікувати, що `$sum` зміниться після `$fn()`, коли написано `use ($sum)` без `&` — зовнішня змінна лишиться незмінною.
Ставити `&` «про всяк випадок» у циклі: усі замикання почнуть ділити одну змінну й після циклу віддаватимуть її останнє значення.
Думати, що `fn` захоплює за посиланням або що в нього можна дописати `use` — синтаксис `fn ($x) use ($y) => ...` є помилкою парсингу.
Писати в `fn` кілька інструкцій через `;` — стрілочна функція має рівно одне вираження, яке і є результатом.
Дивуватись «Using $this when not in object context» у замиканні, яке оголосили через `static function`, або тому, що `$this` витік у замикання, збережене в контейнері й тримає обʼєкт від збирання.
ПОРАДА

Одна фраза, яка закриває питання: «`function () use ($x)` — явне захоплення, за значенням, або за посиланням через `&`; `fn () => $x` — неявне захоплення, тільки за значенням, тільки одне вираження». Далі додайте, що `$this` захоплюється в обох, і зняти його можна словом `static`.

Сторінка питання →
PHP
Core PHP·Middle ·strict_types ·declare ·TypeError

`declare(strict_types=1)` — це директива рівня одного файлу, яка вимикає приведення скалярів для викликів, зроблених із цього файлу: без неї PHP у coercive-режимі намагається сконвертувати аргумент до оголошеного типу (з PHP 8.0 нечислові рядки — це `TypeError`, а числові з «хвостом» — `int` плюс `E_WARNING`), зі strict приймається лише точний тип, з єдиним винятком `int` → `float`.

Чому `strlen(42)` в одному файлі працює, а в сусідньому кидає TypeError?
Я поставив `declare(strict_types=1)` у `bootstrap.php` — чому це не вплинуло на решту файлів?
Функція оголошена з `int $id`, а в базу пішло `0` замість `'abc'` — як так вийшло?
Чому `declare(strict_types=1)` не завадив передати `'10'` у функцію з `int` — файл-виклик чи файл-оголошення тут головний?

declare(strict_types=1) — це не налаштування рантайму й не ini-опція, а директива часу компіляції з областю дії рівно в один файл. Вона зʼявилась у PHP 7.0 разом зі скалярними тайп-хінтами (RFC Andrea Faulds) і перемикає перевірку оголошених типів між двома режимами: coercive (за замовчуванням) і strict. Записувати її треба першою інструкцією файлу — після <?php не може бути ані пробільного виводу, ані namespace, ані use; аргумент — тільки літерал 0 або 1, змінна там дасть помилку компіляції. Директива не успадковується: include строгого файлу з нестрогого нічого не змінює, автозавантажений клас живе за режимом свого файлу, і саме тому найпоширеніша помилка — поставити рядок в один bootstrap.php і вважати проєкт строгим.

Найважливіша механіка: режим бере той файл, у якому знаходиться виклик, а не той, де функція оголошена. PHP на етапі компіляції позначає опкод виклику прапорцем поточного файлу, і перевірка аргументів у момент виконання йде за цим прапорцем. Практичний наслідок — ваш строгий код, викликаючи бібліотеку без директиви або взагалі внутрішню функцію ядра, отримає строгу перевірку: strlen(42) зі strict-файлу кине TypeError, хоча strlen() до вашого файлу не має жодного стосунку. І навпаки: якщо строга бібліотека викликає ваш нестрогий callback, перевірка буде строгою, бо виклик стався в її файлі.

У coercive-режимі PHP намагається сконвертувати аргумент до оголошеного типу, і з PHP 8.0 ці правила стали значно чеснішими. Повністю числовий рядок ('5', ' 5 ', '1e3') конвертується тихо. Leading-numeric рядок ('5 apples') конвертується у 5, але з E_WARNING. Повністю нечисловий рядок ('apples') з PHP 8.0 — це вже TypeError, тоді як у 7.x він мовчки ставав 0 і саме через це в базу потрапляли записи з id = 0. Окремо PHP 8.1 задеприкейтив неявне floatint із втратою дробової частини: 8.5 у параметр int дає deprecation-повідомлення. У strict-режимі всього цього немає взагалі — приймається лише точний тип, з єдиним винятком: int можна передати в параметр float (widening), бо в PHP немає окремого літерала для «цілого числа з плаваючою комою» і вимагати 100.0 замість 100 було б знущанням. Зворотний напрямок, floatint, заборонений завжди.

Дуже поширене перебільшення на співбесіді — сказати, що strict_types «вимикає type juggling». Не вимикає. Директива діє лише там, де тип оголошено явно: параметри, тип повернення, типізовані властивості (PHP 7.4+) і типізовані константи класів (PHP 8.3+). Усе інше поводиться як завжди: '5' + 5 дає 10, if ($string) приводить до bool, == порівнює за своїми правилами (які, до речі, самі змінились у PHP 8.0 у RFC «Saner string to number comparisons»), ключ масиву '5' збігається з 5, а array_sum() підсумує змішаний масив без питань. Не чіпає директива й нетипізовані параметри — там просто нема чого перевіряти. Тому strict_types корисно розуміти вузько: це заборона неявного приведення скалярів у типізованих сигнатурах, не більше.

Практично це означає дві речі. Перша — вмикати директиву треба автоматично в кожному файлі: правило declare_strict_types у Pint/PHP-CS-Fixer, шаблон файлу в IDE, перевірка в CI; ручна дисципліна тут не працює, а частково строгий проєкт дає найгірший варіант — поведінка залежить від того, з якого файлу прийшов виклик. Друга — межа з зовнішнім світом. HTTP-параметри, значення з CLI, дані з БД у деяких драйверах, JSON без JSON_BIGINT-нюансів — усе це приходить рядками, і в строгому коді конвертація має бути явним, видимим кроком: filter_var(..., FILTER_VALIDATE_INT), FormRequest з правилом integer, $request->integer('page'). Строгі типи не роблять валідацію за вас — вони гарантують, що після точки конвертації неправильний тип уже не протече глибше в домен. І варто памʼятати про верхній шар: PHPStan рівня 6+ ловить ті самі невідповідності статично, до запуску, тоді як strict_types спрацьовує вже в рантаймі — ці два інструменти доповнюють один одного, а не замінюють.

<?php

declare(strict_types=1);          // ПЕРША інструкція файлу, інакше fatal error

final class Cart
{
    public int $itemCount = 0;    // типізована властивість теж під strict

    public function addItem(int $productId, float $price): float
    {
        $this->itemCount++;

        return $price * $productId;
    }
}

$cart = new Cart();

// int → float: ЄДИНЕ послаблення, дозволене і в strict-режимі (widening)
$cart->addItem(7, 100);           // 100 стає 100.0, помилки немає

try {
    // рядок у параметр int: у strict-режимі приведення заборонене
    $cart->addItem('7', 100.0);   // TypeError: Argument #1 must be of type int, string given
} catch (TypeError $e) {
    echo $e->getMessage();
}

try {
    // float → int заборонено ЗАВЖДИ, навіть без strict_types
    $cart->addItem(7.0, 100.0);   // TypeError: Argument #1 must be of type int, float given
} catch (TypeError $e) {
    echo $e->getMessage();
}

try {
    $cart->itemCount = '10';      // типізована властивість: теж TypeError
} catch (TypeError $e) {
    echo $e->getMessage();
}

// Директива не чіпає type juggling поза оголошеними типами:
var_dump('5' + 5);                // int(10) — арифметика працює як раніше
var_dump('abc' == 0);             // false у PHP 8+, true у PHP 7 — це вже інші правила

// Межа з HTTP: вхід завжди рядок, конвертація має бути явною
$page = filter_var($_GET['page'] ?? '1', FILTER_VALIDATE_INT) ?: 1;
$cart->addItem($page, 9.99);      // сюди приходить справжній int
Що `declare(strict_types=1)` діє на файл, у якому написаний, і не успадковується ані підключеними файлами, ані автозавантаженими класами — це не налаштування рантайму й не ini-опція.
Що вирішує файл **виклику**, а не файл оголошення функції: `strlen(42)` зі strict-файлу кинеться, хоча `strlen()` оголошена в ядрі.
Що директива стосується лише скалярних типів (`int`, `float`, `string`, `bool`) у параметрах, поверненнях і присвоєннях типізованим властивостям; `array`, обʼєкти, `iterable`, `callable` ніколи не приводяться і без неї.
Що єдине послаблення в strict-режимі — widening `int` → `float` (передати `5` у параметр `float` можна), а `float` → `int` заборонено завжди.
Що з PHP 8.0 coercive-режим став суворішим: `'abc'` в `int` — це `TypeError`, `'5 apples'` — `5` з `E_WARNING`, `'5'` — тихо `5`; до 8.0 `'abc'` мовчки ставало `0`.
Що `declare(strict_types=1)` має стояти першою інструкцією файлу (після `<?php`, до всього іншого, крім опційного блоку `declare` у фігурних дужках) — інакше фатальна помилка компіляції.
Ставити `declare(strict_types=1)` в один `index.php`/`bootstrap.php` і вважати, що весь проєкт тепер строгий: директива не поширюється на `include`, `require` й автозавантажені класи.
Казати, що strict_types вимикає «type juggling» узагалі: `'5' + 5`, `if ($str)`, `==` і `array_sum()` працюють точно так само — директива стосується лише перевірки оголошених типів.
Вважати, що директива діє там, де функція оголошена. Насправді режим бере з файлу, у якому стоїть виклик; бібліотека без strict, викликана зі strict-файлу, отримає строгу перевірку.
Думати, що в strict-режимі `int` не пройде в параметр `float` — widening дозволений і це навмисно, бо в PHP немає літерала для «цілого float».
Розраховувати на strict_types у даних із HTTP: `$_GET['page']` — завжди рядок, і в strict-режимі його треба явно кастити (`(int)`) або валідувати `filter_var`, а не сподіватись, що PHP «сам зрозуміє».
Плутати `declare(strict_types=1)` з `1` як «увімкнено для всіх» — допустимі лише літерали `0` і `1`, змінна чи константа там викличе помилку компіляції.
ПОРАДА

Формулювання, яке закриває питання: «strict_types — це директива компіляції на один файл, і вирішує вона файл виклику; вмикає не заборону type juggling, а заборону неявного приведення скалярів у типізованих сигнатурах — плюс один виняток, int → float». Далі додайте, що в проєкті це ставлять у кожен PHP-файл автоматично (Pint/PHP-CS-Fixer, правило `declare_strict_types`), а на межі з HTTP усе одно потрібен явний каст.

Сторінка питання →
PHP
Що таке property hooks у PHP 8.4+? ЧАСТО ПИТАЮТЬ
Core PHP·Middle ·PHP 8.4 ·property hooks ·інваріанти

Це можливість оголосити get і set логіку прямо на властивості, без окремих методів і без магії __get/__set.

Чим property hooks кращі за геттери й сеттери?
Що нового в PHP 8.4 для класів?
Чи можна оголосити hook в інтерфейсі?

Property hooks дозволяють оголосити логіку читання й запису прямо на властивості. Замість пари getName()/setName() клас має public string $name { get => ...; set => ...; }, а зовнішній код працює зі звичайним доступом до поля. Обійти сеттер неможливо: будь-яке присвоєння, зокрема в промоутнутому конструкторі, проходить через хук, тому інваріант виконується завжди.

На відміну від __get/__set хуки статично типізовані й привʼязані до конкретної властивості. IDE підказує тип, PHPStan перевіряє тіло хука, а рантайм не платить за виклик магічного методу на кожне звернення. Якщо хук get не читає саму властивість, вона стає віртуальною й не займає памʼяті, як обчислюване fullName.

Інтерфейси теж отримали цю можливість: public string $label { get; } у контракті замінює метод getLabel(). Клас може реалізувати вимогу і хуком, і звичайною публічною властивістю.

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

final class Product
{
    public function __construct(
        public string $name {
            set(string $value) {
                if (trim($value) === '') {
                    throw new InvalidArgumentException('Name cannot be empty.');
                }
                $this->name = trim($value);
            }
        },
        public int $priceCents { set => max(0, $value); },
    ) {}

    // Віртуальна властивість: памʼять не виділяється, значення обчислюється
    public string $label { get => $this->name.' — '.number_format($this->priceCents / 100, 2).' грн'; }
}

interface HasLabel
{
    public string $label { get; }   // інтерфейс вимагає властивість, а не метод getLabel()
}

$p = new Product('  Кава ', 1990);
$p->name = '';      // InvalidArgumentException: сеттер неможливо обійти
echo $p->label;     // Кава — 19.90 грн
Що hook оголошується на самій властивості, тому обійти set неможливо: інваріант виконується завжди, навіть при прямому присвоєнні.
Що на відміну від __get/__set це статично типізовано: IDE й PHPStan бачать тип, а аналізатор перевіряє тіло хука.
Що властивість із хуком get без backing value стає віртуальною й не займає памʼяті.
Що інтерфейси можуть вимагати властивість з get або set, і це замінює пари getX/setX у контрактах.
Тверезу оцінку: масова міграція геттерів на hooks заради стилю не окупається, переписують те, де вже болять інваріанти.
Плутати з readonly: readonly забороняє зміну після ініціалізації, hooks додають логіку на читання й запис.
Вважати hooks заміною __get і __set: магічні методи працюють для неоголошених властивостей, hooks лише для оголошених.
Не знати, що властивість з хуками не можна зробити readonly, а всередині set треба писати $this->prop = ..., щоб зберегти значення.
Змішувати з asymmetric visibility public private(set), яка теж зʼявилась у 8.4, але вирішує іншу задачу.
ПОРАДА

Згадайте, що масова міграція геттерів на hooks заради стилю не окупається — переписують те, де вже болять інваріанти. І назвіть другу фічу 8.4 поруч: asymmetric visibility.

Сторінка питання →
PHP
Core PHP·Middle ·Generator ·yield ·yield from

Генератор — це функція з `yield`, яка при виклику не виконує жодного рядка тіла, а повертає обʼєкт `Generator` (реалізує `Iterator`): значення обчислюються по одному на вимогу, тому пікова памʼять не залежить від обсягу даних; ціна — один-єдиний прохід без перемотки, без `count()` і без доступу за індексом.

Експорт падає з Allowed memory size exhausted на 2 млн рядків — як переписати, не піднімаючи memory_limit?
Чим `yield` відрізняється від `return` і що взагалі повертає функція, у тілі якої є `yield`?
Чому `count()` на результаті такої функції падає, а `foreach` працює?
Чому другий `foreach` по тому самому генератору кидає «Cannot rewind»?
Навіщо `yield from`, якщо можна написати вкладений `foreach` з `yield` усередині?

Генератори зʼявилися в PHP 5.5 і працюють не так, як здається з синтаксису. Наявність yield будь-де в тілі функції змінює саму природу виклику: PHP не виконує жодного рядка тіла, а створює й повертає обʼєкт класу Generator, який реалізує інтерфейс Iterator. Тіло стартує лише тоді, коли хтось уперше запитає значення — foreach, current(), send() або iterator_to_array(). Дійшовши до yield, функція заморожується: її локальні змінні, позиція виконання й навіть напівпройдений try живуть у власному стек-фреймі генератора. Наступна ітерація не починає функцію спочатку, а розморожує її рівно там, де зупинила. Саме тому в прикладі RuntimeException з fopen() вилітає не на рядку allLines(...), а вже всередині foreach — класична пастка, на якій ловлять на співбесіді.

Практичний сенс цієї ліні — памʼять. Функція, що повертає масив, зобовʼязана дорахувати всі елементи до return, тому пік споживання пропорційний обсягу даних: мільйон рядків по 200 байт — це не 200 МБ, а помітно більше, бо кожен елемент хеш-таблиці коштує ще десятки байтів службових даних. Генератор тримає одночасно один елемент, і memory_limit перестає бути функцією розміру файла чи вибірки. Друга, менш очевидна вигода — конвеєр: onlyErrors(allLines(...)) не створює жодного проміжного масиву, кожен рядок проходить крізь усі ланки й одразу звільняється. Третя — час до першого результату: споживач отримує перший рядок майже миттєво, а не після повного читання. Тому генератори — природний інструмент для парсингу великих файлів, ітерації по вибірці з БД (Model::cursor(), LazyCollection), потокової віддачі відповіді через StreamedResponse і для нескінченних послідовностей, які просто не існують як масив.

Протокол генератора ширший за foreach. Ключі — його повноцінна частина: без явного ключа PHP нумерує елементи 0, 1, 2…, а yield $key => $value дозволяє віддавати свої (номер рядка, ідентифікатор запису). yield from, доданий у PHP 7.0, делегує іншому генератору, масиву чи будь-якому Traversable і повертає його return-значення як результат виразу — у прикладі це дозволяє порахувати сумарну кількість рядків, не заводячи зайвого стану. Важливий нюанс: yield from зберігає ключі внутрішнього джерела, тому при склеюванні кількох файлів нумерація кілька разів починається з нуля, і iterator_to_array() без другого аргументу false мовчки перетре частину даних. return у генераторі теж не те, чим здається: він не потрапляє в foreach, а забирається окремо через getReturn() — і лише після того, як генератор дійшов до кінця. Нарешті, send() робить обмін двонаправленим: вираз yield усередині обчислюється в значення, передане ззовні, — на цьому побудовані корутини асинхронних рушіїв.

Головне обмеження генератора — одноразовість. Він не має курсора, який можна повернути назад: Generator::rewind() дозволений лише доки виконання стоїть на першому yield, інакше кидає Exception: Cannot rewind a generator that was already run. Через це другий foreach по тому самому обʼєкту падає, а не повертає порожнечу. Так само немає count() (генератор не знає, скільки в нього елементів, поки не дорахує), немає доступу за індексом, і його не приймають array_map(), array_filter(), sort() — усі вони працюють з масивами. Якщо дані потрібні двічі, зберігайте не генератор, а спосіб його створити: фабрику-замикання або клас з IteratorAggregate; саме так влаштований LazyCollection, який приймає замикання й тому ітерується скільки завгодно разів. Матеріалізація через iterator_to_array() теж законна — просто памʼятайте, що вона повертає ту саму памʼять, заради якої все й починалось.

Межі варто називати чесно, бо на них ловлять найчастіше. Генератор не прискорює обчислення — він лише розтягує їх у часі; на маленькій колекції з десятків елементів звичайний масив простіший і швидший, а зайва ланка генераторів робить стек-трейси менш читабельними. Генератор не зменшує памʼять поза PHP: Model::cursor() на MySQL з увімкненою буферизацією PDO (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, за замовчуванням true у mysqlnd) все одно затягне весь результат у клієнтський буфер — тут або небуферизований запит зі своїми обмеженнями, або chunkById(), який ріже вибірку на окремі запити. І генератор — погане місце для тримання ресурсу з довгим життям: якщо споживач вийде через break, генератор просто зависне на yield до знищення, тож звільнення файла чи відкат транзакції мають бути у finally, а сама транзакція — краще в коді-споживачі, а не всередині ітератора.

/** Читає лог рядок за рядком: у памʼяті живе один рядок, а не весь файл. */
function lines(string $path): Generator
{
    // ця перевірка спрацює НЕ на виклику lines(), а на першій ітерації
    $handle = fopen($path, 'rb') ?: throw new RuntimeException("Немає {$path}");
    $n = 0;
    try {
        while (($line = fgets($handle)) !== false) {
            yield $n++ => rtrim($line, "\r\n");   // ключ задаємо явно
        }
    } finally {
        fclose($handle);          // виконається і при break у споживача
    }
    return $n;                    // видно лише через getReturn() після кінця
}

/** yield from делегує іншому генератору й повертає його return-значення. */
function allLines(string ...$paths): Generator
{
    $total = 0;
    foreach ($paths as $path) {
        $total += (yield from lines($path));   // ключі 0,1,2… стартують заново!
    }
    return $total;
}

/** Ланка конвеєра: фільтр нічого не матеріалізує. */
function onlyErrors(iterable $lines): Generator
{
    foreach ($lines as $key => $line) {
        if (str_contains($line, ' ERROR ')) {
            yield $key => $line;
        }
    }
}

$stream = allLines('app-09-01.log', 'app-09-02.log'); // тіло ще не виконувалось
foreach (onlyErrors($stream) as $key => $line) {
    echo "{$key}: {$line}\n";     // пікова памʼять не залежить від розміру логів
}
echo $stream->getReturn();        // усього рядків; до завершення — Exception
// повторний foreach ($stream) → Cannot rewind a generator that was already run
Що виклик функції з `yield` не виконує її тіло: повертається обʼєкт `Generator`, і код стартує лише на першій ітерації (тому валідація аргументів усередині генератора «мовчить» до `foreach`).
Що памʼять стає сталою замість лінійної: масив на мільйон рядків — це сотні мегабайтів (рядок плюс десятки байтів на елемент хеш-таблиці), генератор тримає один рядок і власний стек-фрейм.
Що `Generator` реалізує `Iterator`, тому працюють `foreach` та `iterator_to_array()`, але не працюють `count()`, `$gen[0]`, `array_map()` і будь-який другий прохід.
Що ключі є повноцінною частиною протоколу: без явного ключа PHP нумерує 0, 1, 2…, а `yield $key => $value` задає свій — і `yield from` (PHP 7.0+) ключі внутрішнього генератора **не** переномеровує.
Що `send()` робить генератор двонаправленим — значенням виразу `yield` усередині стає те, що передали ззовні; це основа корутин у ReactPHP/Amp.
Що межа економії — не лише PHP: `Model::cursor()` не врятує памʼять на MySQL, поки PDO буферизує весь результат на боці клієнта.
Класти перевірку аргументів на початок генератора і чекати винятку одразу після виклику: тіло не виконається, поки хтось не почне ітерацію.
«Для зручності» обгортати результат в `iterator_to_array()` — це повертає рівно ту памʼять, заради якої генератор і писався.
Забувати про дублікати ключів після `yield from`: `iterator_to_array($gen)` тихо перетирає рядки, потрібен другий аргумент `false`.
Викликати `getReturn()` до того, як генератор дійшов до кінця (або після `break`), і отримувати `Exception: Cannot get return value of a generator that hasn't returned`.
Писати `return $value;` у генераторі й чекати, що `foreach` віддасть це значення як останній елемент — воно доступне лише через `getReturn()`.
Вважати `Model::cursor()` або `LazyCollection` автоматичною гарантією низької памʼяті, не подивившись на буферизацію запиту в драйвері БД.
ПОРАДА

Формула, яка закриває питання: «функція з `yield` повертає не дані, а обʼєкт `Generator`; він рахує значення по одному, памʼять стала, але прохід рівно один — перемотати не можна, лише викликати функцію ще раз». Далі одразу назвіть трійку `yield from` / `send()` / `getReturn()` — це показує, що ви бачили генератори не лише в `foreach`.

Сторінка питання →
PHP
Core PHP·Junior ·трейти ·ООП ·композиція

Трейт — механізм горизонтального повторного використання коду: набір методів і властивостей, який компілятор копіює в клас.

Чим трейт відрізняється від абстрактного класу й інтерфейсу?
Що буде, якщо два трейти мають метод з однаковою назвою?
Чи є трейти множинним наслідуванням?

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

Важлива різниця з класами й інтерфейсами: трейт не є типом. По ньому не можна зробити type-hint, instanceof завжди поверне false, а сам трейт неможливо створити. Якщо потрібен контракт, трейт доповнюють інтерфейсом: інтерфейс обіцяє метод, трейт постачає реалізацію за замовчуванням.

Конфлікти вирішуються явно. Якщо два трейти мають однаковий метод, PHP не вгадує, а вимагає вказати insteadof, і за потреби дає доступ до другої реалізації через as. Той самий as змінює видимість методу.

Межа застосування проста: трейт добре працює як технічна домішка без власного стану, наприклад логування, timestamps, серіалізація. Коли в трейт починають потрапляти бізнес-правила й залежності на кшталт $this->db, код стає непрозорим: клас отримує поведінку, походження якої не видно в його оголошенні. У таких випадках краще композиція через окремий сервіс у конструкторі.

trait Loggable
{
    // Трейт може вимагати від класу метод, не знаючи його реалізації
    abstract protected function logger(): LoggerInterface;

    public function log(string $message): void
    {
        $this->logger()->info(static::class.': '.$message);
    }
}

trait Timestamps
{
    public function touch(): void { $this->updatedAt = new DateTimeImmutable; }
}

final class Order
{
    use Loggable, Timestamps {
        Timestamps::touch as protected markUpdated; // псевдонім зі зміною видимості
    }

    private DateTimeImmutable $updatedAt;

    public function __construct(private LoggerInterface $logger) {}

    protected function logger(): LoggerInterface { return $this->logger; }
}

$order instanceof Loggable; // false: трейт не є типом
Що трейт не є типом: його не можна інстанціювати, по ньому не можна робити type-hint, він лише вставляє код у клас на етапі компіляції.
Що конфлікти методів вирішуються явно через insteadof і as, а не за порядком use.
Що трейт може оголошувати абстрактні методи та статичні властивості й тим самим вимагати від класу контракт.
Межу застосування: технічна домішка без стану (логування, timestamps, серіалізація) доречна, а доменну поведінку краще виносити в окремий сервіс через композицію.
Називати трейт множинним наслідуванням: у трейта немає власного місця в ієрархії, і instanceof по ньому не працює.
Ховати в трейти залежності: метод трейта звертається до $this->db, якого в класі може не бути, і помилка спливає лише в рантаймі.
Робити один величезний трейт HasEverything, який роздувають десятки класів, замість кількох невеликих.
Не знати, що властивість трейта і властивість класу з однаковою назвою мусять мати сумісне оголошення, інакше fatal error.
ПОРАДА

Хороша відповідь містить межу застосування: трейт доречний для технічної домішки без власного стану, а поведінку домену краще виносити в окремий сервіс. Наведіть приклад з Laravel: SoftDeletes або HasFactory.

Сторінка питання →
PHP
Core PHP·Middle ·named arguments ·constructor promotion ·PHP 8.0

Named arguments (PHP 8.0) дозволяють передавати аргументи за іменем параметра й пропускати необовʼязкові, а constructor property promotion (PHP 8.0) оголошує властивість прямо в сигнатурі конструктора; ціна — імена параметрів стають частиною публічного API, а промотована властивість ініціалізується ще до тіла конструктора.

У нас конструктор на вісім параметрів — як зробити виклик читабельним, не роблячи білдер?
Чому після перейменування параметра `$str` на `$string` у сервісі впав чужий код, хоча сигнатура сумісна?
Можна передати масив з рядковими ключами як іменовані аргументи — і з якої версії PHP?
Чому `$this->name = trim($name)` у тілі конструктора падає з «Cannot modify readonly property», якщо параметр промотований?

Обидві фічі приїхали в PHP 8.0, але лікують різні болі. Constructor property promotion прибирає дублювання в оголошенні: замість трьох рядків на кожну залежність (властивість, параметр конструктора, присвоєння) модифікатор видимості просто ставиться перед параметром — public function __construct(private LoggerInterface $logger) {} — і PHP сам створює властивість із тим самим типом та імʼям. Named arguments лікують місце виклику: аргумент передається за іменем параметра, а не за позицією, тому необовʼязкові параметри можна пропускати вибірково, а не «дотягувати» дефолтами до потрібного. new SearchQuery(text: 'php', city: 'Львів') читається без заглядання в сигнатуру, тоді як new SearchQuery('php', 1, 20, 'Львів') — ні.

Механіка named arguments має кілька жорстких правил, і саме на них ловлять. Позиційні аргументи мають іти першими: f(text: 'php', 3) — це помилка парсингу, а не рантайму. Передати той самий параметр двічі (позиційно й за іменем) не можна — буде Error: Named parameter $x overwrites previous argument. Неіснуюче імʼя дає Error: Unknown named parameter $x. Іменовані аргументи, що не збіглися з жодним параметром, збираються у варіадик із рядковими ключами — саме тому прозорі декоратори виду handle(...$args) продовжують працювати. Розпакування масиву з рядковими ключами в іменовані аргументи (f(...['page' => 2])) дозволене з PHP 8.1; у 8.0 воно кидало Cannot unpack array with string keys. Окремо варто памʼятати, що func_get_args() бачить виклик так, ніби все передали позиційно: пропущені необовʼязкові параметри підставляються своїми дефолтами, тому старий код на func_num_args() починає рахувати інакше.

Головна ціна named arguments — імена параметрів стають публічним API. PHP звіряє сумісність сигнатур за типами й кількістю параметрів, але не за іменами: клас, що реалізує інтерфейс, може назвати параметр як завгодно і завантажиться без жодної помилки. Тому виклик $gateway->charge(amount: 100) через тип-інтерфейс впаде в рантаймі на тій єдиній реалізації, де параметр названо $sum. Так само перейменування $str на $string у власній бібліотеці — це BC break, навіть якщо тип і позиція незмінні. Практичне правило: іменовані аргументи безпечні для конструкторів конкретних класів, DTO, атрибутів і вбудованих функцій (їх імена якраз і причесали в PHP 8.0 саме заради цього), а для поліморфних викликів через абстракцію надійніше лишатися позиційним.

У промоції своя пастка, і вона про порядок. Присвоєння промотованих властивостей відбувається до тіла конструктора, тому тіло вже працює з $this->…. Для звичайних властивостей це нічого не змінює, а для readonly (PHP 8.1+) означає, що властивість уже ініціалізована, і $this->text = trim($text) у тілі дасть Cannot modify readonly property. Трансформацію значень доводиться виносити або на рівень виклику — приватний конструктор плюс статичний fromRequest(), який чистить дані, — або в окремі value-обʼєкти, що нормалізують себе самі. Валідація ж без зміни значення (кинути InvalidArgumentException, прочитавши $this->perPage) у тілі конструктора цілком легальна.

Межі промоції варто називати списком: тільки __construct не-абстрактного класу, не варіадичний параметр, не тип callable (замість нього — Closure), не можна дублювати ту саму властивість у тілі класу, модифікатор видимості обовʼязковий. readonly доступний з 8.1, асиметрична видимість public private(set) — з 8.4. Атрибут перед промотованим параметром вішається і на параметр, і на властивість, а ReflectionProperty::isPromoted() дозволяє відрізнити такі властивості в рантаймі. Останнє — документація типів: узагальнені типи промотованих властивостей описують @param list<string> $tags у docblock конструктора, бо окремого місця для @var більше немає; при міграції старих DTO на promotion це найчастіша тиха втрата типів для PHPStan.

final class SearchQuery
{
    /** @param list<string> $tags */
    public function __construct(
        public readonly string $text,
        public readonly int $page = 1,
        public readonly int $perPage = 20,
        public readonly ?string $city = null,
        public readonly array $tags = [],
    ) {
        // тіло виконується ПІСЛЯ присвоєння — властивості вже заповнені
        if ($this->perPage > 100) {
            throw new InvalidArgumentException('perPage максимум 100');
        }
        // $this->text = trim($text);  // Error: readonly вже ініціалізовано промоцією
    }
}

// називаємо лише те, що відрізняється від типового; page і perPage пропускаємо
$q = new SearchQuery(text: 'php', city: 'Львів', tags: ['remote']);

// new SearchQuery(text: 'php', 3);   // Parse error: позиційний після іменованого
// new SearchQuery('php', text: 'x'); // Error: Named parameter $text overwrites previous argument

// розпакування масиву з РЯДКОВИМИ ключами як іменованих — PHP 8.1+
$filters = ['text' => 'php', 'perPage' => 50];
$q2 = new SearchQuery(...$filters);

// зайвий ключ ламає виклик, тому вхідні дані фільтруємо явно
// new SearchQuery(...['text' => 'php', 'sort' => 'new']); // Unknown named parameter $sort

function collect(...$args): array
{
    return $args;                     // іменовані аргументи стають рядковими ключами
}

var_dump(collect(1, 2));              // [0 => 1, 1 => 2]
var_dump(collect(a: 1, b: 2));        // ['a' => 1, 'b' => 2]
Що обидві фічі зʼявилися в PHP 8.0 і вирішують різні задачі: named arguments — про місце виклику, promotion — про місце оголошення.
Що після переходу на іменовані аргументи назва параметра стає частиною контракту: перейменування `$str` → `$string` — це BC break, хоч типи й порядок незмінні.
Що позиційний аргумент після іменованого — синтаксична помилка, а повторна передача того самого параметра позиційно й за іменем дає `Error: Named parameter $x overwrites previous argument`.
Що розпакування масиву з рядковими ключами (`f(...['page' => 2])`) як іменованих аргументів працює з PHP 8.1, а в 8.0 давало `Cannot unpack array with string keys`.
Що промоція присвоює властивості ДО виконання тіла конструктора, тому для `readonly` повторний запис у тілі вже неможливий — трансформацію треба робити в аргументі або у статичному конструкторі.
Що промотувати не можна все підряд: тільки в конструкторі не-абстрактного класу, не варіадичний параметр, не тип `callable`.
Вважати named arguments «просто цукром» і вільно перейменовувати параметри в публічних класах і в реалізаціях інтерфейсів — PHP не перевіряє сумісність імен при успадкуванні, і виклик за іменем впаде вже в рантаймі.
Писати `f(text: 'php', 3)` — позиційний аргумент після іменованого не парситься взагалі, це не рантайм-помилка.
Розраховувати, що `new Dto(...$request->all())` безпечний: зайвий ключ дає `Error: Unknown named parameter $sort`, тому масив треба явно фільтрувати або валідувати.
Дублювати промотовану властивість ще й у тілі класу (`private string $name;` + `private string $name` у конструкторі) — фатальна помилка «Cannot redeclare property».
Робити `$this->name = trim($name)` у тілі конструктора для промотованого `readonly`-параметра й дивуватись `Cannot modify readonly property`.
Промотувати параметри в класі з великою логікою ініціалізації й вважати, що це «звільняє» від валідації: перевірки в тілі конструктора нікуди не діваються, просто працюють уже з `$this->…`.
ПОРАДА

Формула на дві фрази: «promotion скорочує оголошення, named arguments — виклик; разом вони роблять DTO читабельним без білдера». І одразу назвіть ціну: «але імена параметрів після цього — публічний API, перейменування = BC break».

Сторінка питання →
PHP
Core PHP·Middle ·match ·switch ·PHP 8.0

`match` (PHP 8.0+) — це вираз, який повертає значення й порівнює строго через `===` без провалювання між гілками, а якщо жодна умова не збіглася і немає `default` — кидає `UnhandledMatchError`; `switch` — оператор, який порівнює нежорстко через `==`, потребує `break` і при відсутності збігу мовчки нічого не робить.

`match` — це просто коротший `switch`, чи різниця глибша?
Чому `match ($_GET['page'])` з гілкою `1 => ...` не спрацьовує, хоча в URL стоїть `page=1`?
Що станеться, якщо жодна гілка `match` не збіглася і `default` немає?
Чому в `switch` треба писати `break`, а в `match` — ні? І що буде, якщо все-таки написати?

Головна відмінність не в синтаксисі, а в тому, що це різні мовні категорії. switch — оператор: він передає керування в гілку, а результат ви мусите самі кудись покласти, тому кожен case закінчується присвоєнням у тимчасову змінну і break. match, що зʼявився в PHP 8.0, — вираз: він обчислюється у значення, тому пишеться там, де очікується значення — праворуч від =, у return, в аргументі виклику, у тілі стрілочної функції, у рядковій інтерполяції через {}. Звідси й дрібна деталь синтаксису, на якій спотикаються: після закриваючої фігурної дужки match ставиться крапка з комою, бо це закінчення виразу, а не блоку.

Друга відмінність — семантика порівняння. switch порівнює тему з кожним case нежорстко, через ==, з усіма приведеннями типів; match — строго, через ===, тобто збіг має бути і за значенням, і за типом. PHP 8.0 прибрав найгидкішу пастку switch, змінивши порівняння числа з нечисловим рядком (0 == 'foo' тепер false), але сам == нікуди не подівся: '1' == 1, '1e2' == '100', '0' == false, null == false — усе це в switch досі дає збіг. Практичний наслідок видно в прикладі коду: значення '1', що прийшло з query-рядка, потрапляє в case 1 у switch і не потрапляє в гілку 1 => у match. Це не баг match, а причина №1 регресій при механічній заміні однієї конструкції на іншу — тип теми треба нормалізувати явно ((int) $page, Status::from($raw)).

Третя відмінність — поведінка на невідомому значенні. switch без відповідного case і без default мовчки не робить нічого; це зручно рівно доти, доки на цьому «нічого» випадково не почала триматися логіка. match зобовʼязаний повернути значення, тому відсутність збігу для нього — помилковий стан: інтерпретатор кидає \UnhandledMatchError. Важлива для співбесіди деталь: цей клас успадковує \Error, а не \Exception, тож catch (Exception $e) його не перехопить — потрібні catch (\UnhandledMatchError), catch (\Error) або \Throwable. Найцінніше це на enum: match по кейсах enum без default дає вичерпність, яку перевіряє статичний аналізатор на етапі CI, а рантайм страхує помилкою. Дописаний «щоб не падало» default => null вимикає обидва рівні захисту — і забутий новий кейс перетворюється з голосної помилки на тихий null, який спливе за три екрани звідси.

Решта відмінностей дрібніші, але їх теж питають. Провалювання (fallthrough) в match немає взагалі: кілька значень в одній гілці перелічуються комою (200, 201, 204 =>), а break там не пишеться і не парситься — гілка є виразом, а не блоком інструкцій. Через це ж обмеження гілка не може містити кілька інструкцій: багатокрокова логіка або витягується в метод, або лишається в switch. Для діапазонів і складених умов є ідіома match (true), де кожна гілка — булевий вираз; вона читається краще за драбину if/elseif, бо повертає значення, але порядок гілок стає критичним, а умови після першого збігу не обчислюються взагалі. Щодо продуктивності: коли всі умови — літерали одного типу (int або string), обидві конструкції компілюються в таблицю переходів (ZEND_SWITCH_LONG/ZEND_SWITCH_STRING для switch, ZEND_MATCH для match), тож обирати між ними за швидкістю немає сенсу.

Межі варто назвати чесно. match — не патерн-матчинг: у PHP немає ні деструктуризації, ні guard-умов, ні матчингу за типом (instanceof доводиться писати руками всередині match (true)). Порівняння через === для звичайних обʼєктів означає ідентичність екземпляра, а не рівність вмісту, тому два еквівалентні DateTimeImmutable у match не збігатимуться — з enum це працює лише тому, що його кейси є синглтонами. І якщо match розростається до пари десятків гілок, це вже не питання вибору між ним і switch: там просилася або мапа array<string, callable>, або поліморфізм із окремим класом на кожен випадок.

declare(strict_types=1);

$page = '1';                          // усе, що приходить з HTTP, — рядок

switch ($page) {                      // switch порівнює через ==
    case 1:                           // '1' == 1 → true, гілка спрацює
        $bySwitch = 'перша сторінка';
        break;                        // без break провалиться в default
    default:
        $bySwitch = 'інша';
}
echo $bySwitch;                       // 'перша сторінка'

$byMatch = match ($page) {            // match порівнює через ===
    1 => 'перша сторінка',            // '1' !== 1 → гілка НЕ спрацює
    '1' => 'перша сторінка (рядок)',
    default => 'інша',
};                                    // match — вираз, тому крапка з комою
echo $byMatch;                        // 'перша сторінка (рядок)'

$status = 503;

// match повертає значення: присвоюємо одразу, тимчасова змінна не потрібна
$label = match (true) {               // умови перевіряються згори вниз
    $status >= 500 => 'помилка сервера',
    $status >= 400 => 'помилка клієнта',
    $status >= 200 => 'успіх',
    default => 'невідомо',
};
echo $label;                          // 'помилка сервера'

try {
    // default немає; кілька значень в одній гілці — через кому, без fallthrough
    echo match ($status) {
        200, 201, 204 => 'тіло можна кешувати',
        301, 302 => 'редірект',
    };
} catch (\UnhandledMatchError $e) {   // це Error, а не Exception
    echo $e->getMessage();            // у повідомленні — незіставлене значення
}
Що `match` порівнює через `===` (тип і значення), а `switch` — через `==` з приведенням типів; це не стилістична, а семантична різниця.
Що `match` — вираз: його результат можна присвоїти, повернути з `return`, передати аргументом, покласти в тіло стрілочної функції; `switch` — оператор, тому в кожній гілці доводиться писати присвоєння у тимчасову змінну.
Що провалювання (fallthrough) в `match` немає взагалі: кілька значень групуються комою `200, 201, 204 =>`, а `break` там не потрібен і навіть неможливий.
Що при відсутності збігу `match` без `default` кидає `\UnhandledMatchError`, який успадковує `Error`, а не `Exception` — тобто `catch (Exception)` його не спіймає.
Що тіло гілки `match` — рівно одне вираження, тому багатокрокова логіка з кількома інструкціями лишається за `switch`, окремим методом або `if`.
Що `match` зʼявився в PHP 8.0 і що на enum він дає перевірювану вичерпність: без `default` статичний аналізатор бачить пропущений case, а рантайм ловить його `UnhandledMatchError`.
Називати `match` «синтаксичним цукром над switch»: цукор не змінює семантику, а тут змінюються і порівняння, і поведінка при відсутності збігу.
Механічно замінювати `switch` на `match` у коді, що працює з даними з HTTP, БД чи CSV: там значення — рядки, і гілка `1 =>` після заміни перестає спрацьовувати, бо `'1' !== 1`.
Вважати, що в PHP 8 `switch` став строгим: змінилося лише порівняння числа з нечисловим рядком (`0 == 'foo'` тепер `false`), а `==` з усіма іншими приведеннями (`'1' == 1`, `'1e2' == '100'`, `'0' == false`) у `switch` лишився.
Писати `break` або кілька інструкцій через `;` усередині гілки `match` — це помилка парсингу, бо гілка є виразом, а не блоком.
Ловити `UnhandledMatchError` через `catch (Exception $e)` — не спрацює; потрібен `catch (\UnhandledMatchError)`, `catch (\Error)` або `\Throwable`.
Дописувати `default => null` «щоб не падало» в `match` по enum: це вимикає перевірку вичерпності, і новий case мовчки почне повертати `null` замість того, щоб зламатися голосно.
ПОРАДА

Одна фраза, яка закриває питання: «`switch` — оператор із `==` і провалюванням, `match` — вираз із `===` і без провалювання, а на невідоме значення `switch` мовчить, `match` кидає `UnhandledMatchError`». Далі додайте головний практичний наслідок: `match` по enum без `default` перетворює забутий новий case із тихого бага на помилку.

Сторінка питання →
PHP
Core PHP·Middle ·Fiber ·PHP 8.1 ·корутини

`Fiber` (PHP 8.1+) — це низькорівневий примітив кооперативної багатозадачності: блок коду з власним стеком, який можна призупинити з будь-якої глибини викликів через `Fiber::suspend()` і відновити через `resume()`. Планувальника й циклу подій у ядрі немає — їх дають revolt/event-loop та AMPHP v3.

Fibers — це нарешті багатопоточність у PHP?
Чим `Fiber` відрізняється від генератора, якщо обидва вміють призупинятися й віддавати значення?
Я обгорнув запити через PDO у `Fiber`, а сторінка не пришвидшилась — чому?
Ви колись писали `new Fiber()` руками? Якщо ні, то навіщо воно взагалі в мові?

Fiber — це клас у глобальному просторі імен, доданий у PHP 8.1 разом із FiberError. Обʼєкт створюють від будь-якого callable (new Fiber($callback)), запускають через start(...$args), а всередині коду волокна викликають статичний Fiber::suspend($value) — виконання завмирає, а start() повертає передане значення тому, хто волокно запустив. Далі resume($value) продовжує роботу з тієї ж точки, причому аргумент resume() стає значенням, яке поверне Fiber::suspend(). Стан читають через isStarted(), isSuspended(), isRunning(), isTerminated(), результат callable — через getReturn() (до завершення волокна це FiberError), а «розбудити з винятком» можна через throw(). Усе разом це один примітив: перемикання стеків, і нічого більше.

Порівняння з генераторами — головна змістовна частина відповіді. Обидва механізми кооперативні й обидва вміють двобічний обмін (Generator::send() проти Fiber::resume()), але генератор призупиняє тільки власне тіло: yield має стояти в тій самій функції, а функція з yield перестає бути звичайною — вона повертає Generator, і викликач мусить її ітерувати. Щоб призупинитися з глибини, кожну функцію в ланцюжку доводиться робити генератором і прокидати yield from — це та сама «розфарбованість функцій», через яку синхронний і корутинний код не змішувалися. Волокно має власний стек, тому Fiber::suspend() спрацьовує на будь-якій глибині, а проміжний код лишається звичайним і взагалі не знає про своє оточення; за потреби бібліотека перевіряє контекст через Fiber::getCurrent(). Зворотний бік: волокно не є Iterator, у нього немає ключів і foreach, тож для лінивого перебору даних генератор нікуди не подівся.

Руками new Fiber() майже ніхто не пише, і це нормальна відповідь на питання «навіщо воно тоді». RFC свідомо лишив у ядрі лише примітив, без планувальника й циклу подій, бо його місце — у користувацькому просторі. Планувальником став revolt/event-loop — спільний цикл подій, який використовують AMPHP v3 і ReactPHP; поверх нього amphp/amp v3 дає Amp\async() (запускає волокно) та Future::await() (усередині кличе suspend()). Найкраща ілюстрація зиску — сама історія AMPHP: у v2 корутини будувалися на генераторах і кожен асинхронний виклик писався як yield $promise, у v3 бібліотеку переписали на волокна, і yield із користувацького коду зник — асинхронний виклик виглядає як звичайний. У ReactPHP той самий підхід дає пакет react/async зі своїми async()/await().

Чому це не async у розумінні JavaScript. По-перше, у PHP немає вбудованого рантайму з циклом подій: у Node цикл є завжди й усі I/O-API неблокуючі за замовчуванням, у PHP цикл треба принести бібліотекою і явно запустити. По-друге, у ядрі немає ані Promise, ані ключових слів async/await — є один клас Fiber. По-третє й найважливіше практично: волокно не перетворює блокуючий виклик на неблокуючий. PDO::query(), file_get_contents(), curl_exec(), sleep() зупиняють увесь процес разом з усіма волокнами, тож конкурентність зʼявляється лише тоді, коли ви користуєтесь неблокуючими клієнтами (amphp/http-client, amphp/mysql, amphp/postgres, amphp/socket). Це відрізняє підхід ядра PHP від Swoole, який підміняє блокуючі функції власними реалізаціями через runtime hooks.

Межі варто назвати одразу, щоб відповідь звучала як досвід, а не як переказ документації. Волокна — це конкурентність, а не паралелізм: у кожен момент виконується рівно одне волокно, і задачу, що впирається в CPU, вони не пришвидшать — там потрібні amphp/parallel, ext-parallel або кілька воркерів. У класичній моделі PHP-FPM виграш обмежений одним запитом: розпаралелити три звернення до зовнішніх API можна, а тримати цикл подій між запитами — ні (для цього потрібен довгоживучий воркер). Памʼять теж не безкоштовна: кожне призупинене волокно тримає власний стек, тому «мільйон волокон» — не той дизайн, який варто пропонувати. І нарешті, ресурси всередині волокна закривають у finally: коли призупинене волокно втрачає останнє посилання, PHP розкручує його стек саме заради finally, а призупинитися ще раз у цей момент уже не дасть.

// Функції нижче — звичайні: ні yield, ні Generator у сигнатурах
function fetchBody(string $url): string
{
    return readResponse($url);        // виклик на рівень глибше
}

function readResponse(string $url): string
{
    // тут був би неблокуючий сокет; призупиняємось із глибини стека
    $answer = Fiber::suspend("чекаю на {$url}");

    return "тіло {$url} ({$answer})";  // suspend() повертає те, що дали в resume()
}

$fibers = [
    new Fiber(fn (): string => fetchBody('/api/jobs')),
    new Fiber(fn (): string => fetchBody('/api/companies')),
];

// start() повертає значення, передане у Fiber::suspend()
foreach ($fibers as $fiber) {
    echo $fiber->start(), PHP_EOL;    // «чекаю на /api/jobs», «чекаю на /api/companies»
}

// Примітивний планувальник: обидва волокна вже «в польоті» одночасно
foreach ($fibers as $fiber) {
    if ($fiber->isSuspended()) {
        $fiber->resume('200 OK');     // значення повертається з suspend()
    }
}

foreach ($fibers as $fiber) {
    // getReturn() дійсний лише після завершення волокна
    echo $fiber->isTerminated() ? $fiber->getReturn() : 'ще виконується', PHP_EOL;
}

try {
    Fiber::suspend('з {main}');       // поза волокном це заборонено
} catch (FiberError $e) {
    echo $e->getMessage(), PHP_EOL;   // Cannot suspend outside of fiber
}
Що `Fiber` — це не «async у PHP», а лише механізм призупинення: RFC свідомо не додав ані планувальника, ані циклу подій, ані `Promise` в ядро.
Що головна відмінність від генератора — власний стек: `Fiber::suspend()` викликається з будь-якої глибини вкладених функцій, і проміжні функції не треба переписувати на `yield from`.
Що волокна кооперативні й однопотокові: у кожен момент виконується рівно одне, паралельності на кількох ядрах вони не дають.
Що реальні споживачі — `revolt/event-loop` (спільний цикл подій для AMPHP v3 і ReactPHP) та `amphp/amp` v3; AMPHP v2 будував корутини на генераторах і `yield`, v3 переписали на волокна й `yield` з користувацького коду зник.
Що блокуючий виклик (`PDO::query`, `file_get_contents`, `sleep`, `curl_exec`) усередині волокна блокує весь процес — волокно саме собою нічого не робить неблокуючим.
Казати «Fibers — це потоки» або «тепер PHP паралелить запити на ядрах»: це один процес, один потік, кооперативне перемикання за явним `suspend()`.
Обгортати у волокно звичайний `PDO`/`file_get_contents` і чекати прискорення: потрібні неблокуючі клієнти (`amphp/http-client`, `amphp/mysql`, `amphp/postgres`), інакше виграшу нуль.
Викликати `Fiber::suspend()` із `{main}` або з коду поза волокном — буде `FiberError: Cannot suspend outside of fiber`.
Читати `$fiber->getReturn()`, поки волокно ще призупинене — `FiberError`; спочатку `isTerminated()`.
Плутати ролі: брати `Fiber` там, де потрібна лінива послідовність даних — для ітерації по мільйону рядків правильний інструмент і далі генератор.
Стверджувати, що в PHP 8.1 зʼявилися `async`/`await` і `Promise`: у ядрі є лише клас `Fiber`, решта — бібліотеки.
ПОРАДА

Формула, яка закриває питання: «генератор — це корутина, видима в сигнатурі; волокно — корутина, невидима для викликаного коду». Далі одне речення про межі: «PHP отримав перемикач стеків, а планувальник і неблокуючий I/O довелося взяти з Revolt і AMPHP».

Сторінка питання →
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` там перетворюється на витік із перевикористаними ключами.

Сторінка питання →
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`».

Сторінка питання →
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 запитів. Ще краще розповісти про реальний витік, який ви знайшли.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти
ПІДТЕМИ
Типи і приведення ООП і трейти Замикання Garbage collector PHP 8.4–8.5 Автозавантаження
НА ЧОМУ ВАЛЯТЬСЯ

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