Питання на співбесіді з PHP
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Основні функції:
explode(',', 'php,laravel,vue'); // ['php', 'laravel', 'vue']
explode(',', 'a,b,c,d', 2); // ['a', 'b,c,d'] - не більше 2 частин
implode(', ', ['php', 'laravel']); // 'php, laravel'
str_split('abcdef', 2); // ['ab', 'cd', 'ef'] - байтами!
mb_str_split('привіт', 2); // ['пр', 'ив', 'іт'] - символами
preg_split('/[\s,;]+/', 'php, laravel; vue', -1, PREG_SPLIT_NO_EMPTY);
// ['php', 'laravel', 'vue'] - кілька роздільників
Типові пастки:
- Порожній рядок:
explode(',', '')повертає['']- масив з одним порожнім елементом, а не порожній масив. Для введення користувача часто потрібнеarray_filter(array_map('trim', explode(',', $input))). - Пробіли:
explode(',', 'a, b')дає['a', ' b']- з пробілом. Звідсиarray_map('trim', ...). - Кирилиця:
str_splitділить байти й ламає багатобайтові символи. Для тексту -mb_str_split. - CSV не розбирають через
explode: значення можуть містити коми в лапках ("Київ, Україна"). Для цьогоstr_getcsv()чиSplFileObjectзREAD_CSV.
У Laravel зручніше через Str і колекції:
Str::of('php, laravel , vue')->explode(',')->map(fn ($s) => trim($s))->filter();
$a ?? $b(null coalescing) - повертає$a, якщо воно існує і неnull, інакше$b. Не кидає попереджень на неіснуючих змінних і ключах.$a ?: $b(скорочений тернарний, «elvis») - повертає$a, якщо воно істинне, інакше$b. Для0,'','0',[],falseтеж поверне$b.
$page = $_GET['page'] ?? 1; // ключа немає - 1, без попередження
$page = $_GET['page'] ?: 1; // ключа немає - попередження "Undefined array key"
$discount = 0;
$discount ?? 10; // 0 - нуль не null
$discount ?: 10; // 10 - нуль хибний
$title = '';
$title ?? 'Без назви'; // ''
$title ?: 'Без назви'; // 'Без назви'
Як обирати:
??- «значення за замовчуванням, якщо нічого немає»: ключі масиву, необов'язкові параметри, конфігурація. Найчастіший випадок.?:- коли порожній рядок, нуль чи порожній масив справді треба замінити: показати «Без назви» замість порожнього заголовка.
Присвоєння з ??= (PHP 7.4):
$options['timeout'] ??= 30; // присвоїти, лише якщо ключа немає чи там null
Ланцюжки: $a ?? $b ?? $c - перше значення, що не null. Разом із nullsafe-оператором: $user?->profile?->city ?? 'Невідомо'.
match (PHP 8.0) - вираз, що повертає значення залежно від умови:
$label = match ($status) {
'new', 'pending' => 'Очікує',
'paid' => 'Оплачено',
'cancelled' => 'Скасовано',
};
Відмінності від switch:
- Строге порівняння
===.switchпорівнює через==, тожswitch ('1')спрацює наcase 1, аswitch (0)у PHP 7 - навіть наcase 'abc'. - Повертає значення - його можна присвоїти чи повернути одразу.
switch- інструкція. - Немає провалювання (fall-through) і не потрібен
break. Забутийbreakуswitch- класичний баг. - Необроблене значення - виняток
UnhandledMatchError.switchбезdefaultпросто нічого не робить, і помилка залишається непомітною. - Кілька умов через кому в одній гілці.
match (true) - заміна ланцюжку if/elseif з довільними умовами:
$grade = match (true) {
$score >= 90 => 'A',
$score >= 75 => 'B',
$score >= 60 => 'C',
default => 'F',
};
Обмеження: у кожній гілці - один вираз. Якщо гілці потрібно кілька інструкцій, - викликати метод або лишити if/switch.
У поєднанні з enum match без default - спосіб гарантувати, що кожен варіант оброблено: новий case без гілки дасть UnhandledMatchError, а статичний аналізатор (PHPStan) попередить ще до запуску.
Fatal error: Allowed memory size of 134217728 bytes exhausted означає, що скрипт спробував використати більше пам'яті, ніж дозволяє налаштування memory_limit (тут 128 МБ). PHP зупиняє виконання, щоб один запит не з'їв пам'ять усього сервера.
Типові причини:
- Усе завантажено одразу:
User::all()на мільйоні рядків,file()для великого файлу,json_decodeвеликої відповіді API. - Накопичення в циклі: масив, у який дописують результати обробки сотень тисяч записів.
- Нескінченна рекурсія чи цикл, що безкінечно додає елементи.
- Витік у довгому процесі: воркер черги, що накопичує дані між завданнями.
Як виправляти - не піднімати ліміт, а змінити підхід:
// Замість User::all()
User::query()->chunkById(1000, function ($users) {
foreach ($users as $user) { /* ... */ }
});
// Або ліниво, по одному
foreach (User::query()->lazy() as $user) { /* ... */ }
// Великий файл - рядок за рядком
$file = new SplFileObject('huge.csv');
foreach ($file as $line) { /* ... */ }
Коли збільшити ліміт доречно: справді важка, але обмежена задача - генерація великого звіту в команді, обробка зображення. Тоді точково: ini_set('memory_limit', '512M') у конкретній команді, а не глобально для веб-запитів.
Поруч - max_execution_time: ліміт часу виконання (у CLI за замовчуванням вимкнений, для веб - зазвичай 30 с). Помилка Maximum execution time exceeded - сигнал перенести роботу в чергу.
Як знайти винуватця: memory_get_peak_usage(true) між етапами або профайлер (Xdebug, Blackfire, SPX).
Посилання - друге ім'я для тієї самої змінної. Зміна через одне ім'я видна через інше.
$a = 1;
$b = &$a;
$b = 2;
echo $a; // 2
Де трапляються посилання:
- Передача в функцію за посиланням - функція змінює змінну викликача:
function addTax(array &$order): void
{
$order['total'] *= 1.2;
}
foreachза посиланням - зміна елементів масиву на місці:
foreach ($prices as &$price) {
$price = round($price, 2);
}
unset($price); // обов'язково!
Пастка з foreach: після циклу $price лишається посиланням на останній елемент. Наступний foreach ($prices as $price) (вже без &) запише в нього кожне значення по черзі - і останній елемент масиву стане копією передостаннього. Тому після циклу за посиланням - unset().
Чого посилання НЕ дають - економії пам'яті. Масиви й рядки в PHP копіюються ліниво (copy-on-write): передача у функцію за значенням не копіює дані, поки їх не змінять. «Передати за посиланням, щоб було швидше» нічого не прискорює, а інколи навпаки змушує PHP розділити масив.
Об'єкти й так передаються «за ідентифікатором»: функція, що отримала об'єкт, змінює той самий об'єкт. & для об'єктів потрібен лише щоб замінити саму змінну на інший об'єкт.
Порада: у сучасному коді посилання - рідкість. Зрозуміліше повернути нове значення ($order = addTax($order)) чи використати array_map, ніж змінювати аргументи непомітно для читача.
require- залежності, без яких застосунок не працює: фреймворк, бібліотеки для бізнес-логіки, SDK.require-dev- потрібні лише розробникам і CI: тестовий фреймворк (Pest, PHPUnit), статичний аналіз (PHPStan, Larastan), форматування (Pint), Faker, налагоджувальні панелі.
composer require guzzlehttp/guzzle
composer require --dev pestphp/pest
На продакшені dev-залежності не ставлять:
composer install --no-dev --optimize-autoloader
Це:
- зменшує розмір застосунку й образу;
- прибирає з сервера інструменти, яких там не має бути (менше коду - менша поверхня атаки);
- пришвидшує автозавантаження.
Типова помилка: клас з dev-пакета використовується в робочому коді. Локально все працює, бо dev-пакети встановлено, а на проді - Class not found. Приклад - фабрики й Faker у сидерах, які запускають на проді, або налагоджувальний пакет, підключений у сервіс-провайдері без перевірки оточення.
Як ловити: CI-крок, що встановлює залежності з --no-dev і запускає застосунок (або хоча б php artisan чи тести, що не потребують dev-пакетів), а також composer-require-checker для перевірки, що весь код покритий оголошеними залежностями.
Для бібліотек require-dev ще важливіший: споживачі бібліотеки отримують лише її require, тож тестові інструменти бібліотеки до них не потрапляють.
Скрипти в composer.json - іменовані команди, які запускають через composer run (або коротко composer <назва>), і хуки на події Composer.
"scripts": {
"test": "pest --parallel",
"lint": ["pint --test", "phpstan analyse"],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently \"php artisan serve\" \"php artisan queue:listen\" \"npm run dev\""
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
]
}
composer test
composer lint
composer dev
Навіщо:
- Єдині команди для команди й CI. Не треба пам'ятати прапорці кожного інструмента -
composer testоднаковий локально й у пайплайні. - Хуки на події:
post-install-cmd,post-update-cmd,post-autoload-dump,post-create-project-cmd. Laravel черезpost-autoload-dumpвиявляє пакети (package:discover), а черезpost-create-project-cmd- генеруєAPP_KEY.
Що варто знати:
@phpу скрипті використовує той самий PHP, яким запущено Composer;@composer- той самий Composer;@other-script- викликати інший скрипт.- Виконувані файли з
vendor/binдоступні в скриптах без повного шляху. - За замовчуванням скрипт обмежений 300 секундами; для довгих (dev-сервер) -
Composer\Config::disableProcessTimeout. composer install --no-scriptsвимикає хуки - корисно в Docker-збиранні, поки код застосунку ще не скопійовано.
Безпека: скрипти пакетів-залежностей Composer не виконує - лише скрипти кореневого composer.json. Плагіни ж пакетів потребують явного дозволу в allow-plugins.
Pure enum - набір іменованих варіантів без значень:
enum Suit
{
case Hearts;
case Spades;
}
Backed enum - кожен варіант має скалярне значення (int або string), яке можна зберегти в базу чи передати в JSON:
enum Status: string
{
case Draft = 'draft';
case Published = 'published';
}
Status::Published->value; // 'published'
Status::from('draft'); // Status::Draft
Status::tryFrom('unknown'); // null
Як обрати: якщо значення має покидати PHP (база, API, кеш, черга) - backed. Pure - для внутрішніх станів, що ніде не зберігаються.
Усі варіанти - cases():
Status::cases(); // [Status::Draft, Status::Published]
array_column(Status::cases(), 'value'); // ['draft', 'published']
array_map(fn (Status $s) => $s->name, Status::cases()); // ['Draft', 'Published']
Це зручно для випадаючих списків, правил валідації (in:draft,published чи Rule::enum(Status::class) у Laravel), документації API.
Що ще варто знати:
nameє в будь-якого варіанта,value- лише в backed.- Варіанти - синглтони:
Status::Draft === Status::Draft, тож порівнюють через===. - Enum не можна створити через
new, успадкувати чи додати йому стан (властивості) - лише методи, константи й реалізацію інтерфейсів. - Значення backed enum мають бути унікальними й одного типу.
Усі чотири конструкції підключають і виконують інший PHP-файл. Різниця - у реакції на відсутній файл і в повторному підключенні:
require- файлу немає: фатальна помилка, виконання зупиняється.include- файлу немає: попередження, виконання продовжується.require_once/include_once- якщо файл уже підключали, повторно не підключають.
require __DIR__ . '/vendor/autoload.php'; // без нього застосунок не має сенсу
include __DIR__ . '/partials/banner.php'; // банер необов'язковий
Коли що:
require- для того, без чого код не може працювати: автозавантажувач, конфігурація.*_once- для файлів з оголошеннями функцій і класів: повторне оголошення - фатальна помилка.include- для справді необов'язкових фрагментів. На практиці рідко: краще явно перевіритиfile_exists.
Що варто знати:
- Підключений файл бачить змінні з області видимості, де стоїть
include, і може повернути значення:$config = require 'config.php';- так влаштовані конфіг-файли Laravel. - Відносні шляхи розв'язуються відносно
include_pathі поточного робочого каталогу, а не файлу, що підключає. Тому завжди__DIR__ . '/...'. - Шлях від користувача в
include- вразливість (Local/Remote File Inclusion):include $_GET['page'] . '.php'.
У сучасному коді ручні require майже зникли: класи завантажує автозавантажувач Composer (PSR-4), а require лишається в точці входу (public/index.php) і для файлів, що повертають масиви.
У PHP функція не бачить змінних ззовні - на відміну від JavaScript, де внутрішня функція бачить змінні зовнішньої.
$rate = 1.2;
function withTax(int $price): float
{
return $price * $rate; // Warning: Undefined variable $rate
}
Як передати значення у функцію:
- Параметром - правильний спосіб:
withTax(100, $rate). global $rate;чи$GLOBALS['rate']- доступ до глобальної змінної. Працює, але робить функцію залежною від прихованого глобального стану: її важко тестувати й переносити.- Замикання з
use- захоплює значення змінної в момент створення:
$withTax = function (int $price) use ($rate): float {
return $price * $rate;
};
- Стрілкова функція захоплює зовнішні змінні автоматично (за значенням):
$withTax = fn (int $price): float => $price * $rate;
Статичні змінні зберігають значення між викликами функції:
function counter(): int
{
static $count = 0;
return ++$count;
}
Корисно для мемоізації, але це теж прихований стан, що живе весь процес.
Блоки не створюють області видимості: змінна, оголошена в if чи foreach, видна після нього до кінця функції - наприклад, $item після foreach ($items as $item) дорівнює останньому елементу.
Методи класу бачать лише свої параметри й $this, тож стан об'єкта - явна альтернатива глобальним змінним.
Класичний PHP працює за моделлю «нічого не спільного» (shared-nothing): кожен запит починає з чистого аркуша.
Життєвий цикл запиту:
- Веб-сервер (Nginx, Caddy) передає запит у PHP-FPM.
- Вільний воркер FPM запускає скрипт-точку входу (
public/index.php). - Підключається автозавантажувач, створюється застосунок, завантажується конфігурація, реєструються сервіс-провайдери - на кожному запиті заново.
- Обробляється запит, формується відповідь.
- Після відповіді уся пам'ять звільняється: змінні, об'єкти, з'єднання (крім постійних), статичні властивості.
Що з цього випливає:
- Витоки пам'яті майже не шкодять: усе звільниться в кінці запиту.
- Стан між запитами живе лише зовні: в базі, кеші (Redis), сесії, файлах. Статична змінна, змінена в одному запиті, у наступному знову має початкове значення.
- Помилка в одному запиті не впливає на інші - кожен ізольований.
- Ціна - повторна ініціалізація. Завантажити фреймворк з сотнями класів на кожен запит дорого. Частково це компенсують OPcache (готовий байт-код) і кешування конфігурації й маршрутів (
php artisan optimize).
Інша модель - довгоживучі воркери (Laravel Octane на FrankenPHP, RoadRunner чи Swoole): застосунок завантажується один раз і обслуговує тисячі запитів. Це швидше, але стан зберігається між запитами: статичні властивості, синглтони, кеш у пам'яті сервісу. Код, написаний з розрахунку на shared-nothing, може «протікати» даними одного користувача в запит іншого.
Іменовані аргументи (PHP 8.0) дозволяють передавати аргументи за іменем параметра, а не лише за позицією.
// Позиційно: що означає true? а 0?
htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);
// Іменовано
htmlspecialchars($text, double_encode: false);
str_pad(string: 'x', length: 5, pad_type: STR_PAD_LEFT);
Переваги:
- Читабельність для булевих прапорців і «магічних» чисел:
new Invoice(paid: true, sendEmail: false)замістьnew Invoice(true, false). - Пропуск необов'язкових параметрів: передати лише останній, не перелічуючи значення за замовчуванням для всіх попередніх.
- Будь-який порядок: іменовані аргументи можна передавати не в порядку оголошення.
- Разом з constructor promotion дають зручні value objects і DTO:
new Address(city: 'Київ', street: 'Хрещатик').
Правила:
- Спершу позиційні, потім іменовані:
f(1, b: 2)- так;f(a: 1, 2)- ні. - Один параметр не можна передати двічі.
- Невідоме ім'я - помилка.
- Для variadic-параметрів (
...$args) іменовані аргументи збираються в масив з рядковими ключами.
Застереження для авторів бібліотек: з іменованими аргументами назва параметра стає частиною публічного API. Перейменування $str на $string у новій версії зламає код, що викликав функцію іменовано. Тому в бібліотеках назви параметрів змінюють так само обережно, як і назви методів.
First-class callable syntax (PHP 8.1) створює замикання (Closure) з функції чи методу, записавши (...) замість аргументів.
$length = strlen(...);
$length('привіт'); // 12
$format = $this->formatPrice(...); // метод об'єкта
$create = User::create(...); // статичний метод
array_map(trim(...), $lines);
array_filter($users, $this->isActive(...));
Чим краще за старі способи передати функцію:
array_map('trim', $lines); // рядок
array_filter($users, [$this, 'isActive']); // масив
array_filter($users, fn ($u) => $this->isActive($u)); // обгортка
- Перевіряється одразу: якщо методу не існує чи він недоступний, помилка виникне в момент створення, а не десь пізніше при виклику.
- Рефакторинг і IDE: «перейти до визначення» і перейменування методу працюють - на відміну від імені в рядку
'isActive'. - Видимість:
$this->privateMethod(...)усередині класу створює замикання, яке можна передати назовні й викликати, - а[$this, 'privateMethod']поза класом не спрацює. - Статичний аналіз бачить сигнатуру й перевіряє типи.
Обмеження: не працює з nullsafe ($obj?->method(...) - помилка) і з new Foo(...).
PHP 8.5 доповнив це оператором конвеєра |>, який передає значення в callable праворуч: $text |> trim(...) |> strtoupper(...).
PHP 8.0 (листопад 2020) - один з найбільших релізів мови. Головне:
Мова:
- Іменовані аргументи -
f(limit: 10). - Constructor property promotion - властивості в параметрах конструктора.
- Union-типи -
int|string, плюс типmixedі тип поверненняstatic. match- вираз зі строгим порівнянням і без провалювання.- Nullsafe-оператор
?->. - Атрибути -
#[Route('/users')]замість анотацій у PHPDoc. throwяк вираз -$user ?? throw new NotFound().catchбез змінної -catch (TimeoutException).
Суворіша поведінка:
- Порівняння числа з рядком:
0 == 'abc'теперfalse. - Помилки замість попереджень: багато внутрішніх функцій кидають
TypeErrorчиValueErrorпри неправильних аргументах, а арифметика з нечисловим рядком -TypeError. - Узгоджені сигнатури вбудованих функцій і перевірка сумісності методів при успадкуванні.
Нові функції: str_contains(), str_starts_with(), str_ends_with(), get_debug_type(), fdiv().
Продуктивність: JIT-компілятор (для обчислювальних задач; типовому веб-застосунку дає небагато) і загальні оптимізації рушія.
Чому це важливо на співбесіді: більшість сучасного PHP-коду спирається саме на ці можливості, а суворіші правила PHP 8 - часта причина проблем при оновленні старих застосунків з PHP 7.
Обидві створюють об'єкт Closure, але по-різному працюють із зовнішніми змінними.
Анонімна функція (function () use (...)) бачить лише ті зовнішні змінні, які явно перелічені в use, і захоплює їх за значенням у момент створення:
$rate = 1.2;
$withTax = function (int $price) use ($rate): float {
return $price * $rate;
};
Щоб змінювати зовнішню змінну, - захоплення за посиланням: use (&$total).
Стрілкова функція (fn, PHP 7.4) автоматично захоплює всі зовнішні змінні, які використовує, за значенням:
$withTax = fn (int $price): float => $price * $rate;
Відмінності:
- Лише один вираз. Тіло стрілкової функції - один вираз, результат якого повертається. Кілька інструкцій - тільки анонімна функція.
- Не можна змінити зовнішню змінну.
fn () => $count++змінить копію, а не$countззовні. Для накопичення - анонімна функція зuse (&$count). useне потрібен - менше шуму в коді.
Де що використовують:
fn- короткі колбеки:array_map(fn ($u) => $u->email, $users),->filter(fn ($o) => $o->isPaid()),->when($search, fn ($q) => $q->where(...)).function- колбеки з кількома кроками, розгалуженнями чи зміною зовнішнього стану.
Обидві автоматично прив'язують $this усередині класу. Щоб уникнути цього (і зайвого утримання об'єкта в пам'яті), - static fn чи static function.
Питання з реальних технічних співбесід - 100 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- ООП 12 Масиви й рядки 12 Типи й помилки 12 Пам'ять і продуктивність 12 Composer і PSR 12 Основи мови 12 Функції й замикання 10 PHP 8+ 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії