Питання на співбесіді з 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 - від сесії, від резолвленої моделі, від автентифікованого користувача, - і поставте його після цього.
Код до $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(): інакше користувач чекає.
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 сам розсилає події життєвого циклу: 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().
Правило: модельні події - для інваріантів самої моделі (слаг, нормалізація поля, службові звʼязки). Бізнес-процес - оформлення замовлення, розсилка, інтеграції - належить явному сервісу чи доменній події, які видно в місці виклику.
Типовий випадок: імпорт оновлює товар 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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії