Питання на співбесіді з PHP
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
final class- від класу не можна успадкуватися.final public function- метод не можна перевизначити в нащадку.final const(PHP 8.1) - константу не можна перевизначити.
final class InvoiceNumberGenerator
{
public function next(): string { /* ... */ }
}
class Report extends InvoiceNumberGenerator {} // Fatal error
Чому final за замовчуванням:
- Успадкування - найсильніший зв'язок. Нащадок залежить від усіх деталей предка. Будь-яка зміна в класі може зламати чужого нащадка, про якого автор навіть не знає.
- Свобода змінювати клас. Якщо клас
final, його внутрішню будову (protected-методи, порядок викликів) можна змінювати вільно: зовні доступний лише публічний API. - Свідомі рішення. Прибрати
final, коли з'явилася реальна потреба в успадкуванні, - одна правка. Повернутиfinal, коли на клас уже успадковуються, - ламаюча зміна. - Підштовхує до композиції й інтерфейсів замість ієрархій.
Типове заперечення - «а як мокати в тестах?». Мокати слід інтерфейси, а не конкретні класи. Якщо клас треба підмінити, - він реалізує інтерфейс, і тест підставляє іншу реалізацію. Для зовнішніх final-класів бібліотек є обхідні шляхи (пакет dg/bypass-finals), але це сигнал щодо дизайну.
Де final не ставлять: базові класи, призначені для успадкування (Model, Controller, абстрактні класи), і класи, які фреймворк проксує чи розширює під час виконання (наприклад, моделі Doctrine з лінивими проксі).
PHP використовує бібліотеку PCRE: функції preg_* приймають шаблон з роздільниками (/.../, ~...~, #...#) і модифікаторами після них.
preg_match('/^\d{4}-\d{2}-\d{2}$/', $date); // 1, 0 або false при помилці
preg_match('/(?<year>\d{4})-(?<month>\d{2})/', $s, $m); // $m['year'], $m['month']
preg_match_all('/#(\w+)/u', $text, $matches); // усі хештеги
preg_replace('/\s+/', ' ', $text); // стиснути пробіли
preg_replace_callback('/\d+/', fn ($m) => $m[0] * 2, $s);
preg_split('/[,;]\s*/', $list);
Модифікатор u вмикає режим UTF-8:
- шаблон і рядок обробляються як UTF-8, а не як байти;
.відповідає одному символу, а не байту;- працюють Unicode-класи:
\p{L}(будь-яка літера),\p{Lu}(велика),\p{Cyrillic}.
Без u шаблон /^.{3}$/ для «кіт» не спрацює (там 6 байтів), а \w не вважає кирилицю літерами.
preg_match('/^[\p{L}\s\'-]+$/u', "Мар'яна Іваненко"); // 1
Інші корисні модифікатори: i - без урахування регістру, m - ^/$ для кожного рядка, s - . охоплює й переведення рядка, x - пробіли й коментарі в шаблоні для читабельності.
Пастки:
- Помилка ≠ збіг:
preg_matchповертаєfalseпри помилці (зламаний UTF-8 у рядку зu, перевищення ліміту backtracking). Причина - уpreg_last_error_msg(). - Катастрофічний backtracking: шаблони на кшталт
(a+)+$на зловмисному введенні виконуються експоненційно довго (ReDoS). Уникайте вкладених квантифікаторів. - Екранування введення користувача в шаблоні - через
preg_quote($input, '/'). - Для простих випадків швидші й зрозуміліші
str_contains,str_starts_with,str_replace.
Деструктуризація - розпакування масиву в окремі змінні (короткий синтаксис замість list()):
[$first, $second] = [10, 20];
[, $second] = [10, 20]; // пропустити елемент
['id' => $id, 'name' => $name] = $user; // за ключами
[$a, [$b, $c]] = [1, [2, 3]]; // вкладене
[$a, $b] = [$b, $a]; // обмін значеннями
foreach ($rows as ['id' => $id, 'email' => $email]) {
// ...
}
Якщо ключа немає, змінна отримає null з попередженням.
Spread-оператор ... розпаковує масив у місці виклику чи в літералі масиву:
$all = [...$defaults, ...$overrides]; // склеїти масиви
$merged = [...['a' => 1], ...['a' => 2, 'b' => 3]]; // ['a' => 2, 'b' => 3] (PHP 8.1+)
function sum(int ...$numbers): int // збір аргументів (variadic)
{
return array_sum($numbers);
}
sum(...[1, 2, 3]); // передача масиву як аргументів
Що варто знати:
- Рядкові ключі в spread дозволені з PHP 8.1 і поводяться як
array_merge: пізніший ключ перемагає. Числові ключі перенумеровуються. - Spread ітератора:
[...$generator]працює з будь-якимTraversable. - Іменовані аргументи зі spread:
create(...['name' => 'Оля', 'email' => '...'])(PHP 8.1+) - ключі масиву стають іменами параметрів. - Не для великих масивів у циклі:
$result = [...$result, $item]у циклі щоразу копіює весь масив - квадратична складність. Для накопичення -$result[] = $item.
У PHP є дві системи повідомлень про проблеми: винятки (throw) і помилки рушія - E_WARNING, E_NOTICE, E_DEPRECATED. Попередження за замовчуванням лише пишеться в лог, а виконання продовжується - часто з неправильними даними:
$content = file_get_contents('/missing.txt'); // Warning, $content === false
$data = json_decode($content); // працюємо далі з false
set_error_handler перехоплює такі помилки. Найпоширеніший прийом - перетворити їх на ErrorException:
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
if (! (error_reporting() & $severity)) {
return false; // помилку придушено через @ чи налаштування
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
Тепер file_get_contents для неіснуючого файлу кидає виняток, і код не піде далі з false.
Laravel робить це за вас: обробник помилок фреймворку (HandleExceptions) перетворює попередження й notice на ErrorException. Тому в Laravel-застосунку «Undefined array key» - це виняток зі стек-трейсом, а не рядок у лозі. Застарілі (E_DEPRECATED) Laravel на винятки не перетворює, а пише в окремий канал логів, якщо його налаштовано (LOG_DEPRECATIONS_CHANNEL).
Що варто знати:
- Фатальні помилки (
E_ERROR, вичерпання пам'яті)set_error_handlerне перехоплює. Для них -register_shutdown_function()зerror_get_last(). - Оператор
@придушує попередження, але приховує й справжні проблеми. Краще перевіряти результат функції явно. - Сучасні функції часто вже кидають винятки:
json_decode(..., flags: JSON_THROW_ON_ERROR),random_int(), більшість методів класів SPL і intl.
?-> (PHP 8.0) звертається до методу чи властивості, лише якщо об'єкт ліворуч не null. Якщо він null, весь ланцюжок повертає null і далі не виконується (short-circuit).
// До PHP 8
$country = null;
if ($session !== null) {
$user = $session->user();
if ($user !== null && $user->address !== null) {
$country = $user->address->country;
}
}
// PHP 8
$country = $session?->user()?->address?->country;
Short-circuit важливий: якщо $session - null, метод user() навіть не викликається, а аргументи далі по ланцюжку не обчислюються.
Обмеження:
- Лише читання.
$user?->name = 'Оля'- помилка компіляції: присвоювати через?->не можна. - Не працює з масивами:
$data?['key']такого синтаксису немає. Для масивів -??. - Не для посилань:
foo($obj?->prop)у параметр за посиланням не передати. null- не «помилка». Якщо об'єкт мав бути там завжди,?->ховає баг: замість зрозумілого винятку код тихо працює зnull.
Разом з ?? дає значення за замовчуванням:
$city = $order->customer?->address?->city ?? 'Не вказано';
Порада: ?-> - для справді необов'язкових зв'язків (у користувача може не бути профілю). Довгі ланцюжки ?-> - запах: код занадто багато знає про внутрішню будову інших об'єктів, і варто подумати про метод на кшталт $order->shippingCity().
Головне правило - ніколи не тримати в пам'яті весь файл і не накопичувати результати. Читати рядок за рядком і одразу обробляти.
Читання потоком:
function rows(string $path): Generator
{
$file = new SplFileObject($path);
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD);
$header = null;
foreach ($file as $row) {
if ($header === null) {
$header = $row;
continue;
}
yield array_combine($header, $row);
}
}
Генератор віддає по одному рядку, тож пам'ять стала незалежно від розміру файлу.
Запис у базу - порціями:
$batch = [];
foreach (rows('import.csv') as $row) {
$batch[] = ['email' => $row['email'], 'name' => $row['name']];
if (count($batch) === 1000) {
DB::table('subscribers')->insert($batch);
$batch = [];
}
}
if ($batch !== []) {
DB::table('subscribers')->insert($batch);
}
Вставка по одному рядку - мільйон запитів; порціями по 1000 - тисяча. Масова вставка через query builder ще й не створює моделей і не запускає подій.
Що ще враховувати:
- Кодування й роздільник: CSV з Excel часто в Windows-1251 і з
;. Перекодування на льоту - потоковим фільтром (php://filter/read=convert.iconv.windows-1251/utf-8/resource=...). - Логування запитів вимкнене:
DB::enableQueryLog()у довгому імпорті накопичує всі запити в пам'яті. - Помилки в окремих рядках не мають зупиняти весь імпорт - збирати їх у звіт.
- Перервність: запам'ятовувати номер обробленого рядка, щоб продовжити після збою, а не починати спочатку.
- Черга: великий імпорт з веб-інтерфейсу - завдання в черзі, а не синхронний запит.
У Laravel те саме зручно робити через LazyCollection::make(fn () => yield from rows(...))->chunk(1000).
PHP-FPM (FastCGI Process Manager) - менеджер процесів, що виконують PHP. Веб-сервер (Nginx, Caddy) передає йому запити по FastCGI, а FPM роздає їх пулу воркерів. Один воркер обробляє один запит за раз.
Режими менеджера процесів (pm):
static- фіксована кількість воркерів (pm.max_children). Передбачувано, найкраще для виділених серверів.dynamic- кількість змінюється міжpm.min_spare_serversіpm.max_children, стартує зpm.start_servers. Типовий вибір.ondemand- воркери створюються лише під запити й завершуються після простою. Економить пам'ять на малонавантажених сайтах, але перший запит повільніший.
Як порахувати pm.max_children: обмеження - пам'ять.
max_children ≈ (пам'ять для PHP) / (середня пам'ять одного воркера)
Сервер з 4 ГБ, з яких 1 ГБ потрібно базі й системі, і воркери по ~60 МБ: (3072 / 60) ≈ 50. Реальне споживання воркера дивляться в ps чи статусній сторінці FPM під навантаженням.
Що буде при помилці:
- Замало воркерів: запити стають у чергу, час відповіді росте, у лозі FPM -
server reached pm.max_children setting. - Забагато: під навантаженням пам'ять закінчується, сервер іде в swap чи ядро вбиває процеси - гірше, ніж черга.
Корисні налаштування:
pm.max_requests- перезапускати воркер після N запитів, щоб обмежити наслідки витоків пам'яті.request_terminate_timeout- вбивати завислий запит.pm.status_path- сторінка статусу: активні й вільні воркери, довжина черги.slowlogзrequest_slowlog_timeout- стек-трейси повільних запитів.
Альтернатива - довгоживучі сервери (FrankenPHP, RoadRunner, Swoole через Laravel Octane): фреймворк завантажується один раз, але з'являються проблеми стану між запитами.
За замовчуванням Composer шукає пакети на Packagist. Інші джерела додають у секцію repositories.
Git-репозиторій (приватний пакет):
"repositories": [
{ "type": "vcs", "url": "git@github.com:acme/billing-sdk.git" }
],
"require": {
"acme/billing-sdk": "^2.1"
}
Composer читає теги репозиторію як версії. Доступ - через SSH-ключ або токен у auth.json (composer config --global github-oauth.github.com <token>), а в CI - через змінну COMPOSER_AUTH.
Локальна папка (розробка пакета поруч із застосунком):
"repositories": [
{ "type": "path", "url": "../packages/billing-sdk", "options": { "symlink": true } }
]
Зміни в пакеті одразу видно в застосунку, без публікації нових версій. Для монорепозиторіїв - "url": "packages/*".
Приватний реєстр: для кількох пакетів і великої команди зручніше Private Packagist, Satis (статичний реєстр) або реєстр пакетів GitLab/GitHub - ними керують як Packagist, з версіями й правами доступу.
Що варто знати:
- Порядок важливий: репозиторії з
repositoriesперевіряються перед Packagist. Пакет з тією ж назвою з вашого репозиторію матиме пріоритет - цим користуються для тимчасових форків з виправленням ("type": "vcs"на свій форк і версіяdev-fix-branch). - Форк - тимчасовий захід. Виправлення варто віддати в оригінальний пакет і повернутися на нього, інакше ви самі підтримуєте форк.
path-репозиторій на проді зазвичай не використовують: локальний шлях там може не існувати.
PER Coding Style - стандарт форматування PHP-коду від PHP-FIG, наступник PSR-12 (а той - PSR-2). Описує відступи (4 пробіли), розташування дужок, пробіли навколо операторів, порядок use, оформлення сигнатур, нові конструкції мови (enum, match, атрибути, property hooks).
Навіщо єдиний стиль: код-рев'ю обговорює суть, а не пробіли; diff-и не засмічуються переформатуванням; новий розробник читає код без звикання до особистих звичок кожного автора.
Інструменти, що форматують автоматично:
- Laravel Pint - обгортка над PHP-CS-Fixer з пресетом Laravel (є й пресет
per). У Laravel-проєктах - стандарт.
vendor/bin/pint # виправити
vendor/bin/pint --test # лише перевірити (для CI)
vendor/bin/pint --dirty # лише змінені файли
- PHP-CS-Fixer - гнучке налаштування правил.
- PHP_CodeSniffer (
phpcs/phpcbf) - перевірка й виправлення за стандартами.
Як впровадити, щоб працювало:
- Один конфіг у репозиторії (
pint.json) - а не налаштування в IDE кожного розробника. - Перевірка в CI - злиття неможливе, якщо стиль порушено.
- Автоформатування при збереженні в IDE чи pre-commit хук - щоб CI рідко падав.
- Одноразове переформатування всього коду окремим комітом, а його хеш - у
.git-blame-ignore-revs, щобgit blameне показував цей коміт як автора кожного рядка.
Форматування ≠ якість коду: стиль ловить пробіли й дужки, а помилки логіки й типів - статичний аналіз (PHPStan/Larastan) і тести.
Так. Enum у PHP - повноцінний тип: він може мати методи (звичайні й статичні), константи, реалізовувати інтерфейси й використовувати трейти (без властивостей).
interface HasLabel
{
public function label(): string;
}
enum OrderStatus: string implements HasLabel
{
case New = 'new';
case Paid = 'paid';
case Shipped = 'shipped';
public const DEFAULT = self::New;
public function label(): string
{
return match ($this) {
self::New => 'Нове',
self::Paid => 'Оплачене',
self::Shipped => 'Відправлене',
};
}
public function isFinal(): bool
{
return $this === self::Shipped;
}
public static function forSelect(): array
{
return array_combine(
array_column(self::cases(), 'value'),
array_map(fn (self $s) => $s->label(), self::cases()),
);
}
}
$order->status->label(); // 'Оплачене'
OrderStatus::DEFAULT; // OrderStatus::New
Навіщо це: вся поведінка, пов'язана з переліком, живе в одному місці. Замість if ($status === 'paid' || $status === 'shipped'), розкиданих по коду, - $status->isFinal(). Додали новий варіант - match без default змусить оновити кожен метод.
Інтерфейси дозволяють використовувати різні enum однаково: Filament і багато бібліотек розпізнають інтерфейси на кшталт HasLabel, HasColor, HasIcon і самі показують мітку, колір і іконку.
Обмеження:
- Немає властивостей (стану) - лише константи.
- Не можна успадкувати один enum від іншого.
- Трейти - лише без властивостей.
- Магічні методи заборонені, крім
__call,__callStaticі__invoke.
Варіант enum - це об'єкт, а ключем масиву в PHP може бути лише int чи string. Спроба дає помилку:
$limits = [Plan::Free => 3, Plan::Pro => 100];
// TypeError: Cannot access offset of type Plan on array
Варіанти:
->valuebacked enum як ключ:
$limits = [Plan::Free->value => 3, Plan::Pro->value => 100];
$limits[$user->plan->value];
Просто, але втрачається тип: ключ знову рядок, і опечатка в ключі не ловиться.
- Метод на самому enum - найчастіше найкращий варіант:
enum Plan: string
{
case Free = 'free';
case Pro = 'pro';
public function projectLimit(): int
{
return match ($this) {
self::Free => 3,
self::Pro => 100,
};
}
}
Знання про ліміт живе разом із варіантами, а новий варіант без гілки в match не пройде непомітно.
SplObjectStorageчиWeakMap- коли потрібна справжня мапа «об'єкт → значення», наприклад, для підрахунку:$counts[$status] ??= 0зWeakMapпрацює з enum як ключами.
Ще про порівняння: < і > між варіантами не мають сенсу - результат завжди false, бо порядку між об'єктами немає. Якщо порядок потрібен, - метод order(): int чи порівняння ->value для числових backed enum. Рівність перевіряють через ===: варіанти - синглтони.
Докладніше в документації: Відмінності перелічень від об'єктів
Простір імен - префікс до назв класів, функцій і констант, щоб різні бібліотеки могли мати класи з однаковими іменами: App\Models\User і Laravel\Socialite\Two\User - різні класи.
namespace App\Services\Billing;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
use Stripe\Invoice as StripeInvoice; // псевдонім, щоб не конфліктувати з нашим Invoice
final class Invoice
{
public function sync(Order $order, StripeInvoice $remote): void { /* ... */ }
}
use не підключає файл і нічого не завантажує - лише створює коротке ім'я для повного. Клас завантажить автозавантажувач у момент першого використання.
Як PHP розв'язує ім'я класу:
- Повне ім'я з
\на початку - як є:\DateTime. - Ім'я з
use- підставляється повне. - Інакше - до імені додається поточний простір імен:
new DateTime()уApp\ServicesшукатимеApp\Services\DateTime. Звідси класична помилка «Class App\Services\DateTime not found» - потрібенuse DateTime;чи\DateTime.
Функції й константи розв'язуються інакше: якщо в поточному просторі функції немає, PHP шукає глобальну (strlen працює без \). Явний \strlen() чи use function strlen; трохи пришвидшує виклик, бо рушій не перевіряє простір імен, і дозволяє OPcache оптимізувати вбудовані функції.
Простори імен і PSR-4: простір імен відповідає каталогу (App\Services\Billing → app/Services/Billing), а ім'я класу - файлу. На цьому збігу тримається автозавантаження.
::class дає повне ім'я класу рядком: Invoice::class → 'App\Services\Billing\Invoice' - так посилаються на класи в конфігурації без помилок у рядках.
Одинарні лапки - рядок «як є». Змінні не підставляються, з екранувань працюють лише \' і \\.
'Привіт, $name\n'; // буквально: Привіт, $name\n
Подвійні лапки - з інтерполяцією змінних і екрануваннями (\n, \t, \u{1F600}, \$):
"Привіт, $name\n";
"Сума: {$order->total} грн"; // складні вирази - у фігурних дужках
"Перший: {$items[0]['title']}";
Heredoc - багаторядковий рядок з інтерполяцією, як подвійні лапки:
$html = <<<HTML
<p>Привіт, {$user->name}</p>
<p>Замовлень: {$count}</p>
HTML;
Nowdoc - багаторядковий без інтерполяції, як одинарні лапки (мітка в лапках):
$template = <<<'SQL'
SELECT * FROM users WHERE email = :email
SQL;
З PHP 7.3 закривальну мітку можна відступати - відступ прибирається з усіх рядків, що зручно для коду всередині класів.
Що варто знати:
- Інтерполяція
${name}застаріла з PHP 8.2 - лише{$name}. - Швидкодія однакова: історична порада «одинарні лапки швидші» сьогодні не має значення - рядки компілюються один раз і кешуються в OPcache.
- Виклики методів і функцій у рядку не працюють без змінної:
"{$user->name()}"- так,"{strtoupper($name)}"- ні. - Nowdoc зручний для SQL, шаблонів і регулярних виразів, де
$має бути буквальним. - Інтерполяція - не для HTML і SQL з даними користувача: екранування (
htmlspecialchars) і прив'язка параметрів обов'язкові незалежно від способу складання рядка.
Суперглобальні змінні доступні в будь-якому місці коду без global:
$_GET,$_POST- параметри рядка запиту й тіла форми;$_COOKIE,$_FILES,$_REQUEST(суміш перших трьох);$_SERVER- заголовки, метод, шлях, IP, змінні середовища сервера;$_SESSION,$_ENV,$GLOBALS.
Чому не брати дані напряму:
- Усе - від користувача і все - рядки чи масиви.
$_GET['id']може бути'5','5 OR 1=1', масивом['x'](?id[]=x) чи взагалі відсутнім. Без валідації це шлях до SQL-ін'єкцій, XSS і помилок типів. - Глобальний стан. Код, що читає
$_POSTу глибині сервісу, неможливо протестувати без підробки глобальних змінних і неможливо перевикористати в консольній команді чи черзі. $_REQUESTзмішує джерела, і порядок пріоритету залежить від налаштувань (request_order) - незрозуміло, звідки прийшло значення.$_SERVERтеж від клієнта:HTTP_HOST,HTTP_X_FORWARDED_FOR, будь-якіHTTP_*- це заголовки, які можна підробити.
У фреймворку - об'єкт запиту і валідація:
public function update(Request $request, Post $post)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:200'],
'published' => ['boolean'],
]);
$post->update($data);
}
Запит передається явно (його легко підробити в тесті), дані проходять перевірку, а $request->ip() враховує налаштування довірених проксі замість сирого заголовка.
У довгоживучих серверах (Octane) суперглобальні змінні й зовсім ненадійні: фреймворк сам формує об'єкт запиту для кожного запиту.
Атрибути (PHP 8.0) - структуровані метадані, прикріплені до класів, методів, властивостей, параметрів, функцій і констант. Синтаксис #[...]. На відміну від анотацій у PHPDoc, атрибути - частина мови: перевіряються синтаксично, мають класи й автодоповнення.
Оголошення атрибута:
#[Attribute(Attribute::TARGET_METHOD)]
final class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
) {}
}
Використання:
final class UserController
{
#[Route('/users', method: 'GET')]
public function index(): Response { /* ... */ }
}
Читання через Reflection:
foreach ((new ReflectionClass(UserController::class))->getMethods() as $method) {
foreach ($method->getAttributes(Route::class) as $attribute) {
$route = $attribute->newInstance(); // створює об'єкт Route з аргументами
$router->add($route->method, $route->path, [$method->class, $method->name]);
}
}
Важливо: атрибут сам нічого не робить. Це лише дані, які хтось має прочитати. Об'єкт атрибута створюється лише при newInstance().
Вбудовані атрибути PHP: #[\Override] (8.3) - перевірка, що метод справді перевизначає батьківський; #[\SensitiveParameter] (8.2) - приховати значення параметра в стек-трейсах (паролі); #[\Deprecated] (8.4); #[\NoDiscard] (8.5) - попередження, якщо результат функції проігноровано; #[\AllowDynamicProperties].
У Laravel: атрибути контейнера (#[Config('app.name')], #[CurrentUser], #[Storage('s3')]), атрибути моделей (#[ObservedBy], #[ScopedBy]), атрибути Livewire (#[Url], #[Locked], #[Computed]).
Продуктивність: читання через Reflection не безкоштовне - фреймворки кешують результат (кеш маршрутів, скомпільований контейнер).
Питання з реальних технічних співбесід - 100 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- ООП 12 Масиви й рядки 12 Типи й помилки 12 Пам'ять і продуктивність 12 Composer і PSR 12 Основи мови 12 Функції й замикання 10 PHP 8+ 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії