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

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

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

378 питань

Перебір (/invoices/1, /invoices/2...) і скрапінг шукають дані, які віддаються без достатніх перевірок, або просто вивантажують усе відкрите.

Перша лінія - авторизація. Кожен запис перевіряється політикою, а запити списків починаються від власника. Якщо цього немає, решта заходів лише гальмує витік.

Не розкривати існування: для чужих ресурсів - 404, а не 403 (Response::denyAsNotFound()), щоб перебір не відрізняв «чуже» від «немає».

Обмеження частоти з розумом:

RateLimiter::for('lookups', function (Request $request) {
    return Limit::perMinute(10)
        ->by($request->user()?->id ?: $request->ip())
        ->after(fn (Response $response) => $response->status() === 404);
});

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

Непередбачувані ідентифікатори (UUID, ULID) у публічних URL ускладнюють перебір, але не замінюють авторизацію.

Проти масового збору відкритих даних:

  • пагінація з обмеженням розміру сторінки, без «віддати все»;
  • ліміти для гостей суворіші, ніж для автентифікованих;
  • захист на рівні CDN (WAF, challenge) для явних ботів - він дешевший за обробку в PHP;
  • моніторинг: різкий ріст 404 з одного джерела - сигнал.

Докладніше в документації: Обмеження частоти на основі відповіді

Middleware утворюють «цибулю» навколо обробки запиту: кожен бачить запит на вході й відповідь на виході у зворотному порядку. Порядок вирішує, бо одні залежать від роботи інших.

Зазвичай він визначається місцем реєстрації: глобальні → групові → маршрутні. Але middleware, призначені маршруту, сортуються за списком пріоритетів, а не за тим, як їх перелічили в ->middleware([...]).

Чому це важливо. SubstituteBindings резолвить {post} у модель. Політика, що перевіряє власника поста, має спрацювати після цього - інакше отримає рядок замість моделі. Так само аутентифікація мусить йти раніше за авторизацію: перевіряти права нема в кого, поки користувач не визначений.

Задати свій порядок у bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->priority([
        \Illuminate\Cookie\Middleware\EncryptCookies::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\Auth\Middleware\Authenticate::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
        \App\Http\Middleware\EnsureSubscribed::class,
        \Illuminate\Auth\Middleware\Authorize::class,
    ]);
})

Ті, що є в списку, ідуть у вказаному порядку; решта - за порядком призначення.

Ознака, що ви натрапили саме на це: middleware працює на одному маршруті й «не бачить» даних на іншому, хоча код той самий. Зазвичай це означає, що щось потрібне ще не встигло відпрацювати.

Порада: не переставляйте наосліп. Спершу зʼясуйте, від чого залежить ваше middleware - від сесії, від резолвленої моделі, від автентифікованого користувача, - і поставте його після цього.

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

Код до $next($request) виконується до контролера, код після - коли відповідь уже готова.

public function handle(Request $request, Closure $next): Response
{
    $start = hrtime(true);

    $response = $next($request);

    $response->headers->set('Server-Timing', 'app;dur='.(hrtime(true) - $start) / 1e6);
    $response->headers->set('X-Frame-Options', 'SAMEORIGIN');

    return $response;
}

Типові задачі «після»: заголовки безпеки, Cache-Control, кореляційний ID запиту, вимірювання часу.

На що зважати:

  • Порядок. На вході middleware виконуються за списком, на виході - у зворотному порядку. Middleware, що ставить Cache-Control, має йти після того, що може його змінити. Laravel дозволяє задати пріоритет (priority() у bootstrap/app.php).
  • Потокові й файлові відповіді. StreamedResponse і BinaryFileResponse не мають готового тіла - не можна читати чи змінювати getContent(), лише заголовки.
  • Глобальний чи маршрутний. Глобальний middleware спрацьовує навіть для 404 і на запити до Livewire чи ресурсів, тож важка логіка там коштує на кожен запит.
  • Чужі middleware можуть перетерти заголовок. Наприклад, Livewire ставить no-store глобально, тож власний middleware, що робить сторінку кешованою, мусить бути в тому самому глобальному стеку й іти після нього.
  • Робота після відповіді - не тут, а в terminate() чи defer(): інакше користувач чекає.

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

BDD розширює TDD, зміщуючи фокус на поведінку системи з погляду бізнесу/користувача, а не на технічні деталі. Сценарії описують зрозумілою мовою (Gherkin: Given-When-Then).

Scenario: Успішний вхід
  Given користувач зареєстрований
  When він вводить правильні дані
  Then він потрапляє на дашборд

У PHP-екосистемі - Behat. Pest також заохочує «describe behavior» стиль:

it('redirects to dashboard after login', function () {
    // ...
});

Цінність BDD - спільна мова між розробниками, QA та бізнесом; тести стають живою документацією очікуваної поведінки.

Для Livewire - хелпер livewire() (із pest-plugin-livewire):

livewire(SearchPosts::class)
    ->set('query', 'laravel')
    ->assertSee('Laravel Queues')
    ->call('clear')
    ->assertSet('query', '')
    ->assertDispatched('posts-updated');

Для Filament - спершу автентифікуйте користувача, потім тестуйте сторінки ресурсів:

livewire(CreatePost::class)
    ->fillForm(['title' => 'Hello'])
    ->call('create')
    ->assertHasNoFormErrors();

livewire(ListPosts::class)
    ->callAction(TestAction::make('publish')->table($post))
    ->assertNotified();

Ключові асерти: assertSet, assertSee, assertDispatched, assertHasFormErrors, assertCanSeeTableRecords.

Усі три замінюють справжню залежність у тесті, але перевіряють різне.

  • Fake - робоча спрощена реалізація. Mail::fake() справді приймає листи, а тест перевіряє результат: що пішло, кому, з чим.
  • Mock - об'єкт з очікуваннями, заданими заздалегідь: «метод charge буде викликано раз з сумою 100». Не збулося - тест падає.
  • Spy - записує всі виклики, а перевірки пишуть після дії. Як mock, але читається в порядку «дія → перевірка».
$this->mock(PaymentGateway::class, function (MockInterface $mock) {
    $mock->expects('charge')->with(10000)->andReturn(new Charge('ch_1'));
});

Чому надмір моків шкодить: тест, що мокає п'ять залежностей і перевіряє порядок їхніх викликів, фіксує реалізацію, а не поведінку. Будь-який рефакторинг ламає тест, хоча для користувача нічого не змінилось, - і команда звикає «лагодити тести», а не довіряти їм.

Практичні правила:

  • Мокати межі системи - зовнішні API, платежі, пошту, час - а не власні класи.
  • Віддавати перевагу фейкам і перевірці стану: що записано в базу, що повернуто.
  • Не мокати те, чим не володієте, напряму: краще обгорнути чужий SDK своїм інтерфейсом і підміняти його.
  • Хоча б кілька тестів мають проганяти справжній ланцюжок без підмін - інакше зелений набір нічого не каже про продакшен.

Докладніше в документації: Мокінг об'єктів

Спершу виміряти, потім лагодити найповільніше.

1. Знайти повільні тести:

php artisan test --profile

Зазвичай кілька тестів забирають більшу частину часу - справжні HTTP-запити, sleep(), сідер з тисячами рядків.

2. Паралельний запуск:

php artisan test --parallel

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

3. Дешевші налаштування:

  • WithCachedConfig (Laravel 13) - конфігурація збирається один раз, а не на кожен тест;
  • LazilyRefreshDatabase - тести без бази не мігрують її;
  • драйвери array для кешу й сесії, sync для черги, низький BCRYPT_ROUNDS для хешування.

4. Менше роботи в самих тестах:

  • створювати рівно ті дані, які перевіряються, а не викликати важкий сідер;
  • make() замість create(), де база не потрібна;
  • підміняти мережу (Http::fake()) і час (travel()), а не чекати.

5. Ширше: на CI розбивати набір між машинами (--shard у Pest), а локально запускати лише тести, яких торкнулися зміни (--tia у Pest 5).

Швидкий набір - не розкіш: тести, які йдуть 25 хвилин, розробники перестають запускати локально.

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

Зробити так, щоб N+1 ламав тест, а не тихо пригальмовував сторінку в проді.

1. Заборонити ліниве завантаження поза продакшеном:

// AppServiceProvider::boot()
Model::preventLazyLoading(! $this->app->isProduction());

Тепер звернення до незавантаженого зв'язку в циклі кидає LazyLoadingViolationException - і будь-який тест сторінки, що це робить, впаде. Часто вмикають разом Model::shouldBeStrict(), який ще й ловить звернення до відсутніх атрибутів.

2. Обмежити кількість запитів для критичної сторінки (Laravel 13):

it('loads the orders page in a fixed number of queries', function () {
    Order::factory()->count(20)->hasItems(3)->create();

    $this->expectsDatabaseQueryCount(4);

    $this->actingAs(User::factory()->admin()->create())
        ->get('/admin/orders')
        ->assertOk();
});

Важливо: тестові дані мають бути «множинними». N+1 не видно на одному записі - один пост з одним коментарем дасть два запити і з with(), і без. Тест має створювати кілька записів, щоб різниця між «2 запити» і «21 запит» проявилася.

Альтернатива в Laravel 12+ - Model::automaticallyEagerLoadRelationships(), що сам довантажує зв'язки для всієї колекції. Але тест з лічильником запитів корисний і тоді: він фіксує, що сторінка не деградує з часом.

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

Це тести не поведінки, а структури коду: які класи де лежать, від чого залежать, що їм заборонено. У Pest вони пишуться функцією arch().

arch('models extend Eloquent')
    ->expect('App\Models')
    ->toExtend(Illuminate\Database\Eloquent\Model::class);

arch('no debugging leftovers')
    ->expect(['dd', 'dump', 'ray', 'var_dump'])
    ->not->toBeUsed();

arch('controllers stay thin')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Support\Facades\DB');

arch()->preset()->laravel();
arch()->preset()->security();

Що ними зручно фіксувати:

  • забуті dd() і dump(), які інакше потрапляють у продакшен;
  • межі шарів: домен не залежить від HTTP, контролери не пишуть сирі запити;
  • угоди: усі завдання реалізують ShouldQueue, усі enum - у App\Enums, класи final чи readonly, де так домовились;
  • небезпечні функції: eval, exec, md5 для паролів (пресет security).

Навіщо, якщо є code review: рев'ю помічає порушення вибірково й залежить від уважності, а тест перевіряє кожен коміт. Архітектурні рішення, записані тестом, переживають зміну команди - їх не треба переказувати новачкам.

Обережно: правило без причини дратує й обходиться. Кожне має відповідати реальній домовленості, а не смаку.

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

Глобальний скоп додає умову до всіх запитів моделі автоматично. SoftDeletes - саме такий скоп: він дописує where deleted_at is null, і тому видалені записи не з'являються у вибірках.

Свій скоп через атрибут:

use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy(PublishedScope::class)]
class Post extends Model
{
}

class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('is_published', true);
    }
}

Зняти на один запит:

Post::withoutGlobalScope(PublishedScope::class)->get();
Post::withoutGlobalScopes()->get();

У чому небезпека. Умова застосовується там, де її ніхто не бачить у коді виклику:

  • Адмінка показує не все. Редактор відкриває список і не бачить чернеток, бо скоп мовчки їх відрізав. Помилка виглядає як зникнення даних.
  • Підрахунки брешуть. Post::count() рахує лише опубліковані, і звіт розходиться з базою без видимої причини.
  • Зв'язки теж під скопом. $user->posts віддає відфільтроване, і зовні це не помітно.
  • updateOrCreate може створити дубль. Пошук не знаходить існуючий запис, бо той відрізаний скопом, і замість оновлення відбувається вставка.

Практичне правило: глобальний скоп доречний, коли умова справді інваріант для всієї моделі - як SoftDeletes чи розділення по тенантах. Для «зазвичай потрібні лише опубліковані» краще локальний скоп: він явний у місці виклику.

public function scopePublished(Builder $query): void
{
    $query->where('is_published', true);
}

Post::published()->get();   // видно, що фільтр застосовано

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

Eloquent сам розсилає події життєвого циклу: creating, created, updating, updated, saving, saved, deleting, deleted, restored, replicating.

Зручно для дрібниць, які мають виконуватися завжди:

class Post extends Model
{
    protected static function booted(): void
    {
        static::creating(function (Post $post) {
            $post->slug ??= Str::slug($post->title);
        });
    }
}

Для повного набору краще окремий observer - інакше booted() розростається:

#[ObservedBy(PostObserver::class)]
class Post extends Model
{
}

Чим небезпечні:

  • Масові операції їх не викликають. Post::where(...)->update([...]), ->delete() та insert() йдуть повз Eloquent, тож жоден слухач не спрацює. Дані тихо розходяться з тим, що гарантував observer.
  • Логіка стає невидимою. $post->save() у контролері виглядає простим рядком, а насправді тягне за собою запис в інші таблиці й виклик API.
  • Тести сповільнюються. Кожна фабрика в кожному тесті тягне весь ланцюжок подій, включно з тими, що ходять у мережу.
  • Помилка в слухачі ламає збереження. Виняток у creating скасовує операцію - зокрема там, де про це ніхто не думав.
  • Рекурсія. Слухач saved, який оновлює ту саму модель, викликає сам себе; рятує saveQuietly().

Правило: модельні події - для інваріантів самої моделі (слаг, нормалізація поля, службові звʼязки). Бізнес-процес - оформлення замовлення, розсилка, інтеграції - належить явному сервісу чи доменній події, які видно в місці виклику.

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

Типовий випадок: імпорт оновлює товар 20 разів за хвилину, і кожне ProductUpdated перебудовує пошуковий індекс.

Дебаунс (Laravel 13) - обробити лише останню подію за проміжок:

#[DebounceFor(30)]
class UpdateProductSearchIndex implements ShouldQueue
{
    public function debounceId(ProductUpdated $event): string
    {
        return (string) $event->product->getKey();
    }

    public function handle(ProductUpdated $event): void { /* ... */ }
}

Кожна нова подія з тим самим debounceId скидає таймер; виконується лише остання, через 30 секунд тиші.

Унікальний слухач - не ставити в чергу дубль, поки попередній не оброблений:

class AcquireProductKey implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(LicenseSaved $event): string
    {
        return (string) $event->license->id;
    }
}

Різниця: дебаунс чекає, доки події вщухнуть, і обробляє останню. Унікальність обробляє першу, а повтори відкидає, поки вона в черзі.

Інші підходи:

  • подію на рівні пачки: ProductsImported один раз замість тисячі ProductUpdated;
  • Event::defer() чи Model::withoutEvents() навколо масових операцій.

Обидва механізми потребують драйвера кешу з атомарними блокуваннями.

Докладніше в документації: Слухачі подій з дебаунсом

Event::defer() відкладає всі події, що виникли в замиканні, до його завершення.

Event::defer(function () {
    $user = User::create(['name' => 'Victoria']);
    $user->posts()->create(['title' => 'Перший допис']);
});

Без defer слухач created користувача спрацює одразу після User::create() - ще до створення поста. Якщо слухач розраховує на пов'язані записи (надіслати вітання з посиланням на перший допис), він їх не знайде.

Особливості:

  • якщо в замиканні виняток, відкладені події не диспетчеризуються зовсім;
  • другим аргументом можна відкласти лише певні події: ['eloquent.created: '.User::class].

Чим відрізняється від after-commit:

Event::defer() ShouldDispatchAfterCommit
Чекає кінця замикання коміту транзакції бази
Транзакція потрібна ні так (без неї - одразу)
Мета цілісність набору записів у коді щоб слухачі не бачили незакомічених даних

Їх можна поєднувати: defer усередині DB::transaction() збирає події, а after-commit гарантує, що слухачі в черзі побачать закомічені дані.

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

Одна команда робить більшість:

php artisan optimize        # config + route + view + event cache

Окремо:

php artisan config:cache # об'єднує конфіг у один файл
php artisan route:cache # компілює маршрути
php artisan view:cache # прекомпілює Blade
php artisan event:cache # кеш мапінгу подій/слухачів
composer install --no-dev --optimize-autoloader

Додатково: увімкнений OPcache (а краще з JIT), prebuilt ассети (npm run build).

Підводний камінь: після config:cache виклики env() поза config/ повертають null - усі env-значення мають читатися лише у конфіг-файлах. На деплої не забути php artisan optimize:clear перед повторним кешуванням.

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

На одному сервері планувальник працює очевидно. Щойно серверів стає два, зʼявляються дві різні проблеми, які плутають між собою.

1. Завдання виконується двічі. Cron стоїть на обох серверах, тож щохвилинний schedule:run запускається двічі - і звіт розсилається двічі. Лікується onOneServer():

Schedule::command('reports:send')
    ->daily()
    ->onOneServer();

Перший сервер бере атомарне блокування в кеші, другий пропускає запуск. Драйвером за замовчуванням має бути database, memcached, dynamodb або redis, і всі сервери мусять дивитися в один центральний кеш; з file у кожного вузла кеш свій, тож блокування нічого не гарантує.

2. Завдання накладається саме на себе. Імпорт триває сім хвилин, а запускається щоп'ять - і другий екземпляр стартує поверх першого. Це вже про час виконання, а не про кількість серверів:

Schedule::command('import:run')
    ->everyFiveMinutes()
    ->withoutOverlapping();

За замовчуванням блокування тримається 24 години; якщо процес упав без звільнення, наступний запуск чекатиме. Тому варто задавати межу: withoutOverlapping(10).

Разом, коли потрібні обидві гарантії:

Schedule::command('import:run')
    ->everyFiveMinutes()
    ->withoutOverlapping(10)
    ->onOneServer();

Ще дві дрібниці, які кусаються. Часовий пояс планувальника береться з конфігурації застосунку - ->timezone('Europe/Kyiv') задає його явно. І завдання, що не пише жодного логу, мовчки не виконується місяцями; emailOutputOnFailure() або ->onFailure() роблять збій помітним.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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