PHP: питання на співбесіді рівня Junior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
32 питань
Backed enum - перелік зі скалярним значенням, прив'язаним до кожного кейса:
enum Status: string
{
case Draft = 'draft';
case Published = 'published';
}
Інтеграція з Laravel:
// каст у моделі - атрибут стає об'єктом enum
protected $casts = ['status' => Status::class];
// валідація
$request->validate(['status' => [Rule::enum(Status::class)]]);
// Route Model Binding теж резолвить enum з URL
Enum робить «магічні рядки» типобезпечними, а методи на enum (label(), color()) зручно інкапсулюють логіку відображення.
PHP дозволяє оголошувати типи аргументів, повернення та властивостей - і перевіряє їх у рантаймі.
class VacancyService
{
public function __construct(
private VacancyRepository $repository,
) {
}
public function publish(int $id, ?string $comment = null): Vacancy
{
// ...
}
}
Що дає: помилка ловиться в момент виклику, а не через три шари; редактор підказує методи; статичний аналіз бачить невідповідності до запуску.
declare(strict_types=1) змінює поведінку перевірки. Без нього PHP приводить типи мовчки:
function repeat(int $times): string { /* ... */ }
repeat('5'); // без strict_types: '5' стане 5, викликається нормально
repeat('5'); // зі strict_types: TypeError
Саме мовчазне приведення небезпечне: 'abc' перетвориться на 0, а '5 котів' - на 5, і помилка проявиться далеко від місця, де виникла.
Важлива деталь: директива діє на файл, де вона написана, і стосується викликів з нього, а не в нього. Тому її ставлять у кожен файл, першим рядком після <?php.
Корисні типи PHP 8:
?string- рядок абоnull.int|string- обʼєднання типів.never- функція не повертає керування (кидає виняток чи завершує процес).
У Laravel-проєктах declare(strict_types=1) зазвичай вимагається стилем коду й перевіряється Pint.
Інтерфейс - це лише контракт: перелік публічних методів, які клас зобов'язується мати. Реалізації в ньому немає (крім констант), і клас може реалізувати скільки завгодно інтерфейсів.
Абстрактний клас - це недобудований клас: у ньому можуть бути властивості, готові методи й абстрактні методи, які мають дописати нащадки. Успадкувати можна лише один клас.
interface PaymentGateway
{
public function charge(Money $amount): Receipt;
}
abstract class HttpGateway implements PaymentGateway
{
public function __construct(protected HttpClient $http) {}
protected function post(string $path, array $data): array
{
return $this->http->post($this->baseUrl() . $path, $data);
}
abstract protected function baseUrl(): string;
}
Коли що:
- Інтерфейс - коли потрібна взаємозамінність: код залежить від
PaymentGateway, а конкретний шлюз підставляється ззовні. - Абстрактний клас - коли кілька реалізацій ділять спільний код і стан.
Часто вони йдуть разом, як у прикладі: залежать від інтерфейсу, а абстрактний клас лише прибирає повтор між реалізаціями.
Це модифікатори видимості - хто може звертатися до властивості чи методу:
public- будь-хто, зокрема код поза класом.protected- сам клас і його нащадки.private- лише сам клас, де член оголошено. Нащадок його не бачить.
class Account
{
public function __construct(private int $balance = 0) {}
public function deposit(int $amount): void
{
$this->guard($amount);
$this->balance += $amount;
}
protected function guard(int $amount): void
{
if ($amount <= 0) {
throw new InvalidArgumentException('Сума має бути додатною');
}
}
}
Тут баланс не можна змінити напряму ззовні - лише через deposit(), який перевіряє суму. У цьому й сенс інкапсуляції: клас сам стежить за тим, щоб його стан лишався коректним.
Правило за замовчуванням: усе private, доки не з'явиться причина відкрити більше. Відкриту властивість потім важко закрити - на неї вже покладається чужий код.
У PHP 8.4 з'явилася асиметрична видимість: public private(set) int $balance - читати можна звідусіль, а змінювати лише всередині класу.
Усі три обходять масив і викликають функцію для кожного елемента, але повертають різне:
array_map- новий масив тієї ж довжини, кожен елемент перетворено.array_filter- лише ті елементи, для яких функція повернулаtrue. Ключі зберігаються.array_reduce- одне значення, накопичене з усіх елементів.
$prices = [100, 250, 40];
array_map(fn (int $p): int => $p * 2, $prices); // [200, 500, 80]
array_filter($prices, fn (int $p): bool => $p > 50); // [0 => 100, 1 => 250]
array_reduce($prices, fn (int $sum, int $p): int => $sum + $p, 0); // 390
Пастки, про які питають:
- Порядок аргументів різний: у
array_mapспершу функція, уarray_filter- масив. Плутають постійно. - Після
array_filterключі «дірчасті» ([0, 2, 5]), іjson_encodeперетворить такий масив на об'єкт. Допомагаєarray_values(). array_filterбез функції прибирає всі «порожні» значення - разом із0і'0', що інколи несподівано.
Жодна з функцій не змінює початковий масив - усі повертають новий.
Рядок у PHP - це послідовність байтів, а не символів. strlen() рахує байти. У UTF-8 кожна кирилична літера займає 2 байти, тож «привіт» - 12 байтів.
strlen('привіт'); // 12
mb_strlen('привіт'); // 6
mb_substr('привіт', 0, 3); // 'при'
substr('привіт', 0, 3); // зламаний символ: 'п' і половина 'р'
Правило: для тексту, де можуть бути не лише латинські літери, використовуйте mb_*-функції: mb_strlen, mb_substr, mb_strtoupper, mb_str_split. Байтові функції зріжуть символ посередині, і в результаті з'явиться «�».
Нюанси:
mb_*рахують кодові точки Unicode. Емодзі з модифікатором шкіри чи прапор складаються з кількох точок, і для «видимих символів» потрібенgrapheme_strlen()з розширення intl.strlen()не помилка - він потрібен, коли важливі саме байти: розмір тіла запиту, обмеження колонки в байтах.- У Laravel
Str::length(),Str::limit()уже працюють черезmb_*.
=== (тотожність) - true, лише якщо однакові і значення, і тип. == (рівність) спершу приводить операнди до спільного типу, а тоді порівнює.
1 === '1'; // false - int і string
1 == '1'; // true
0 == 'abc'; // false у PHP 8, true у PHP 7
null == false; // true
'1e3' == '1000'; // true - обидва числові рядки
Що змінив PHP 8: порівняння числа з нечисловим рядком тепер порівнює їх як рядки. Раніше 0 == 'abc' було true, бо 'abc' перетворювався на 0 - джерело реальних вразливостей у перевірках паролів і токенів.
Правило: за замовчуванням ===. == - лише коли приведення типів свідомо потрібне, і тоді краще привести явно: (int) $request->input('page') === 1.
Ще пастки ==:
in_array($value, $list)іarray_searchза замовчуванням порівнюють через==. Третій аргументtrueвмикає строге порівняння.switchтеж порівнює через==;match- через===.
У PHP дві гілки «кидабельних» об'єктів, і обидві реалізують інтерфейс Throwable:
Exception- очікувані ситуації, які код програми кидає сам і може обробити: немає файлу, сервіс не відповів, дані не пройшли перевірку.Error- помилки в самому коді чи середовищі, які кидає рушій:TypeError,ArgumentCountError,DivisionByZeroError, виклик методу наnull.
try {
$user->profile->avatar(); // $user->profile === null
} catch (Exception $e) {
// сюди не потрапить: це Error
} catch (Error $e) {
// Call to a member function avatar() on null
}
Що з цим робити:
- Ловіть конкретні класи винятків, які очікуєте:
catch (ConnectionException $e). catch (Throwable $e)- лише на самому верху: в обробнику помилок фреймворку, у воркері черги, щоб записати збій і не впасти цілком.Errorзазвичай не «обробляють», а виправляють: це баг, і його треба побачити в логах, а не проковтнути.
До PHP 7 більшість таких помилок були фатальними й не ловилися взагалі - Error з'явився саме для того, щоб їх можна було перехопити.
Генератор - функція, яка віддає значення по одному через yield і «засинає» між ними. Вона повертає об'єкт Generator, який можна обходити foreach, але значення обчислюються лише тоді, коли їх просять.
function readLines(string $path): Generator
{
$handle = fopen($path, 'r');
try {
while (($line = fgets($handle)) !== false) {
yield $line;
}
} finally {
fclose($handle);
}
}
foreach (readLines('access.log') as $line) {
// у пам'яті лише один рядок, хай файл і 10 ГБ
}
Навіщо:
- Пам'ять. Масив з мільйона рядків займає сотні мегабайтів; генератор - стільки, скільки один елемент.
- Ліниве обчислення. Якщо цикл зупиниться на сотому елементі, решта не обчислюватиметься взагалі.
Обмеження: генератор можна пройти лише один раз, у нього немає count() і довільного доступу за індексом.
У Laravel на генераторах побудовані LazyCollection і cursor() / lazy() в Eloquent.
Перед виконанням PHP компілює кожен файл у байт-код (опкоди). Без кешу це відбувається на кожному запиті для кожного підключеного файлу - а в Laravel-застосунку їх сотні.
OPcache зберігає скомпільований байт-код у спільній пам'яті. Наступні запити беруть готовий код, не читаючи й не розбираючи файли знову. Це дає кратне прискорення без жодних змін у коді.
Що він НЕ кешує: дані, результати запитів до БД, HTML. Для цього - кеш застосунку (Redis тощо).
Налаштування на проді, на які дивляться:
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
validate_timestamps=0 вимикає перевірку, чи змінився файл, - ще трохи швидше, але тоді після деплою потрібно скинути кеш (перезапустити PHP-FPM чи викликати opcache_reset()), інакше працюватиме старий код.
Типова помилка: max_accelerated_files менший за кількість файлів у vendor/. Кеш переповнюється, і частина файлів компілюється щоразу.
composer installставить рівно ті версії, що записані вcomposer.lock. Нічого не оновлює.composer updateшукає найновіші версії, дозволені обмеженнями зcomposer.json, ставить їх і переписуєcomposer.lock.
Звідси правило:
- На проді, в CI і після
git pull- лишеinstall. Так у всіх однакові версії, і те, що перевірили тести, саме те, що поїде на сервер. update- свідома дія розробника, щоб оновити залежності. Після неї ганяють тести і комітять новийcomposer.lock.
composer install --no-dev --optimize-autoloader # прод
composer update laravel/framework --with-dependencies # оновити один пакет
Пастка: composer update без аргументів оновлює все одразу. Якщо після нього щось зламалося, важко зрозуміти, який пакет винен. Краще оновлювати пакети поштучно чи невеликими групами.
Якщо composer.lock немає, install поводиться як update і створює його.
composer.json описує дозволені версії (^11.0 - будь-яка 11.x). composer.lock фіксує точні версії всіх пакетів, разом із залежностями залежностей, і хеші їхнього вмісту.
Для застосунку - комітити обов'язково. Інакше кожен composer install у колеги, в CI чи на сервері поставить «найновіше, що підходить», і версії поступово розійдуться. Баг «у мене працює» часто саме звідси.
Для бібліотеки (пакета, який ставлять інші) lock-файл зазвичай не комітять або він ні на що не впливає: застосунок, який ставить бібліотеку, однаково розв'язує версії сам, за своїм lock-файлом.
Що ще дає lock-файл:
composer installшвидший, бо не треба розв'язувати залежності.composer auditперевіряє на відомі вразливості саме встановлені версії.- Diff
composer.lockу pull request показує, що реально оновилося.
Конфлікти в lock-файлі при злитті гілок не правлять руками: беруть версію однієї гілки й заново виконують ту composer require чи update, яка була в іншій.
Докладніше в документації: composer.lock у системі контролю версій
Просування властивостей (PHP 8.0) дозволяє оголосити властивість і присвоїти їй значення прямо в параметрах конструктора - додаючи модифікатор видимості.
// До PHP 8
class Invoice
{
private string $number;
private int $total;
public function __construct(string $number, int $total)
{
$this->number = $number;
$this->total = $total;
}
}
// PHP 8+
class Invoice
{
public function __construct(
private string $number,
private int $total,
) {}
}
Обидва варіанти створюють однакові властивості. Друге - втричі коротше, і неможливо забути присвоєння.
Що варто знати:
- Просувається лише параметр з модифікатором (
public,protected,private,readonly). Звичайні параметри поруч лишаються просто параметрами. - Можна поєднувати з
readonly:public function __construct(public readonly string $email) {}- незмінний value object у кілька рядків. - Тип обов'язковий не формально, але без нього властивість буде нетипізованою - на практиці тип пишуть завжди.
- Значення за замовчуванням дозволені:
private int $attempts = 3. - Тіло конструктора може бути й не порожнім - там зручно перевіряти інваріанти:
public function __construct(public readonly int $amount)
{
if ($amount < 0) {
throw new InvalidArgumentException('Сума не може бути від\'ємною');
}
}
У Laravel так оголошують залежності сервісів і контролерів: контейнер підставляє їх через параметри конструктора.
Звичайний метод працює з конкретним об'єктом і має доступ до $this - його стану.
Статичний метод належить класу, а не об'єкту: викликається без створення екземпляра (Money::fromCents(100)), і $this у ньому немає.
final class Money
{
private function __construct(private int $cents) {}
public static function fromCents(int $cents): self // статичний: створює об'єкт
{
return new self($cents);
}
public function add(Money $other): self // звичайний: працює зі станом
{
return new self($this->cents + $other->cents);
}
}
Коли static доречний:
- Іменовані конструктори й фабричні методи:
Money::fromCents(),Carbon::parse(). - Чисті допоміжні функції без стану, логічно прив'язані до класу:
Str::slug(). - Константи й кеш на рівні класу - обережно (див. нижче).
Чому зі статикою обережні:
- Важко підмінити в тестах. Код, що викликає
PaymentGateway::charge(), жорстко прив'язаний до класу; залежність, передана в конструктор, легко замінюється на фейк. - Статичні властивості - глобальний стан. Вони живуть увесь процес: у довгоживучих воркерах і Octane значення «перетікає» між запитами.
- Приховані залежності: зі сигнатури класу не видно, що він користується статикою іншого класу.
Фасади Laravel (Cache::get()) лише виглядають статичними: насправді це виклик методу об'єкта з контейнера, і в тестах його можна підмінити (Cache::fake(), Cache::shouldReceive()).
Усі три перевіряють «чи є значення», але з різною семантикою:
isset($a['key'])- ключ існує і значення неnull.array_key_exists('key', $a)- ключ існує, хай навіть зі значеннямnull.empty($a['key'])- ключа немає або значення «порожнє»:null,false,0,0.0,'','0',[].
$data = ['name' => '', 'phone' => null, 'age' => 0];
isset($data['phone']); // false - значення null
array_key_exists('phone', $data); // true - ключ є
empty($data['age']); // true - 0 вважається порожнім
empty($data['name']); // true
isset($data['missing']); // false, без попередження
Де це важливо:
- PATCH-запит до API:
{"phone": null}означає «очистити телефон», а відсутній ключ - «не чіпати». Розрізнити допоможе лишеarray_key_exists(у Laravel -$request->has('phone')проти$request->filled('phone')). empty('0')-true. Рядок «0» у формі (кількість, рейтинг) стане «порожнім». Для чисел краще явні перевірки.- Вкладені ключі:
isset($a['user']['address']['city'])безпечно перевіряє весь ланцюжок без попереджень.
isset і empty - мовні конструкції, а не функції: не кидають попереджень на неіснуючих змінних і ключах. Через це їх легко використати для приховування опечаток - isset($usr) мовчки поверне false.
Для об'єктів: isset($obj->prop) для неініціалізованої типізованої властивості - false; property_exists() перевіряє, чи оголошена властивість у класі.
Питання рівня Junior з реальних технічних співбесід - 32 питання у 9 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
- Теми
- ООП 4 Масиви й рядки 4 Типи й помилки 4 Пам'ять і продуктивність 4 Composer і PSR 4 Основи мови 4 Функції й замикання 3 PHP 8+ 3
Готуєтесь до співбесіди не просто так: зараз на сайті 7 відкритих вакансій рівня Junior. Переглянути вакансії