Питання на співбесіді з PHP
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Статус замовлення, заявки чи статті рідко може змінитися на будь-який інший: оплачене не стає новим, відправлене не скасовується. Ці правила - машина станів, і enum - зручне місце, щоб описати її в одному місці.
enum OrderStatus: string
{
case New = 'new';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
/** @return list<self> */
public function allowedTransitions(): array
{
return match ($this) {
self::New => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped, self::Cancelled => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions(), true);
}
}
Застосування в моделі:
public function transitionTo(OrderStatus $next): void
{
if (! $this->status->canTransitionTo($next)) {
throw new InvalidStateTransition($this->status, $next);
}
$this->update(['status' => $next]);
event(new OrderStatusChanged($this, $next));
}
Що це дає:
- Правила переходів - в одному місці, а не розкидані
if-ами по контролерах. matchбезdefaultзмушує описати переходи для кожного нового статусу.- Легко тестувати: таблиця «з якого - в який - дозволено».
- UI може показувати лише доступні дії:
$order->status->allowedTransitions().
Коли enum уже замало: переходи залежать від даних (оплатити можна, лише якщо сума збігається), потрібні дії при вході й виході зі стану, історія переходів, паралельні стани. Тоді - окремі класи станів (патерн State) чи бібліотеки на кшталт spatie/laravel-model-states, а для довгих бізнес-процесів - workflow-рушії.
Конкурентність: перевірка й оновлення мають бути атомарними - UPDATE ... SET status = 'paid' WHERE id = ? AND status = 'new' чи блокування рядка, інакше два паралельні запити обидва «побачать» статус New.
Налаштування PHP задаються на кількох рівнях, і не кожне можна змінити будь-де. Для кожної директиви в документації вказано режим:
INI_SYSTEM- лише вphp.iniчи конфігурації сервера:opcache.memory_consumption,opcache.preload,disable_functions.INI_PERDIR- ще й у.user.ini(для FPM/CGI) чи.htaccess(Apache з mod_php):upload_max_filesize,post_max_size.INI_USER- ще й у коді черезini_set().INI_ALL- усюди:memory_limit,max_execution_time,display_errors,date.timezone.
ini_set('memory_limit', '512M'); // працює
ini_set('upload_max_filesize', '50M'); // не подіє: тіло запиту вже розібране
Звідки PHP бере налаштування:
php --ini # які файли завантажено
php -i | grep memory # поточні значення (CLI!)
CLI і FPM часто мають різні php.ini (/etc/php/8.4/cli/php.ini і /etc/php/8.4/fpm/php.ini). Класична пастка: в консолі memory_limit=-1, а веб-запити падають на 128 МБ. Значення для FPM дивляться через phpinfo() у веб-запиті чи статусну сторінку.
Пули PHP-FPM можуть перевизначати налаштування: php_admin_value[memory_limit] = 256M (не можна змінити з коду) і php_value[...] (можна).
У Docker налаштування кладуть окремим файлом у /usr/local/etc/php/conf.d/ - він перевизначає дефолти без редагування основного php.ini.
Рекомендації для проду: display_errors=Off, log_errors=On, expose_php=Off, налаштований date.timezone, OPcache з validate_timestamps=0 і скиданням при деплої. Зміни в php.ini вимагають перезапуску FPM.
DateTime змінюється на місці: modify(), add(), setTime() змінюють сам об'єкт і повертають його ж.
DateTimeImmutable ніколи не змінюється: ті самі методи повертають новий об'єкт, а оригінал лишається без змін.
Класичний баг з DateTime:
$start = new DateTime('2026-10-01');
$end = $start->modify('+7 days');
echo $start->format('Y-m-d'); // 2026-10-08 - початок теж зсунувся!
$start і $end - той самий об'єкт. Особливо підступно, коли дату передали у функцію чи вона - властивість моделі: виклик modify() усередині методу непомітно змінює стан десь в іншому місці.
З DateTimeImmutable:
$start = new DateTimeImmutable('2026-10-01');
$end = $start->modify('+7 days'); // $start лишився 2026-10-01
Що ще варто знати про дати в PHP:
- Часовий пояс - явно:
new DateTimeImmutable('now', new DateTimeZone('Europe/Kyiv')). Без нього беретьсяdate.timezone, яке на різних серверах різне. - Зберігати в UTC, показувати в поясі користувача -
->setTimezone(...)при виведенні. modify('+1 month')31 січня дає 3 березня (переповнення дня). Для «наступного місяця» -modify('last day of next month')чиfirst day of next month.- Порівняння - звичайними операторами:
$a < $bпрацює для дат. - Для інтервалів -
diff()повертаєDateInterval, для ітерації -DatePeriod.
У Laravel Carbon (на основі DateTime) змінюваний, а CarbonImmutable - ні. Laravel дозволяє зробити immutable за замовчуванням: Date::use(CarbonImmutable::class) у сервіс-провайдері - тоді now() і дати моделей стають незмінними.
Не всі генератори випадкових чисел однакові. Для безпеки потрібен криптографічно стійкий генератор (CSPRNG), результат якого неможливо передбачити.
Для токенів, паролів, кодів підтвердження - лише CSPRNG:
random_int(100000, 999999); // 6-значний код підтвердження
bin2hex(random_bytes(32)); // 64-символьний токен
Str::random(40); // у Laravel - теж на random_bytes
Чого не використовувати для безпеки:
rand(),mt_rand()- Mersenne Twister: швидкий, але передбачуваний. Знаючи кілька результатів, можна відновити внутрішній стан і передбачити наступні.uniqid()- це час з мікросекундами, а не випадковість.md5(time()),sha1(microtime())- хеш передбачуваного значення лишається передбачуваним.shuffle(),array_rand(),str_shuffle()- використовують нестійкий генератор.
Розширення Random (PHP 8.2) - об'єктний API з явним вибором рушія:
use Random\Randomizer;
use Random\Engine\Secure;
use Random\Engine\Mt19937;
$secure = new Randomizer(new Secure()); // CSPRNG за замовчуванням
$secure->getBytesFromString('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 8); // код без схожих символів
$secure->shuffleArray($items); // безпечне перемішування
$seeded = new Randomizer(new Mt19937(42)); // відтворюваний: та сама послідовність з тим самим seed
$seeded->getInt(1, 100);
Відтворювана (seeded) випадковість корисна поза безпекою: тести, генерація даних, детерміновані вибірки («та сама випадкова добірка для того самого користувача сьогодні»).
Порівняння токенів - через hash_equals(), а зберігати токени доступу в базі краще хешованими (як паролі, але достатньо hash('sha256', ...), бо токен і так довгий і випадковий).
Лінивий об'єкт створюється одразу, але його справжня ініціалізація (дорогий конструктор, запит до бази, з'єднання) відкладається до моменту, коли об'єкт справді знадобиться. PHP 8.4 зробив це вбудованою можливістю мови через Reflection.
Два види:
- Ghost - сам об'єкт потрібного класу, який ініціалізується «на місці» при першому зверненні до його стану:
$reflector = new ReflectionClass(Connection::class);
$connection = $reflector->newLazyGhost(function (Connection $object): void {
$object->__construct(config('database.dsn')); // підключення лише тепер
});
// ... з'єднання ще не встановлено
$connection->query('SELECT 1'); // перше звернення до властивостей - ініціалізація
- Proxy - об'єкт-замісник, який при першому зверненні створює справжній екземпляр через фабрику й далі перенаправляє йому всі звернення:
$service = $reflector->newLazyProxy(fn () => $container->make(HeavyService::class));
Важливий нюанс: ініціалізацію запускає звернення до властивостей об'єкта. Виклик методу, який не чіпає стан, лінивий об'єкт не ініціалізує.
Де це корисно:
- Контейнери залежностей: сервіс, впроваджений у контролер, але не використаний у цьому запиті, не створюється взагалі.
- ORM: зв'язана сутність завантажується з бази лише при зверненні до неї (так Doctrine давно робила через згенеровані класи-проксі - тепер це вміє мова).
- Важкі клієнти: з'єднання з Redis, S3, сторонніми API, яке потрібне лише в частині запитів.
Чим краще за рукописні проксі: не треба генерувати код класів-проксі, об'єкт лишається того самого класу (працюють instanceof і типи), а final-класи теж можна зробити лінивими.
Обмеження: ініціалізація може відбутися в несподіваний момент (наприклад, при var_dump чи серіалізації), а помилка в ініціалізаторі виникне там, де об'єкт уперше використали, а не там, де його створили.
Оновлення PHP - це не зміна однієї цифри в Dockerfile. Ламаються зазвичай не нові можливості, а зміни поведінки й залежності.
1. Прочитати посібник з міграції на php.net для кожної версії між поточною й цільовою: несумісні зміни, застаріле, змінені функції.
2. Перевірити залежності:
composer why-not php 8.5
composer outdated --direct
Пакет, що не підтримує нову версію PHP, - найчастіший блокер. Оновити, знайти заміну чи дочекатися релізу.
3. Автоматичні інструменти:
- Rector - автоматично переписує код під нову версію (набори правил
UpgradeToPhp84тощо) і прибирає застарілі конструкції. - PHPStan/Psalm з налаштованою цільовою версією PHP.
- PHPCompatibility для PHP_CodeSniffer - знаходить несумісний код.
4. Тести на обох версіях. Матриця в CI: поточна й нова версія PHP. Поки обидві зелені, можна оновлюватися поступово.
5. Застарілі попередження - як помилки в тестах. Deprecation сьогодні - помилка в наступній мажорній версії. PHPUnit/Pest можна налаштувати так, щоб E_DEPRECATED валили тест.
6. Поступове розгортання: спершу стейджинг, потім частина продакшн-трафіку (канарка), моніторинг помилок і швидкодії, і лише потім усе.
Типові проблеми при оновленні:
- PHP 8.0: порівняння чисел з рядками,
TypeErrorзамість попереджень у вбудованих функціях. - PHP 8.1: передача
nullу параметри вбудованих функцій, що не приймають null (deprecated). - PHP 8.2: динамічні властивості й інтерполяція
${var}- deprecated. - PHP 8.4: неявно nullable параметри - deprecated.
Не відкладати надовго: кожна версія PHP отримує виправлення безпеки обмежений час. Оновлюватися щороку на одну версію значно легше, ніж стрибати через три.
Застарілі (deprecated) можливості ще працюють, але видають попередження й будуть прибрані в наступній мажорній версії. Найпоширеніші в реальному коді:
Динамічні властивості (PHP 8.2):
$user = new User();
$user->temporaryFlag = true; // Deprecated: Creation of dynamic property
Оголосіть властивість явно. Для класів, яким це справді потрібно, - #[\AllowDynamicProperties]. stdClass і класи з __get/__set не зачіпаються.
Інтерполяція ${var} у рядках (8.2):
"Привіт, ${name}"; // застаріло
"Привіт, {$name}"; // правильно
Неявно nullable параметри (8.4):
function find(Criteria $criteria = null) {} // застаріло
function find(?Criteria $criteria = null) {} // правильно
null у параметрах вбудованих функцій, що не приймають null (8.1):
strlen(null); // Deprecated
trim($request->input('name')); // якщо поле відсутнє - null
trim($request->input('name') ?? '');
Інше:
utf8_encode()/utf8_decode()(8.2) →mb_convert_encoding().- Часткова підтримка callable-рядків на кшталт
"self::method"(8.2). - Неявне приведення
floatз дробовою частиною доintу ключах і операціях (8.1). E_STRICTяк константа (8.4).
Як знайти все одразу:
error_reporting(E_ALL)у тестах і перетворення deprecation на помилки тестів.- Laravel пише застарілі в окремий канал логів (
LOG_DEPRECATIONS_CHANNEL) - увімкнути його на стейджингу й подивитися, що сиплеться під реальним трафіком. - Rector автоматично виправляє більшість із цього списку.
Чим раніше прибрати застаріле, тим дешевше наступне оновлення PHP.
Чиста функція:
- для тих самих аргументів завжди повертає той самий результат;
- не має побічних ефектів - не змінює нічого за своїми межами й не залежить від прихованого стану.
Побічні ефекти: запис у базу чи файл, HTTP-запит, надсилання листа, зміна глобальних чи статичних змінних, зміна переданих за посиланням аргументів, читання поточного часу, випадкових чисел, $_GET, конфігурації.
// Нечиста: залежить від часу й бази
function isExpired(int $subscriptionId): bool
{
$sub = Subscription::find($subscriptionId);
return $sub->ends_at < now();
}
// Чиста: усе потрібне - в аргументах
function isExpired(DateTimeImmutable $endsAt, DateTimeImmutable $now): bool
{
return $endsAt < $now;
}
Чому це важливо для тестів:
- Чисту функцію тестують одним рядком: дав вхід - перевірив вихід. Без бази, моків, фейкового часу.
- Тести швидкі й стабільні: немає залежності від порядку, часу запуску, стану бази.
- Легко перевірити граничні випадки - просто передати інші аргументи.
Як застосувати на практиці - «функціональне ядро, імперативна оболонка»:
- Ядро - чисті обчислення бізнес-правил: розрахунок ціни зі знижками, перевірка правил, переходи станів, форматування.
- Оболонка - тонкий шар, що збирає дані (база, запит, час), викликає ядро й виконує побічні ефекти з результатом (зберегти, надіслати).
Більшість логіки опиняється в легко тестованому ядрі, а оболонку покривають кількома інтеграційними тестами.
Не все має бути чистим: застосунок існує заради побічних ефектів. Мета - не прибрати їх, а відокремити й зібрати на краях, щоб логіка між ними лишалася простою для перевірки.
yield from делегує частину генерації іншому генератору чи ітерованому значенню: усі його значення віддаються так, ніби їх видав поточний генератор.
function walk(array $node): Generator
{
yield $node['path'];
foreach ($node['children'] as $child) {
yield from walk($child); // рекурсивний обхід дерева
}
}
foreach (walk($tree) as $path) {
echo $path, PHP_EOL;
}
Працює з генераторами, масивами й будь-яким Traversable.
Повернення значення з генератора: return у генераторі не віддає значення в цикл, а зберігає його як результат - його читають через getReturn() після завершення:
function importRows(iterable $rows): Generator
{
$imported = 0;
foreach ($rows as $row) {
yield $row['id']; // проміжні результати
$imported++;
}
return $imported; // підсумок
}
$gen = importRows($rows);
foreach ($gen as $id) { /* ... */ }
echo $gen->getReturn(); // кількість
А yield from повертає результат делегованого генератора:
$count = yield from importRows($rows);
Пастки:
- Ключі.
yield fromзберігає ключі делегованого джерела. Дваyield fromпо масивах з ключами0, 1, 2дадуть дублікати ключів, іiterator_to_array()перезапише значення. Рішення -iterator_to_array($gen, preserve_keys: false). getReturn()до завершення генератора кидає виняток.- Генератор одноразовий: пройти його вдруге не можна.
Двосторонній обмін: $received = yield $value; разом із $gen->send($data) дозволяє передавати дані в генератор - на цьому будували співпрограми до появи Fibers.
extract($array) створює змінні в поточній області видимості з ключів масиву:
extract(['name' => 'Оля', 'role' => 'admin']);
echo $name; // 'Оля'
compact('name', 'role') - зворотне: збирає змінні в масив за іменами.
Чому extract небезпечний:
- Перезапис змінних. За замовчуванням
extractперезаписує наявні змінні. Якщо масив прийшов від користувача, нападник може підмінити будь-яку змінну функції:
$isAdmin = false;
extract($_POST); // POST isAdmin=1 → $isAdmin = '1'
if ($isAdmin) { /* ... */ }
Історично це була реальна вразливість у CMS і плагінах.
- Невидимі змінні. Читач коду не бачить, звідки взялася
$role- її оголошення ніде немає. IDE й статичний аналіз теж не бачать. - Опечатки не ловляться: відсутній ключ просто не створить змінну, і код отримає «Undefined variable» далі.
Якщо extract таки потрібен: ніколи з даними від користувача і з прапорцем, що забороняє перезапис - extract($data, EXTR_SKIP).
compact безпечніший, але має свої мінуси:
return view('orders.show', compact('order', 'items', 'total'));
- Імена змінних - рядки: перейменування змінної в IDE не оновить рядок у
compact, і ключ зникне. - Відсутня змінна - лише попередження.
Явний масив читабельніший і надійніший при рефакторингу:
return view('orders.show', ['order' => $order, 'items' => $items, 'total' => $total]);
Де ці функції живуть легітимно: шаблонізатори (Blade під капотом робить extract даних подання в області видимості шаблону) - там джерело даних контрольоване, і це їхня пряма задача.
Питання з реальних технічних співбесід - 100 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- ООП 12 Масиви й рядки 12 Типи й помилки 12 Пам'ять і продуктивність 12 Composer і PSR 12 Основи мови 12 Функції й замикання 10 PHP 8+ 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії