Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Основи мови

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

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.

Докладніше в документації: Стандарти коду

Усі чотири конструкції підключають і виконують інший 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) і для файлів, що повертають масиви.

Докладніше в документації: require

У 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): кожен запит починає з чистого аркуша.

Життєвий цикл запиту:

  1. Веб-сервер (Nginx, Caddy) передає запит у PHP-FPM.
  2. Вільний воркер FPM запускає скрипт-точку входу (public/index.php).
  3. Підключається автозавантажувач, створюється застосунок, завантажується конфігурація, реєструються сервіс-провайдери - на кожному запиті заново.
  4. Обробляється запит, формується відповідь.
  5. Після відповіді уся пам'ять звільняється: змінні, об'єкти, з'єднання (крім постійних), статичні властивості.

Що з цього випливає:

  • Витоки пам'яті майже не шкодять: усе звільниться в кінці запиту.
  • Стан між запитами живе лише зовні: в базі, кеші (Redis), сесії, файлах. Статична змінна, змінена в одному запиті, у наступному знову має початкове значення.
  • Помилка в одному запиті не впливає на інші - кожен ізольований.
  • Ціна - повторна ініціалізація. Завантажити фреймворк з сотнями класів на кожен запит дорого. Частково це компенсують OPcache (готовий байт-код) і кешування конфігурації й маршрутів (php artisan optimize).

Інша модель - довгоживучі воркери (Laravel Octane на FrankenPHP, RoadRunner чи Swoole): застосунок завантажується один раз і обслуговує тисячі запитів. Це швидше, але стан зберігається між запитами: статичні властивості, синглтони, кеш у пам'яті сервісу. Код, написаний з розрахунку на shared-nothing, може «протікати» даними одного користувача в запит іншого.

Докладніше в документації: FastCGI Process Manager

Два способи оголосити глобальну константу:

const MAX_UPLOAD_MB = 20;              // на етапі компіляції
define('MAX_UPLOAD_MB', 20);           // під час виконання, звичайна функція
const define()
коли визначається під час компіляції файлу під час виконання
усередині if, циклу, функції ні так
ім'я з виразу ні так: define("LIMIT_{$plan}", 10)
у класах і інтерфейсах так ні
простір імен враховує поточний namespace ім'я задається повністю рядком

На практиці в сучасному коді define() майже не потрібен: глобальні константи взагалі краще замінити конфігурацією чи константами класу.

Константи класу - значення, що належать класу й не змінюються:

final class Invoice
{
    public const int PAYMENT_TERM_DAYS = 14;
    protected const string NUMBER_PREFIX = 'INV-';

    public function dueDate(): CarbonImmutable
    {
        return $this->issued_at->addDays(self::PAYMENT_TERM_DAYS);
    }
}
  • видимість (public, protected, private) з PHP 7.1;
  • типізовані константи (const int) з PHP 8.3 - нащадок не може перевизначити константу значенням іншого типу;
  • final const (PHP 8.1) - заборонити перевизначення в нащадках;
  • static::CONST замість self::CONST - взяти значення з класу-нащадка (пізнє статичне зв'язування).

Магічні значення - числа й рядки без пояснення посеред коду:

if ($user->status === 3 && $order->total > 50000) { ... }   // що таке 3? чому 50000?

Замінювати їх варто так:

  • набір пов'язаних варіантів (статуси, типи, ролі) - enum, а не група констант: тип перевіряється, варіанти можна перебрати, у них можуть бути методи;
  • одиночне незмінне значення предметної області (термін оплати, максимальна довжина) - константа класу, де воно використовується;
  • значення, що відрізняється між середовищами чи може змінитися без деплою (ліміти, ключі, адреси) - конфігурація (config('billing.limit')), а не константа.

Константи в інтерфейсах допустимі, але вони потрапляють у кожен клас, що реалізує інтерфейс. Інтерфейс описує поведінку, тож набір значень для нього - ознака, що потрібен enum.

Динамічний доступ: constant(Invoice::class . '::PAYMENT_TERM_DAYS') чи з PHP 8.3 Invoice::{$name} - корисно в рідкісних випадках, але ламає пошук використань в IDE.

Докладніше в документації: Константи класу

Простір імен - префікс до назв класів, функцій і констант, щоб різні бібліотеки могли мати класи з однаковими іменами: 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 використовує підрахунок посилань + збирач циклічних посилань. У звичайному веб-запиті пам'ять звільняється наприкінці запиту, тож витоки малопомітні. Але у довготривалих процесах (черги, Octane) пам'ять накопичується.

Як уникати:

  • Не зберігати стан у статичних властивостях/синглтонах між завданнями.
  • unset() великих структур, скидати накопичувачі (логи запитів DB::flushQueryLog()).
  • Обробляти дані порціями (chunk, lazy), не тримати все в пам'яті.
  • Перезапускати воркери за лімітом: queue:work --max-jobs=1000 --max-time=3600 або при досягненні --memory.

Octane має gc_collect_cycles()-хуки; Horizon автоматично перезапускає воркери, що «розпухли».

Налаштування 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() і дати моделей стають незмінними.

Докладніше в документації: DateTimeImmutable

Не всі генератори випадкових чисел однакові. Для безпеки потрібен криптографічно стійкий генератор (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', ...), бо токен і так довгий і випадковий).

Докладніше в документації: Розширення Random