Питання на співбесіді з 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. Тому вмикають його після вимірювань на своєму навантаженні, а не «про всяк випадок».
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 майже нічого не додає.
За замовчуванням 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.
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) заборонене.
Універсального «очищення» не існує: екранування залежить від того, куди потрапляє рядок. Тому дані зберігають як є, а екранують у момент виведення - під конкретний контекст.
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.
Ключ масиву в 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, і саме такий тип має змінна з моком.
Альтернатива: якщо одна й та сама комбінація інтерфейсів повторюється скрізь, можливо, це окреме поняття домену - і варто оголосити інтерфейс, що розширює обидва.
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.
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 - лише об'єкти; значення утримуються сильно (якщо значення посилається на ключ, запис не звільниться). Момент звільнення залежить від лічильника посилань і збирача циклів, тож логіку коректності на ньому не будують - лише оптимізації.
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), а не раз на два роки разом із мажорною версією фреймворку.
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) {}
}
Контейнер - для «склеювання» застосунку на верхньому рівні (фабрики, фреймворк), а не для використання всередині логіки. Виняток - фабрики, що за природою створюють різні об'єкти за ідентифікатором.
Значення з запиту, бази, черги чи стороннього 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плюс логування невідомого значення - надійніше.
Питання з реальних технічних співбесід - 100 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- ООП 12 Масиви й рядки 12 Типи й помилки 12 Пам'ять і продуктивність 12 Composer і PSR 12 Основи мови 12 Функції й замикання 10 PHP 8+ 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії