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

PHP для Junior: питання на співбесіду

6 питань рівня Junior з теми Core PHP з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
6 питань
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·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.

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