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

PHP: питання на співбесіді рівня Senior

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

33 питань

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

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

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

Інші рівні
Junior 32 Middle 35

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