Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

100 питань

PHP 8.4 (листопад 2024) - головні зміни:

Property hooks - логіка читання й запису властивості без геттерів і сеттерів:

public string $email {
    set => mb_strtolower($value);
}

Асиметрична видимість - різні права на читання й запис: public private(set) string $status.

new без дужок у ланцюжку:

$name = new Profile($user)->displayName();   // раніше: (new Profile($user))->displayName()

Нові функції масивів: array_find(), array_find_key(), array_any(), array_all():

$admin = array_find($users, fn (User $u) => $u->isAdmin());
$allPaid = array_all($orders, fn (Order $o) => $o->isPaid());

Лінива ініціалізація об'єктів (lazy objects) - вбудована підтримка ghost- і proxy-об'єктів, що ініціалізуються при першому зверненні. Корисно для ORM і контейнерів.

#[\Deprecated] - позначати власні функції й методи застарілими, і PHP сам видаватиме E_USER_DEPRECATED при виклику.

Інше:

  • Новий DOM API з підтримкою HTML5 (Dom\HTMLDocument).
  • mb_trim(), mb_ucfirst(), mb_lcfirst().
  • BCMath отримав об'єктний API (BcMath\Number) з перевантаженням операторів.
  • request_parse_body() - розбір тіла для PUT/PATCH.

Застарілим стало: неявно nullable параметри - function f(Type $x = null) тепер видає попередження; правильно ?Type $x = null. Це одна з найчастіших змін при оновленні старого коду.

Докладніше в документації: Реліз PHP 8.4

PHP 8.5 (листопад 2025) - головні нововведення:

Оператор конвеєра |> - передає значення ліворуч у callable праворуч, без проміжних змінних і вкладених викликів:

$slug = $title
    |> trim(...)
    |> mb_strtolower(...)
    |> (fn (string $s) => preg_replace('/\s+/', '-', $s));

// замість
$slug = preg_replace('/\s+/', '-', mb_strtolower(trim($title)));

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

clone з новими значеннями - зручні «withers» для незмінних об'єктів, зокрема з readonly-властивостями:

public function withAmount(int $amount): static
{
    return clone($this, ['amount' => $amount]);
}

#[\NoDiscard] - PHP попередить, якщо результат функції проігноровано. Захищає від помилок на кшталт виклику методу незмінного об'єкта без присвоєння результату.

URI extension - вбудований розбір і нормалізація URL за RFC 3986 і стандартом WHATWG (Uri\Rfc3986\Uri, Uri\WhatWg\Url) замість неточного parse_url().

Інше:

  • array_first() і array_last() - перший і останній елемент без маніпуляцій з внутрішнім вказівником масиву.
  • Замикання й first-class callables у константних виразах (наприклад, у значеннях атрибутів і параметрів за замовчуванням).
  • Стек-трейси для фатальних помилок.
  • Постійні cURL share handles - повторне використання з'єднань і DNS-кешу між запитами.

Як оновлюватися: спершу прогнати тести й статичний аналіз на новій версії в CI, переглянути список застарілого в посібнику з міграції, і лише потім оновлювати прод.

Докладніше в документації: Реліз PHP 8.5

Типізовані константи класу (PHP 8.3) - тип можна вказати й для константи:

interface HasVersion
{
    const string VERSION = '1.0';
}

class Api implements HasVersion
{
    const string VERSION = '2.0';   // ок
    // const VERSION = 2;           // помилка: константа має бути string
}

Раніше нащадок чи клас, що реалізує інтерфейс, міг перевизначити константу значенням будь-якого типу, і код, що на неї покладався, отримував сюрприз.

#[\Override] - позначає метод, який має перевизначати метод батьківського класу чи інтерфейсу. Якщо такого методу в предка немає, PHP видасть помилку:

class BaseController
{
    protected function authorizeRequest(): void {}
}

class UserController extends BaseController
{
    #[\Override]
    protected function authorizeRequest(): void {}   // ок

    #[\Override]
    protected function authoriseRequest(): void {}   // помилка: опечатка, нічого не перевизначено
}

Навіщо #[\Override]:

  • Ловить опечатки в назві перевизначеного методу - без атрибута такий метод просто ніколи не викликається фреймворком.
  • Ловить зміни в батьківському класі: якщо бібліотека перейменувала чи видалила метод, усі «перевизначення» в нащадках одразу дадуть помилку замість тихого мертвого коду.
  • Документує намір для читача.

Ще з PHP 8.3: json_validate() (перевірка без розбору всього документа в пам'ять), динамічне звернення до констант класу (Foo::{$name}), повторна ініціалізація readonly-властивостей у __clone, нові методи Random\Randomizer, mb_str_pad().

Докладніше в документації: Реліз PHP 8.3

Анонімні й стрілкові функції в PHP - об'єкти класу Closure. Цей клас має методи для керування контекстом виконання.

Closure::fromCallable() - створює замикання з будь-якого callable: імені функції, [$object, 'method'], [Class::class, 'staticMethod']. Сьогодні замість нього пишуть first-class callable syntax:

$fn = Closure::fromCallable([$this, 'normalize']);
$fn = $this->normalize(...);    // те саме, з PHP 8.1

bind() / bindTo() - змінюють $this і область видимості класу (scope) замикання:

final class Counter
{
    private int $count = 5;
}

$peek = function (): int {
    return $this->count;    // приватна властивість
};

$bound = Closure::bind($peek, new Counter(), Counter::class);
echo $bound();   // 5

Другий аргумент - новий $this, третій - клас, від імені якого замикання має доступ до приватних і захищених членів.

call() - тимчасово прив'язати й одразу викликати: $peek->call(new Counter()).

Де це використовують:

  • Макроси в Laravel (Collection::macro('toUpper', function () { return $this->map(...); })) - замикання прив'язується до екземпляра колекції, тож $this усередині - колекція.
  • Тести й налагодження - доступ до приватного стану без Reflection.
  • DSL і конфігурація - виконати колбек «всередині» об'єкта-будівника.

Застереження: доступ до приватних членів через bind обходить інкапсуляцію. У робочому коді це майже завжди ознака, що потрібен публічний метод. Статичне замикання (static function) прив'язати до об'єкта не можна взагалі.

Докладніше в документації: Клас Closure

Замикання, створене всередині методу класу, автоматично захоплює $this - навіть якщо не використовує його. Ключове слово static вимикає це:

final class ReportBuilder
{
    public function totals(array $rows): array
    {
        return array_map(static fn (array $row): int => $row['qty'] * $row['price'], $rows);
    }
}

Навіщо:

  • Пам'ять і час життя об'єкта. Звичайне замикання тримає посилання на $this. Якщо його зберегли надовго (у кеші, в реєстрі обробників подій, у властивості іншого об'єкта), то й весь об'єкт з усіма залежностями не звільниться. У довгоживучих процесах (Octane, воркери) це шлях до витоків пам'яті.
  • Ясність намірів. static показує читачу й аналізатору: колбек не залежить від стану об'єкта, це чиста функція даних.
  • Захист від випадкового доступу: використання $this у статичному замиканні - помилка, а не тиха залежність.
  • Незначна економія: менше роботи на прив'язку контексту.

Обмеження: статичне замикання не можна прив'язати до об'єкта через bind()/bindTo(). Тому в місцях, де фреймворк навмисно прив'язує замикання (макроси Laravel, деякі колбеки конфігурації), static ламає поведінку.

Практика: багато команд вмикають правило PHP-CS-Fixer static_lambda, яке автоматично додає static до замикань, що не використовують $this. Rector має аналогічне правило.

Те саме для стрілкових функцій: static fn - стрілкова функція без прив'язки $this, але з автоматичним захопленням інших зовнішніх змінних.

Докладніше в документації: Статичні анонімні функції

Рекурсія - функція викликає сама себе. Природна для деревовидних даних: каталоги, коментарі з відповідями, категорії, розбір вкладеного JSON.

function totalSize(array $node): int
{
    $size = $node['size'];
    foreach ($node['children'] as $child) {
        $size += totalSize($child);
    }
    return $size;
}

Обмеження в PHP:

  • Кожен виклик займає пам'ять у стеку викликів. Глибина в десятки тисяч рівнів може вичерпати стек.
  • Немає оптимізації хвостової рекурсії: навіть return f($n - 1) наприкінці функції займає новий кадр стеку.
  • Наслідки переповнення залежать від версії й платформи. PHP 8.3 додав налаштування zend.max_allowed_stack_size, з яким рушій кидає Error «Maximum call stack size reached» замість падіння. Але на частині конфігурацій процес усе одно аварійно завершується (segfault) без жодного стек-трейсу. З Xdebug діє власний ліміт xdebug.max_nesting_level.

Як обійти глибоку рекурсію - ітерацією з явним стеком:

function totalSize(array $root): int
{
    $size = 0;
    $stack = [$root];

    while ($stack !== []) {
        $node = array_pop($stack);
        $size += $node['size'];
        foreach ($node['children'] as $child) {
            $stack[] = $child;
        }
    }

    return $size;
}

Стек тепер - звичайний масив у купі, обмежений лише memory_limit.

Інші варіанти:

  • Генератори з yield from - обхід дерева без накопичення результатів у пам'яті.
  • Рекурсія в базі даних - WITH RECURSIVE для ієрархій, що зберігаються в таблиці, замість рекурсивних запитів з PHP (і N+1).

Захист від нескінченної рекурсії: умова зупинки на першому рядку і, для даних ззовні (зациклені посилання в графі), - облік уже відвіданих вузлів чи обмеження глибини.

Докладніше в документації: Функції користувача

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

Статична змінна у функції:

function countryName(string $code): string
{
    static $cache = [];

    return $cache[$code] ??= Country::where('code', $code)->value('name');
}

Властивість об'єкта - кеш живе разом з екземпляром сервісу:

final class ExchangeRates
{
    private array $rates = [];

    public function rate(string $currency): float
    {
        return $this->rates[$currency] ??= $this->fetchFromApi($currency);
    }
}

WeakMap - коли аргумент - об'єкт, і кеш не має утримувати його в пам'яті:

private WeakMap $totals;

public function total(Order $order): int
{
    return $this->totals[$order] ??= $this->calculate($order);
}

У Laravel є хелпер once(): результат замикання кешується на час запиту в межах конкретного об'єкта й місця виклику.

public function permissions(): array
{
    return once(fn () => $this->loadPermissionsFromDatabase());
}

Коли мемоізація доречна: функція чиста (той самий вхід - той самий результат), виклики з однаковими аргументами повторюються, а обчислення дороге.

Пастки:

  • Довгоживучі процеси. Статична змінна в PHP-FPM живе один запит, а в Octane чи воркері черги - доки живе процес. Кеш накопичується і може віддавати застарілі дані одного користувача іншому.
  • Нечисті функції: мемоізувати «поточного користувача» чи «час зараз» - отримати застарілі значення.
  • Ключ кешу для кількох аргументів - серіалізувати їх однозначно (serialize, json_encode), пам'ятаючи про об'єкти.
  • Мемоізація ≠ кеш застосунку. Вона живе в пам'яті процесу; для спільного між запитами кешу - Redis і Cache::remember.

Докладніше в документації: Статичні змінні

PHP використовує підрахунок посилань + збирач циклічних посилань. У звичайному веб-запиті пам'ять звільняється наприкінці запиту, тож витоки малопомітні. Але у довготривалих процесах (черги, Octane) пам'ять накопичується.

Як уникати:

  • Не зберігати стан у статичних властивостях/синглтонах між завданнями.
  • unset() великих структур, скидати накопичувачі (логи запитів DB::flushQueryLog()).
  • Обробляти дані порціями (chunk, lazy), не тримати все в пам'яті.
  • Перезапускати воркери за лімітом: queue:work --max-jobs=1000 --max-time=3600 або при досягненні --memory.

Octane має gc_collect_cycles()-хуки; Horizon автоматично перезапускає воркери, що «розпухли».

Enum у коді змінюється легко, а от рядки, які вже лежать у базі, - ні. Саме тут зʼявляються помилки після деплою.

Найнебезпечніше - перейменувати кейс:

// було
case Middle = 'middle';

// стало
case Mid = 'mid';

Код збереться, а всі наявні рядки зі значенням middle перестануть кастуватися: ValueError: "middle" is not a valid backing value. Впаде не міграція, а звичайна сторінка.

Правильний порядок для перейменування:

  1. Додати новий кейс, лишивши старий.
  2. Міграцією перевести дані: Vacancy::where('level', 'middle')->update(['level' => 'mid']).
  3. Наступним релізом прибрати старий кейс.

Додати новий кейс - безпечно, якщо колонка varchar. Але коли в базі використано нативний тип enum, потрібна ще й міграція самої колонки, а Schema::table()->change() для нативних enum працює не в усіх драйверах - подекуди доводиться писати DB::statement().

Тому колонку під enum майже завжди роблять string: перелік живе в PHP, база зберігає рядок, і зміни не потребують ALTER на великій таблиці.

Захист від падіння на невідомому значенні:

// null замість винятку, коли в базі щось несподіване
$level = VacancyLevel::tryFrom($vacancy->getRawOriginal('level'));

І ще одне: якщо enum використовується у валідації через Rule::enum(), видалений кейс одразу зробить старі збережені записи невалідними при редагуванні - про це згадують уже після скарг користувачів.

Докладніше в документації: Міграції

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

Типові симптоми:

  • Ієрархія росте вглиб: Report → PdfReport → ColoredPdfReport → ... Нова комбінація ознак - новий клас.
  • Нащадок перевизначає метод, щоб «вимкнути» поведінку предка, яка йому не потрібна.
  • Базовий клас обростає protected-хелперами, бо так зручніше нащадкам.

Композиція - об'єкт отримує інші об'єкти і делегує їм роботу:

final class ReportExporter
{
    public function __construct(
        private Formatter $formatter,   // PDF, CSV, HTML
        private Storage $storage,       // диск, S3
    ) {}

    public function export(Report $report): string
    {
        return $this->storage->put($this->formatter->format($report));
    }
}

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

Успадкування доречне, коли є справжнє «є різновидом» і спільний контракт стабільний: власні винятки від RuntimeException, моделі від Model. Корисна звичка - робити класи final за замовчуванням: тоді успадкування стає свідомим рішенням, а не випадковістю.

Докладніше в документації: Успадкування об'єктів

readonly-властивість (PHP 8.1) можна присвоїти лише один раз і лише всередині класу - зазвичай у конструкторі. Далі будь-яка спроба змінити її кидає Error. readonly class (PHP 8.2) робить такими всі властивості класу.

final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}
}

Що варто знати:

  • readonly захищає саму властивість, а не вміст об'єкта в ній: якщо там лежить змінюваний об'єкт, його поля змінювати можна.
  • Властивість мусить мати тип, і значення за замовчуванням у неї бути не може.

Змінені копії («withers»). Незмінний об'єкт не редагують, а створюють новий з іншим значенням:

public function withAmount(int $amount): static
{
    return new static($amount, $this->currency);
}

Через clone раніше так не виходило: копія мала ту саму вже ініціалізовану властивість. З PHP 8.3 readonly-властивості можна переприсвоїти в __clone(), а PHP 8.5 додав clone з переліком нових значень - clone($this, ['amount' => $amount]), без ручного конструктора.

Навіщо це все: незмінні value objects (гроші, діапазони дат, адреси) можна спокійно передавати куди завгодно - ніхто не змінить їх «під ногами» в іншому місці коду.

Докладніше в документації: Readonly-властивості

Масиви в PHP передаються за значенням, але копіюються ліниво - за принципом copy-on-write. Присвоєння чи передача в функцію не копіює дані, а лише збільшує лічильник посилань. Справжня копія з'являється в момент, коли одну з «копій» змінюють.

$a = range(1, 1_000_000);
$b = $a;        // пам'ять не виросла: обидві змінні дивляться на ті самі дані
$b[] = 1;       // тепер копія - і ще ~кілька десятків МБ

Що з цього випливає:

  • Передавати великий масив у функцію дешево, поки функція його не змінює. Передача за посиланням (&$items) «для швидкодії» зазвичай нічого не дає.
  • Посилання можуть, навпаки, спричинити копіювання: змішування посилань і звичайних змінних на ті самі дані змушує PHP розділяти масив раніше.
  • Класична пастка - foreach ($items as &$item) без unset($item) після циклу: змінна лишається посиланням на останній елемент, і наступний foreach за значенням його перезапише.

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

Докладніше в документації: Масиви

=== порівнює рядки посимвольно і зупиняється на першій розбіжності. Отже, час порівняння залежить від того, скільки перших символів збіглося. Вимірюючи час відповіді на багатьох запитах, нападник може підбирати секрет символ за символом - це timing-атака.

hash_equals() порівнює рядки за сталий час: завжди проходить усю довжину, незалежно від того, де розбіжність.

// Перевірка підпису вебхука
$expected = hash_hmac('sha256', $payload, $secret);

if (! hash_equals($expected, $request->header('X-Signature'))) {
    abort(403);
}

Де це потрібно: скрізь, де рядок від користувача порівнюється із секретом - підписи вебхуків, API-токени, коди підтвердження, CSRF-токени.

Що варто знати:

  • Першим аргументом іде відомий рядок, другим - отриманий від користувача.
  • Довжину hash_equals не приховує: рядки різної довжини дають false одразу. Тому порівнюють хеші чи HMAC фіксованої довжини, а не сирі секрети.
  • Для паролів не потрібен ні ===, ні hash_equals: password_verify() уже порівнює безпечно.

Laravel використовує hash_equals усередині - наприклад, у перевірці CSRF-токена й підписаних URL.

Докладніше в документації: hash_equals

Мета - щоб код, який використовує модуль, міг зловити саме те, що вміє обробити, і нічого зайвого.

Типова структура:

interface PaymentException extends Throwable {}

final class CardDeclined extends RuntimeException implements PaymentException
{
    public static function forReason(string $reason): self
    {
        return new self("Картку відхилено: {$reason}");
    }
}

final class GatewayUnavailable extends RuntimeException implements PaymentException {}

Принципи:

  • Маркерний інтерфейс модуля (PaymentException) дозволяє зловити все з модуля одним catch, не прив'язуючись до ієрархії класів.
  • Успадкування від SPL-винятків (RuntimeException, InvalidArgumentException, LogicException) зберігає зміст: LogicException - помилка програміста, RuntimeException - обставини під час виконання.
  • Іменовані конструктори (forReason()) тримають формулювання повідомлень в одному місці.
  • Контекст як властивості, а не лише в тексті: $e->orderId можна записати в лог чи показати користувачу без розбору рядка.
  • Ланцюжок причин: загортаючи чужий виняток, передавайте його як previous - інакше в логах загубиться справжня причина.

Чого уникати: винятків для звичайного керування потоком (користувач не знайдений - часто це null, а не виняток) і одного «універсального» AppException на все.

Докладніше в документації: Розширення винятків

  • void (PHP 7.1) - функція нічого не повертає. return; дозволено, return null; - ні.
  • never (PHP 8.1) - функція ніколи не повертає керування: завжди кидає виняток, завершує скрипт чи працює нескінченно.
  • static (PHP 8.0) - повертає екземпляр класу, через який метод викликали (а не того, де він оголошений).
function abortNotFound(): never
{
    throw new NotFoundHttpException();
}

function process(?Order $order): void
{
    $order ?? abortNotFound();
    $order->ship(); // аналізатор знає: тут $order уже не null
}

Навіщо це:

  • never дає статичному аналізатору й IDE зрозуміти, що код після виклику недосяжний. Так звужується тип, і зникають хибні попередження «можливий null».
  • static потрібен для fluent-інтерфейсів і фабрик у базових класах: Builder::where() у нащадку повертає нащадка, а не базовий клас.
  • void чесно документує, що результат не варто використовувати.

Усі три перевіряються рушієм: функція з never, яка все ж завершилася, кине TypeError.

Докладніше в документації: Тип never

Питання з реальних технічних співбесід - 100 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 32 Middle 35 Senior 33

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії