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

Питання на співбесіді з Laravel

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

378 питань

Через defer() - замикання виконається вже після того, як відповідь пішла користувачу.

public function store(StoreOrderRequest $request): RedirectResponse
{
    $order = Order::create($request->validated());

    defer(fn () => Metrics::reportOrder($order));

    return redirect()->route('orders.show', $order);
}

Користувач не чекає на звернення до сервісу метрик, а окремий воркер черги не потрібен.

Як це працює: Laravel відправляє відповідь і закриває з'єднання, а процес PHP продовжує виконання. Для команд Artisan і завдань черги відкладені функції виконуються в кінці.

Деталі:

  • якщо відповідь з помилкою (4xx/5xx), відкладена функція за замовчуванням не виконується; ->always() змінює це;
  • defer(fn () => ..., 'name') з іменем дозволяє скасувати: defer()->forget('name');
  • у тестах їх можна виконувати одразу через $this->withoutDefer().

Коли defer(), а коли черга:

  • defer - коротка, некритична робота: метрики, логування, прогрів кешу. Якщо процес упаде, вона просто не виконається, повторів немає;
  • черга - усе, що має гарантовано виконатися, довго триває чи потребує повторів: листи, платежі, обробка файлів.

Пам'ятайте, що поки працює відкладена функція, процес PHP зайнятий: важка робота тут з'їдає воркери, які мали б обслуговувати запити.

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

Через фасад Concurrency - він запускає замикання одночасно й повертає результати в тому самому порядку.

use Illuminate\Support\Facades\Concurrency;

[$userCount, $orderCount, $rates] = Concurrency::run([
    fn () => DB::table('users')->count(),
    fn () => DB::table('orders')->count(),
    fn () => Http::get('https://api.example.com/rates')->json(),
]);

Якщо кожна операція триває секунду, послідовно це три секунди, паралельно - близько однієї.

Як це працює: драйвер за замовчуванням process серіалізує кожне замикання й виконує його в окремому дочірньому процесі PHP через Artisan. Є драйвер fork (швидший, лише CLI, потрібен пакет spatie/fork) і sync для тестів.

Обмеження:

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

Альтернативи:

  • кілька HTTP-запитів - Http::pool(), без нових процесів;
  • робота, результат якої не потрібен відповіді, - Concurrency::defer() або черга.

Найчастіше це доречно для дашбордів з кількома незалежними важкими агрегаціями чи для зведення даних з кількох зовнішніх API.

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

Коротка відповідь: після php artisan config:cache функція env() поза конфігами повертає null.

Чому. Кешування конфігурації зберігає підсумковий масив у один PHP-файл. Файл .env після цього взагалі не читається - у проді його може й не бути. Значення, зчитані під час збирання кешу, всередині конфігів залишаються; будь-який виклик env() в іншому місці виконується вже без завантаженого середовища.

Як ламається:

class WeatherClient
{
    public function __construct()
    {
        // У проді після config:cache тут null.
        $this->key = env('WEATHER_KEY');
    }
}

Найгірше, що локально все працює - кеш конфігурації зазвичай збирають лише в проді.

Правильно: env() живе тільки у файлах config/, а код читає конфіг.

// config/services.php
'weather' => [
    'key' => env('WEATHER_KEY'),
],

// будь-де в застосунку
$this->key = config('services.weather.key');

Побічна вигода: конфіг можна підмінити в тестах, а .env - ні.

config(['services.weather.key' => 'test-key']);

Виняток - файли, що виконуються до завантаження застосунку (bootstrap/), і сам AppServiceProvider тут не виняток: у ньому теж потрібен config().

Докладніше в документації: Конфігурація

php artisan optimize виконує пакет кешувань, які прибирають повторну роботу з кожного запиту:

php artisan config:cache    # усі config/*.php в один масив
php artisan route:cache     # маршрути в серіалізований вигляд
php artisan view:cache      # прекомпіляція Blade
php artisan event:cache     # мапа подій і слухачів

Зворотна дія - php artisan optimize:clear.

Що це дає: застосунок перестає на кожному запиті обходити десятки файлів конфігурації, парсити маршрути й компілювати шаблони. На середньому проєкті це десятки мілісекунд, які видно на кожному запиті.

Коли шкодить:

  • Локально під час розробки. Закешований конфіг не помічає змін у .env, а закешовані маршрути - нових маршрутів. Півгодини пошуку помилки, якої немає.
  • route:cache не працює із замиканнями в маршрутах: команда впаде з помилкою. Маршрути мають вказувати на контролери.
  • env() поза конфігами перестає працювати - див. окреме питання про це.
  • Якщо конфіг залежить від запиту. Кеш збирається один раз під час деплою, тож щось на кшталт мультитенантності за доменом у конфігу зафіксується у стані збирання.

Порядок під час деплою має значення: спершу викласти новий код, потім кешувати. Якщо кешувати до оновлення файлів, у кеш потрапить попередня версія.

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

Перед завантаженням змінних Laravel дивиться, чи задано APP_ENV (змінною оточення процесу) або опцію --env у команді Artisan. Якщо так, і поруч є .env.{APP_ENV}, береться він замість .env.

APP_ENV=staging php artisan migrate          # прочитає .env.staging, якщо він є
php artisan test                             # phpunit.xml ставить APP_ENV=testing -> .env.testing

Типова схема:

  • .env - локальна розробка, у .gitignore;
  • .env.example - шаблон з усіма змінними без секретів, у репозиторії;
  • .env.testing - окрема база й драйвери для тестів (QUEUE_CONNECTION=sync, MAIL_MAILER=array);
  • продакшен - змінні оточення платформи чи секрет-менеджера, а не файл у репозиторії.

Пріоритет: справжні змінні оточення процесу важливіші за значення з .env - так платформа перекриває налаштування без зміни файлів.

Пастка: config:cache, виконаний з одним оточенням, «заморожує» його значення. Закешована конфігурація з продакшену на машині розробника чи навпаки - класична причина «тести пишуть у робочу базу». Тому кеш конфігурації будують лише там, де він буде використаний.

Перевірити, яке оточення активне: app()->environment(), php artisan about.

Докладніше в документації: Додаткові файли оточення

Коли «на сервері працює не так», перше питання - які насправді значення бачить застосунок.

php artisan about                      # версії, оточення, драйвери, стан кешів
php artisan about --only=environment
php artisan config:show database       # увесь файл конфігурації з поточними значеннями
php artisan config:show cache.default

Що показує about:

  • версії Laravel, PHP, Composer;
  • оточення, режим налагодження, URL, мовні налаштування;
  • драйвери кешу, черги, сесії, пошти, бази;
  • чи закешовані конфігурація, маршрути, події, шаблони.

Пакети можуть додавати свої секції через AboutCommand::add().

Типові знахідки:

  • Config: CACHED після зміни .env - нові значення не діють, потрібен config:cache чи config:clear;
  • Debug Mode: ENABLED на продакшені;
  • черга sync замість redis - завдання виконуються в запиті;
  • неправильний APP_URL - посилання в листах ведуть не туди.

Обережно: config:show виводить і секрети (паролі бази, ключі), тож результат не вставляють у чати й тікети без очищення.

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

Згенерувати клас, що реалізує ValidationRule:

php artisan make:rule Uppercase
class Uppercase implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (strtoupper($value) !== $value) {
            $fail('Поле :attribute має бути у верхньому регістрі.');
        }
    }
}

$request->validate(['code' => [new Uppercase]]);

Для разових перевірок можна передати замикання прямо в правило: 'field' => [fn ($attr, $value, $fail) => ...].

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

Через «крапкову» нотацію й зірочку, яка означає кожен елемент масиву.

$request->validate([
    'items'              => ['required', 'array', 'min:1', 'max:50'],
    'items.*.product_id' => ['required', 'integer', 'distinct', 'exists:products,id'],
    'items.*.quantity'   => ['required', 'integer', 'min:1'],
    'tags'               => ['array'],
    'tags.*'             => ['string', 'max:30'],
]);

На що звернути увагу:

  • Перевіряйте сам масив (array, max), а не лише елементи. Без обмеження клієнт надішле 10 000 елементів, і для кожного виконаються запити exists.
  • distinct забороняє повтори - той самий товар двічі в одному замовленні.
  • exists на кожен елемент - окремий запит до бази. Для великих масивів краще перевірити всі ID одним запитом у after().
  • Повідомлення можуть містити позицію елемента: :index (з нуля) чи :position (з одиниці) - «Товар #3: кількість має бути не менше 1».

validated() поверне лише ті ключі елементів, для яких є правила, тож зайві поля всередині елементів масиву теж відкинуться.

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

Є кілька рівнів - від рядкових правил до логіки в коді.

1. Готові умовні правила:

'company_name' => ['required_if:type,business'],
'vat_number'   => ['required_with:company_name'],
'doctor_name'  => ['exclude_if:has_appointment,false', 'required'],
'discount'     => ['prohibited_unless:role,manager'],

exclude_if прибирає поле і з перевірки, і з validated() - зручно, коли воно не має сенсу за іншої відповіді.

2. Умова кодом:

'role_id' => Rule::requiredIf(fn () => $this->user()->isAdmin()),
'coupon'  => Rule::when($this->boolean('has_coupon'), ['required', 'string']),

3. Перевірки на кілька полів одразу - метод after() запиту форми:

public function after(): array
{
    return [function (Validator $validator) {
        if ($this->date('ends_at') <= $this->date('starts_at')) {
            $validator->errors()->add('ends_at', 'Кінець має бути пізніше за початок.');
        }
    }];
}

Для такого простого випадку вистачить і правила after:starts_at; after() потрібен, коли логіка складніша.

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

Підключення оголошуються в config/database.php. Вибір конкретного:

DB::connection('reporting')->table('events')->get();

class AnalyticsEvent extends Model
{
    protected $connection = 'reporting'; // модель завжди на цьому з'єднанні
}

Типові сценарії:

  • Read/Write splitting - окремі хости для читання й запису (Laravel сам маршрутизує SELECT на репліку):
    'mysql' => ['read' => [...], 'write' => [...]],
    
  • Окрема аналітична або legacy-БД.

Докладніше в документації: Кілька підключень до БД

Laravel абстрагує файлові операції через фасад Storage поверх Flysystem. «Диски» (local, public, s3) налаштовуються в config/filesystems.php.

Storage::disk('s3')->put('reports/q1.pdf', $contents);
$url = Storage::disk('s3')->url('reports/q1.pdf');

$temporary = Storage::disk('s3')->temporaryUrl('reports/q1.pdf', now()->addMinutes(5));
  • php artisan storage:link створює символічне посилання public/storage → storage/app/public для публічного доступу.
  • Зміна сховища (локально ↔ S3) не вимагає переписування коду - лише конфіг.

Докладніше в документації: Зберігання файлів

Через тимчасовий підписаний URL для завантаження: сервер лише видає дозвіл, а файл іде з браузера чи мобільного застосунку напряму в сховище.

public function store(Request $request): JsonResponse
{
    $path = 'uploads/'.$request->user()->id.'/'.Str::uuid().'.mp4';

    ['url' => $url, 'headers' => $headers] = Storage::disk('s3')->temporaryUploadUrl(
        $path, now()->plus(minutes: 10),
    );

    return response()->json(compact('url', 'headers', 'path'));
}

Клієнт робить PUT на url з headers, потім повідомляє застосунку path.

Чому це краще за завантаження через PHP:

  • великий файл не займає воркер PHP і не впирається в upload_max_filesize, post_max_size і таймаути;
  • немає подвійного трафіку «клієнт → сервер → S3».

Що контролює сервер:

  • шлях генерує сам (а не приймає від клієнта) - користувач пише лише у свою теку;
  • короткий термін дії URL;
  • після завантаження - перевірка: файл існує, розмір і тип відповідають очікуванням (Storage::size(), mimeType()), лише тоді запис у базі;
  • незавершені завантаження прибирає правило життєвого циклу бакета.

Підтримують драйвери s3 і local. Для перегляду приватних файлів - temporaryUrl().

Докладніше в документації: Тимчасові URL для завантаження

Storage::fake() підміняє диск тимчасовою текою, а UploadedFile::fake() створює файл-заглушку.

it('stores the avatar', function () {
    Storage::fake('public');
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post('/profile/avatar', ['avatar' => UploadedFile::fake()->image('me.jpg', 400, 400)])
        ->assertRedirect();

    Storage::disk('public')->assertExists($user->fresh()->avatar_path);
});

it('rejects a file that is too big', function () {
    Storage::fake('public');

    $this->actingAs(User::factory()->create())
        ->post('/profile/avatar', ['avatar' => UploadedFile::fake()->create('big.jpg', 5000)])
        ->assertInvalid(['avatar']);

    Storage::disk('public')->assertDirectoryEmpty('avatars');
});

Що варто знати:

  • fake()->image() створює справжнє зображення (потрібен GD) - пройде image і dimensions;
  • fake()->create('doc.pdf', 1024, 'application/pdf') - файл заданого розміру в кілобайтах і MIME-типу, без реального вмісту;
  • перевірки: assertExists, assertMissing, assertCount, assertDirectoryEmpty;
  • шлях краще брати з моделі, а не вгадувати: збережене ім'я генерується (hashName).

Не забути негативні випадки: завеликий файл, неправильний тип, чужий користувач не може видалити файл. Саме там зазвичай діри.

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

Драйвер задається в config/session.php. На одному сервері різниці майже немає, на кількох - вона вирішальна.

file - файли на диску сервера. За балансувальником користувач після кожного запиту може потрапити на інший сервер, де його сесії немає, тож його «розлогінює» через раз.

cookie - усе зберігається в самій куці, зашифрованій. Спільного сховища не треба, але обсяг обмежений ~4 КБ, і дані їздять у кожному запиті.

database - таблиця sessions, спільна для всіх серверів. Працює надійно, ціна - запит на кожен запит.

redis - спільне сховище в памʼяті. Стандартний вибір для кількох серверів: швидко й без навантаження на базу.

Що ще ламається на кількох серверах, крім драйвера:

  • APP_KEY має бути однаковий на всіх серверах, інакше куки, зашифровані одним, не розшифрує інший.
  • Той самий Redis мають бачити всі вузли - окремі інстанси не допоможуть.

Дві деталі, що псують життя окремо від масштабування. Сесія блокується на час запиту, тож кілька паралельних AJAX-запитів шикуються в чергу - для читання це знімається ->block() або сесією без запису. І в API сесій зазвичай узагалі не має бути: токен не потребує стану на сервері, а SESSION_DRIVER для stateless-запитів - зайва робота.

Докладніше в документації: HTTP-сесія

Файли в lang/ перекладають інтерфейс - кнопки, підписи, помилки. Для контенту з бази - назв категорій, описів товарів - потрібен інший підхід, і вибір між трьома.

1. Колонки на кожну мову - найпростіше:

Schema::table('categories', function (Blueprint $table) {
    $table->string('name_uk');
    $table->string('name_en');
});

Працює, поки мов дві. Кожна нова - міграція й правка всіх запитів.

2. JSON-колонка:

$table->json('name');   // {"uk": "Вакансії", "en": "Jobs"}

protected function casts(): array
{
    return ['name' => 'array'];
}

Нова мова не потребує міграції. Мінус - пошук і сортування за перекладом стають незручними, хоча PostgreSQL і MySQL уміють індексувати JSON-шляхи.

3. Окрема таблиця перекладів - рядок на пару «запис + мова». Найгнучкіше: пошук і сортування звичайні, мов скільки завгодно. Ціна - join у кожному запиті.

Що врахувати незалежно від вибору:

  • Запасний варіант. Якщо перекладу немає, показувати мову за замовчуванням, а не порожнє місце.
  • URL. Мову зазвичай виносять у шлях (/en/jobs), бо це дає окремі адреси для індексації - на відміну від зберігання вибору лише в сесії.
  • hreflang у розмітці, щоб пошук розумів звʼязок між версіями.
  • Не лише текст. Формати дат, чисел і валют теж локальні; Carbon::setLocale() і Number::format() це враховують.

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

Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 101 Middle 147 Senior 130

Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії