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

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

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

100 питань

JIT (PHP 8.0) компілює гарячий байт-код у машинний код процесора. Звичайний PHP виконує опкоди віртуальною машиною; з JIT частина коду виконується напряму, без інтерпретації.

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

Чому веб-застосунку - ні: типовий запит більшу частину часу чекає: на базу даних, Redis, зовнішній API, файлову систему. Сам PHP-код - це складання масивів і виклики функцій, які й без JIT виконуються швидко. Прискорити 10% часу запиту вдвічі - означає виграти 5%.

Що реально пришвидшує веб:

  • OPcache з достатнім розміром (це основа, без неї JIT і не працює).
  • Менше запитів до БД, індекси, кеш.
  • Кешування конфігурації, маршрутів і подань (php artisan optimize).
  • Довгоживучий процес (Octane, FrankenPHP), щоб не завантажувати фреймворк на кожному запиті.

Мінуси JIT: більше пам'яті, складніше налагоджувати, і в окремих версіях траплялися баги саме в JIT. Тому вмикають його після вимірювань на своєму навантаженні, а не «про всяк випадок».

Докладніше в документації: Налаштування OPcache JIT

Preloading (PHP 7.4) завантажує вказані файли в пам'ять один раз - під час старту PHP-FPM. Класи, функції й константи з них стають доступні всім запитам так, ніби вони вбудовані в PHP: без автозавантаження, без перевірки файлів, без зв'язування класів на кожному запиті.

opcache.preload=/var/www/preload.php
opcache.preload_user=www-data
// preload.php
require __DIR__ . '/vendor/autoload.php';

foreach ($classmap as $file) {
    opcache_compile_file($file);
}

Обмеження:

  • Зміна коду - лише через перезапуск PHP-FPM. Перевірка файлів для попередньо завантаженого коду не працює взагалі.
  • Один застосунок на пул. Завантажені класи глобальні для всього процесу, тож кілька застосунків з різними версіями однієї бібліотеки в одному пулі конфліктуватимуть.
  • Пам'ять витрачається на все завантажене, навіть на класи, які рідко потрібні. Завантажувати весь vendor - погана ідея; краще найчастіше використовувані класи.
  • Виграш скромний: зазвичай кілька відсотків, бо OPcache і так усуває компіляцію. Найбільше він помітний на фреймворках з тисячами класів.

Довгоживучі сервери (Octane, FrankenPHP worker mode) дають те саме й більше - фреймворк завантажується один раз на процес, тож з ними preloading майже нічого не додає.

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

За замовчуванням PSR-4 автозавантажувач для кожного класу обчислює шлях і перевіряє, чи існує файл. На тисячах класів ці перевірки файлової системи помітні.

Рівень 1 - classmap (composer install --optimize-autoloader або -o). Composer заздалегідь будує мапу «клас → файл» для всіх PSR-4/PSR-0 класів. Знайти клас - це один пошук у масиві, який до того ж лежить в OPcache.

Рівень 2 - authoritative classmap (--classmap-authoritative або -a). Те саме, але якщо класу немає в мапі, Composer не шукає його у файловій системі, а одразу вирішує, що класу немає. Швидше для перевірок class_exists() на неіснуючих класах.

Рівень 2/B - APCu (--apcu-autoloader). Знайдені шляхи кешуються в APCu. Альтернатива для випадків, коли authoritative не підходить.

composer install --no-dev --classmap-authoritative

Обмеження authoritative: класи, згенеровані під час виконання (проксі, скомпільовані шаблони в нестандартних місцях), не знайдуться, бо їх не було в мапі під час збирання. Тому його вмикають і перевіряють на стейджингу.

--no-dev теж допомагає: dev-залежності не потрапляють у мапу й не встановлюються на сервер.

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

Це спільні інтерфейси PHP-FIG для роботи з HTTP. Вони дозволяють бібліотекам не залежати від конкретного фреймворку чи HTTP-клієнта:

  • PSR-7 - інтерфейси HTTP-повідомлень: RequestInterface, ResponseInterface, UriInterface, потоки. Об'єкти незмінні: withHeader() повертає нову копію.
  • PSR-17 - фабрики для створення цих об'єктів, щоб бібліотека не викликала new конкретного класу.
  • PSR-15 - серверні обробники й middleware: RequestHandlerInterface, MiddlewareInterface.
  • PSR-18 - HTTP-клієнт: ClientInterface::sendRequest().
final class GithubApi
{
    public function __construct(
        private ClientInterface $http,             // Guzzle, Symfony HttpClient...
        private RequestFactoryInterface $requests,
    ) {}
}

Що це дає:

  • SDK пише код один раз, а застосунок підставляє клієнт, який уже використовує.
  • Middleware, написане за PSR-15, працює в Slim, Mezzio та інших PSR-сумісних фреймворках.
  • У тестах легко підставити фейковий клієнт.

Laravel використовує власні класи Request/Response на базі Symfony HttpFoundation, а не PSR-7. Але конвертувати можна (пакет symfony/psr-http-message-bridge), а Guzzle під HTTP-клієнтом Laravel реалізує PSR-18.

Докладніше в документації: Стандарти PHP-FIG

clone $obj створює новий об'єкт з тими самими значеннями властивостей. Але копія поверхнева: якщо властивість містить інший об'єкт, копіюється лише посилання на нього - обидва об'єкти ділять той самий вкладений об'єкт.

final class Order
{
    public function __construct(public DateTime $createdAt, public array $items) {}
}

$a = new Order(new DateTime('2026-01-01'), ['book']);
$b = clone $a;

$b->items[] = 'pen';                 // масив скопійовано: $a->items не змінився
$b->createdAt->modify('+1 day');     // об'єкт спільний: $a->createdAt теж змінився!

Масиви копіюються (за значенням), об'єкти всередині - ні.

__clone() викликається на копії після клонування - там роблять глибоку копію вкладених об'єктів:

public function __clone(): void
{
    $this->createdAt = clone $this->createdAt;
}

Де це стає проблемою:

  • «Незмінні» об'єкти з змінюваними частинами. Wither, що робить clone $this і змінює одне поле, ділить усі вкладені змінювані об'єкти з оригіналом.
  • Ресурси й з'єднання. Клон об'єкта з відкритим файлом чи з'єднанням з базою ділить той самий ресурс; закриття в одному ламає інший.
  • Ідентичність: клон Eloquent-моделі зберігає той самий первинний ключ. Для копії запису в базі є replicate().

Як уникнути проблем: робити вкладені значення незмінними (DateTimeImmutable, readonly value objects) - тоді поверхнева копія безпечна. З PHP 8.3 readonly-властивості можна переприсвоїти в __clone(), а PHP 8.5 додав clone($obj, ['prop' => $value]) для «зміненої копії» без ручного __clone.

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

Property hooks (PHP 8.4) дозволяють прив'язати логіку до читання й запису властивості - без геттерів і сеттерів і без магічних __get/__set.

final class User
{
    public string $email {
        set (string $value) {
            if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('Некоректний email');
            }
            $this->email = mb_strtolower($value);
        }
    }

    public string $displayName {
        get => trim("{$this->firstName} {$this->lastName}");   // віртуальна властивість
    }

    public function __construct(public string $firstName, public string $lastName, string $email)
    {
        $this->email = $email;
    }
}

$user->email = 'Olia@Example.com';   // спрацює set-хук
echo $user->displayName;            // спрацює get-хук

Властивість, що має лише get-хук і не звертається до власного значення, - віртуальна: місця для неї не виділяється.

Асиметрична видимість - різні модифікатори для читання й запису:

final class Order
{
    public private(set) string $status = 'new';   // читати - всім, змінювати - лише класу

    public function ship(): void
    {
        $this->status = 'shipped';
    }
}

Скорочення: private(set) string $status означає public private(set).

Що це змінює:

  • Публічна властивість більше не означає «будь-хто може записати що завгодно» - валідацію й нормалізацію можна додати пізніше, не змінюючи API на методи.
  • Відпадає шаблонний код геттерів для «читається всім, змінюється лише всередині».
  • Хуки можна оголошувати в інтерфейсах: public string $name { get; }.

Обмеження: хуки й readonly не поєднуються, а посилання на властивість з set-хуком (&$obj->prop) заборонене.

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

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

HTML (вміст і атрибути):

echo htmlspecialchars($comment, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

ENT_QUOTES екранує обидва типи лапок (важливо для атрибутів), ENT_SUBSTITUTE замінює зламаний UTF-8 замість повернення порожнього рядка. Blade {{ }} робить саме це.

Але HTML-екранування не захищає атрибут href: javascript:alert(1) пройде без змін. URL від користувача перевіряють за схемою (http/https).

SQL - не екранувати, а прив'язувати параметри:

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);

addslashes для SQL небезпечний (кодування, інші СУБД), а ручне екранування легко забути.

URL:

$url = 'https://example.com/search?' . http_build_query(['q' => $query, 'page' => 2]);
rawurlencode($pathSegment);    // для частини шляху

Командний рядок:

exec('convert ' . escapeshellarg($input) . ' ' . escapeshellarg($output));

Ще краще - не будувати рядок команди взагалі, а передати аргументи масивом (Symfony Process, Process::run([...]) у Laravel), тоді оболонка не бере участі.

JavaScript усередині HTML:

<script>const user = <?= json_encode($user, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;</script>

У Blade - @json($user) чи Js::from($user).

Правило: знати контекст виведення і використовувати засіб саме для нього. Рядок, безпечний для HTML, може бути небезпечним у SQL чи shell.

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

Ключ масиву в PHP може бути лише int або string. Усе інше приводиться, і деякі рядки теж:

  • Рядок з десятковим цілим числом ('1', '-5') стає цілим: '1' і 1 - один ключ.
  • Рядок з ведучим нулем чи пробілом ('01', ' 1') лишається рядком.
  • float обрізається до цілого: 1.7 → 1 (з PHP 8.1 - з попередженням про втрату точності).
  • bool стає 0 або 1.
  • null стає порожнім рядком ''.
  • Масиви й об'єкти як ключі - помилка (TypeError), зокрема й enum.
$a = ['1' => 'a', 1 => 'b', true => 'c', '01' => 'd'];
// [1 => 'c', '01' => 'd'] - перші три записи перезаписали один одного

Де це кусає:

  • Ідентифікатори-рядки: масив з ключами '007' і '7' - два ключі, а з '7' і 7 - один.
  • array_merge перенумеровує числові ключі. Масив [10 => 'Київ', 20 => 'Львів'], де ключі - ID міст, після array_merge стане [0 => 'Київ', 1 => 'Львів']. Рядкові ключі, схожі на числа, - теж числові, тож проблема та сама.
  • JSON: json_encode масиву з ключами 0, 1, 2 дає масив [...], а з «дірками» (0, 2) - об'єкт {"0":..,"2":..}. Клієнт отримає інший тип даних.
  • in_array / array_search без третього параметра true порівнюють через ==.

Порада: для словників зі «справжніми» рядковими ключами, де ці правила заважають, - SplObjectStorage, Map з ds-розширення чи колекції з явними ключами; для ID - не покладатися на перенумерацію і використовувати + чи array_replace замість array_merge.

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

PHP 8 переробив правила приведення рядків до чисел, зробивши їх суворішими й передбачуванішими.

Три види рядків:

  • Числовий - лише число, з можливими пробілами на початку й у кінці: '42', ' 3.14 ', '1e3'.
  • З числовим початком - число, а за ним інші символи: '5 apples'.
  • Нечисловий - усе інше: 'abc', ''.

В арифметиці:

'42' + 1;          // 43
'5 apples' + 1;    // 6 і Warning: A non-numeric value encountered
'abc' + 1;         // TypeError

У PHP 7 'abc' + 1 давав 1 з попередженням - помилка тихо перетворювалася на нуль. Тепер це виняток.

У порівнянні ==:

  • два числові рядки порівнюються як числа: '1e3' == '1000' - true, '10' == '010' - true;
  • число з числовим рядком - як числа;
  • число з нечисловим рядком - як рядки: 0 == 'abc' - false у PHP 8 (у PHP 7 - true, бо 'abc' ставав нулем).

Під declare(strict_types=1) скалярні параметри функцій не приводяться взагалі: int $id не прийме '5' - буде TypeError. Арифметичних операторів і порівнянь strict_types не стосується.

Практичні висновки:

  • Дані з форм і запитів - завжди рядки. Приводьте їх явно й валідуйте ((int), filter_var($v, FILTER_VALIDATE_INT), правила валідації Laravel), а не покладайтеся на неявне приведення.
  • === замість ==, особливо для значень від користувача: «магічні хеші» на кшталт '0e123' == '0e456' (обидва - нуль у науковій нотації) досі true.
  • Для перевірки «чи це число» - is_numeric() (приймає числові рядки) чи ctype_digit() (лише цифри, без знаку й крапки).

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

Intersection-тип A&B (PHP 8.1) - значення має відповідати всім перелічених типам одночасно. На практиці - об'єкт, що реалізує кілька інтерфейсів:

function process(Countable&Traversable $items): void
{
    echo count($items);
    foreach ($items as $item) { /* ... */ }
}

Раніше доводилося або обирати один інтерфейс і перевіряти другий вручну (instanceof), або створювати штучний інтерфейс CountableTraversable, який нічого не додає.

DNF-типи (Disjunctive Normal Form, PHP 8.2) - поєднання union і intersection: об'єднання груп, кожна з яких - перетин.

function save((Countable&ArrayAccess)|null $data): void {}
function cache((Stringable&JsonSerializable)|string $value): void {}

Правило DNF: перетини мають бути в дужках, і вони об'єднуються через |. Запис A&(B|C) недозволений - його треба переписати як (A&B)|(A&C).

Обмеження:

  • Перетин - лише з класів і інтерфейсів, не зі скалярів (int&string не має сенсу).
  • Дублювання й надлишкові типи ((A&B)|A) - помилка компіляції.

Коли потрібні:

  • Функція справді покладається на кілька незалежних можливостей об'єкта (лічильність і ітерація, рядкове представлення й серіалізація).
  • Бібліотеки з дрібними інтерфейсами-можливостями (принцип розділення інтерфейсів) - щоб вимагати рівно потрібні, а не один «товстий».
  • Моки в тестах: PHPUnit створює об'єкти-перетини MockObject&Service, і саме такий тип має змінна з моком.

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

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

Fiber (PHP 8.1) - функція з власним стеком, яку можна призупинити посередині й пізніше відновити з того самого місця. Це кооперативна багатозадачність: файбер сам вирішує, коли віддати керування.

$fiber = new Fiber(function (string $first): string {
    $second = Fiber::suspend("отримав: {$first}");   // пауза, повертаємо значення
    return "завершено з {$second}";
});

$value = $fiber->start('A');     // 'отримав: A'
$fiber->resume('B');             // продовжує після suspend
echo $fiber->getReturn();        // 'завершено з B'

Відмінність від потоків:

  • Потоки виконуються паралельно (на різних ядрах чи з перемиканням ОС у будь-який момент). Потрібна синхронізація доступу до спільних даних.
  • Файбери виконуються по черзі в одному потоці. Лише один файбер працює в кожен момент, перемикання відбувається тільки в suspend()/resume(). Гонок даних у класичному розумінні немає.

Тобто файбери не роблять обчислення швидшими й не використовують кілька ядер.

Навіщо вони: для асинхронного вводу-виводу. Поки один файбер чекає відповідь мережі, цикл подій (event loop) запускає інший. Головне - файбер дозволяє писати асинхронний код як звичайний синхронний, без колбеків і промісів: функція глибоко в стеку може призупинитися, не змінюючи сигнатур викликачів.

Хто використовує: Revolt (event loop), AMPHP v3, ReactPHP-адаптери. Прикладному коду рідко потрібно створювати файбери напряму - це будівельний блок для бібліотек.

Застереження: звичайні блокуючі функції (PDO, file_get_contents, sleep) блокують увесь процес разом з усіма файберами. Асинхронність працює лише з неблокуючими драйверами з екосистеми event loop.

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

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

WeakReference (PHP 7.4) - посилання, яке не заважає збирачу сміття:

$user = new User();
$ref = WeakReference::create($user);

$ref->get();   // об'єкт
unset($user);
$ref->get();   // null - об'єкт уже звільнено

WeakMap (PHP 8.0) - словник, де ключі - об'єкти, утримувані слабко. Коли ключ-об'єкт звільнено, запис зникає з мапи сам:

final class PriceCalculator
{
    private WeakMap $cache;

    public function __construct()
    {
        $this->cache = new WeakMap();
    }

    public function totalFor(Order $order): int
    {
        return $this->cache[$order] ??= $this->expensiveCalculation($order);
    }
}

Кеш живе рівно стільки, скільки живе кожне замовлення, і не росте безмежно.

Де застосовують:

  • Мемоізація за об'єктом-аргументом - як у прикладі.
  • Метадані до чужих об'єктів, які не можна змінити (сутності ORM, об'єкти бібліотек).
  • Реєстри й ідентичні мапи в ORM і контейнерах, що не мають утримувати сутності довше за запит.

Обмеження: ключі WeakMap - лише об'єкти; значення утримуються сильно (якщо значення посилається на ключ, запис не звільниться). Момент звільнення залежить від лічильника посилань і збирача циклів, тож логіку коректності на ньому не будують - лише оптимізації.

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

Composer розв'язує обмеження версій усіх пакетів одночасно. Якщо для потрібного пакета немає версії, сумісної з рештою, він відмовляє з довгим повідомленням «Your requirements could not be resolved».

Інструменти для розслідування:

composer why vendor/package             # хто залежить від пакета і з якими обмеженнями
composer why-not laravel/framework 13.0 # що заважає встановити цю версію
composer outdated --direct              # які прямі залежності застаріли
composer show vendor/package --all      # усі доступні версії
composer update vendor/package -W --dry-run   # що оновиться разом із залежностями

why-not - найкорисніша: показує конкретний пакет і обмеження, що блокує версію. Типова відповідь: «пакет X вимагає illuminate/support ^11.0» - тобто X ще не оновився під нову версію Laravel.

Типові причини й рішення:

  • Старий пакет не підтримує нову версію фреймворку чи PHP. Оновити пакет, знайти форк чи альтернативу, або відкласти оновлення.
  • Залежності залежностей зафіксовані lock-файлом. composer update vendor/package оновлює лише його; -W (--with-all-dependencies) дозволяє оновити й залежності, які йому заважають.
  • Платформа: версія PHP чи розширення на машині не задовольняє require пакета. config.platform.php у composer.json фіксує цільову версію PHP прод-сервера, щоб локально не поставити пакети, які там не запрацюють.
  • Конфлікт conflict-обмежень: пакет явно оголосив несумісність з певними версіями іншого.

Чого не робити: --ignore-platform-reqs на проді «щоб встановилося» - пакети, що вимагають іншої версії PHP чи відсутнього розширення, впадуть під час виконання.

Профілактика: оновлювати залежності регулярно й невеликими порціями (Dependabot, Renovate), а не раз на два роки разом із мажорною версією фреймворку.

Докладніше в документації: Команди depends і prohibits

PSR-11 - мінімальний спільний інтерфейс контейнера залежностей. Усього два методи:

namespace Psr\Container;

interface ContainerInterface
{
    public function get(string $id);   // отримати запис або кинути NotFoundExceptionInterface
    public function has(string $id): bool;
}

Навіщо такий мінімалізм: стандарт описує лише отримання сервісів, а не їхню реєстрацію. Конфігурація (прив'язки, автовпровадження, синглтони) у кожного контейнера своя, а споживання - однакове.

Що це дало екосистемі:

  • Фреймворко-незалежні бібліотеки й фреймворки. Slim, Mezzio, роутери й диспетчери middleware приймають будь-який PSR-11-контейнер: PHP-DI, Symfony DependencyInjection, League Container, Laravel.
  • Laravel реалізує PSR-11: Illuminate\Container\Container - це ContainerInterface, тож сторонні бібліотеки, що очікують PSR-контейнер, працюють з ним.
  • Композиція контейнерів - делегування пошуку іншому контейнеру.

Застереження - Service Locator. PSR-11 прямо не радить передавати контейнер у бізнес-класи, щоб вони самі діставали залежності:

// Погано: прихована залежність, важко тестувати
final class ReportService
{
    public function __construct(private ContainerInterface $container) {}

    public function build(): void
    {
        $mailer = $this->container->get(Mailer::class);
    }
}

// Добре: залежність явна
final class ReportService
{
    public function __construct(private Mailer $mailer) {}
}

Контейнер - для «склеювання» застосунку на верхньому рівні (фабрики, фреймворк), а не для використання всередині логіки. Виняток - фабрики, що за природою створюють різні об'єкти за ідентифікатором.

Докладніше в документації: PSR-11: Container interface

Значення з запиту, бази, черги чи стороннього API - це рядок або число, яке може не відповідати жодному варіанту.

  • Status::from($value) - повертає варіант або кидає ValueError, якщо такого немає.
  • Status::tryFrom($value) - повертає варіант або null.
$status = Status::tryFrom($request->input('status'))
    ?? throw ValidationException::withMessages(['status' => 'Невідомий статус']);

Коли що:

  • from - коли невідоме значення означає баг чи пошкоджені дані, і правильна реакція - голосно впасти: значення з власної бази, з внутрішньої черги.
  • tryFrom - на межі з зовнішнім світом, де невідоме значення - нормальна ситуація, яку треба обробити: введення користувача, вебхуки, сторонні API.

На межі застосунку - валідація, а не винятки:

$request->validate([
    'status' => ['required', Rule::enum(Status::class)],
]);

$status = $request->enum('status', Status::class);

Rule::enum можна ще обмежити: ->only([Status::Draft, Status::Published]) чи ->except(...).

Підводні камені:

  • Тип значення: tryFrom('1') для int-backed enum під strict_types - TypeError, а не null. Значення з запиту спершу приводять до потрібного типу.
  • Видалений варіант при наявних даних у базі: після деплою from() при читанні моделі кидатиме ValueError на старих рядках. Спершу мігрують дані, потім прибирають варіант.
  • Зовнішні API додають нові значення без попередження. Код, що робить from() на їхній відповіді, падає в продакшені наступного ранку. tryFrom плюс логування невідомого значення - надійніше.

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

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

Рівні
Junior 32 Middle 35 Senior 33

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