Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Змусити застосунок падати на ліниве завантаження під час розробки й тестів - тоді N+1 не доходить до рев'ю.
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
Тепер $post->author без попереднього with('author') у локальному оточенні й тестах кидає LazyLoadingViolationException. Тести, що проходять сторінку, ловлять пропущені with() автоматично.
У продакшені не падати, а повідомляти:
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation]);
});
Model::shouldBeStrict() вмикає разом три перевірки: ліниве завантаження, мовчазне відкидання атрибутів поза $fillable і звернення до відсутніх атрибутів.
Інші інструменти:
Model::automaticallyEagerLoadRelationships()- Laravel сам довантажить зв'язок для всієї колекції, щойно звернулися до нього в однієї моделі. Зручна страховка, але приховує, які дані сторінка насправді тягне;chaperone()наhasMany- дочірні моделі отримують посилання на батька без зайвого запиту ($comment->postу циклі);- лічильник запитів у тестах: перевірка, що сторінка робить не більше N запитів незалежно від кількості записів.
Пастка: ліниве завантаження в Blade-компонентах і вкладених Livewire-компонентах ховається найкраще - саме там ця заборона окупається.
Вбудованих каналів чотири - mail, database, broadcast, vonage. Усе інше - Slack, Telegram, SMS українського провайдера, пуші - або пакет, або власний канал.
Канал - це клас з одним методом:
class TelegramChannel
{
public function __construct(private TelegramClient $client)
{
}
public function send(object $notifiable, Notification $notification): void
{
$chatId = $notifiable->routeNotificationFor('telegram');
if ($chatId === null) {
return;
}
$this->client->sendMessage($chatId, $notification->toTelegram($notifiable));
}
}
Підключення в сповіщенні:
class VacancyPublished extends Notification
{
public function via(object $notifiable): array
{
return [TelegramChannel::class, 'mail'];
}
public function toTelegram(object $notifiable): string
{
return "Нова вакансія: {$this->vacancy->title}";
}
}
Куди слати - вирішує сам отримувач:
class User extends Authenticatable
{
public function routeNotificationForTelegram(): ?string
{
return $this->telegram_chat_id;
}
}
Навіщо це, а не просто виклик API. Сповіщення дає те, чого ручний виклик не має: один клас описує повідомлення для всіх каналів одразу, via() вирішує канали за налаштуваннями користувача, ShouldQueue відправляє асинхронно, а Notification::fake() дозволяє перевірити відправлення в тесті без мережі.
Канали бувають трьох типів, і різниця між ними - саме в доступі.
Публічний - слухати може будь-хто, хто знає назву:
class VacancyPublished implements ShouldBroadcast
{
public function broadcastOn(): Channel
{
return new Channel('vacancies');
}
}
Годиться лише для того, що й так публічне.
Приватний - потребує авторизації:
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('user.'.$this->user->id);
}
Правило описується в routes/channels.php:
Broadcast::channel('user.{userId}', function (User $user, int $userId) {
return $user->id === $userId;
});
Клієнт спершу звертається до /broadcasting/auth, і лише отримавши підпис, підписується на канал.
Presence - приватний плюс список присутніх: хто зараз онлайн, хто друкує.
Де помиляються:
- Публічний канал для приватних даних. Назву каналу видно у фронтенді, тож
Channel('user.'.$id)слухається ким завгодно з перебором id. - Замикання авторизації, що завжди повертає
true. Це те саме, що публічний канал, але виглядає безпечно. - Забувають, що подія несе всю модель. За замовчуванням у payload потрапляють усі публічні властивості - разом із полями, яких клієнт бачити не мусить. Формуйте payload явно через
broadcastWith(). - Приватні дані у назві каналу. Email чи токен у назві видно всім, хто дивиться трафік.
Практично: усе, що стосується конкретного користувача, - PrivateChannel; payload завжди явний.
Починається все з простого $user->is_admin, далі зʼявляється редактор, потім модератор - і умови розповзаються по коду.
Крок перший - роль як enum:
enum Role: string
{
case Admin = 'admin';
case Editor = 'editor';
case Author = 'author';
}
Це вже краще за рядки, але перевірки виду $user->role === Role::Editor розкидані по контролерах ламаються, щойно права ролі змінюються.
Крок другий - права, а не ролі. Код питає «чи можна публікувати», а не «чи ти редактор»:
Gate::define('publish', fn (User $user) => $user->hasPermission('publish'));
Роль стає лише набором прав, і зміна набору не потребує правок у коді.
Policy для дій над моделлю:
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->hasPermission('posts.update')
|| $post->author_id === $user->id;
}
}
before() для суперкористувача - щоб не дублювати перевірку в кожному методі:
public function before(User $user): ?bool
{
return $user->isAdmin() ? true : null;
}
Повертати треба саме null, а не false: false заборонить дію остаточно й не дасть іншим методам відпрацювати.
Коли брати пакет. spatie/laravel-permission дає ролі, права й кеш перевірок з коробки. Він доречний, коли набір прав змінюють з адмінки; якщо ролей три й вони зашиті в код, enum із Policy простіший.
Що не забути: перевірки прав кешуються не самі - на кожен запит це кілька звернень до бази, тому права користувача варто завантажувати разом із ним.
Політика відповідає на питання про один об'єкт: «чи може цей користувач переглянути цей рахунок?». Але дані витікають і там, де об'єкти беруться списком чи через вкладені маршрути, - і туди політика не дотягується, якщо її не викликали.
Три місця, де це трапляється:
1. Списки. Політика view не фільтрує Invoice::paginate(). Якщо запит не обмежено, сторінка покаже всі рахунки:
$invoices = $request->user()->invoices()->latest()->paginate();
Запит має починатися від користувача (чи від команди / тенанта), а не від моделі.
2. Вкладені маршрути. /users/{user}/posts/{post} без обмеження знайде пост 99, навіть якщо він належить іншому користувачу:
Route::get('/users/{user}/posts/{post}', ...)->scopeBindings();
scopeBindings() шукає пост через $user->posts(), а не по всій таблиці.
3. Пошук, експорт, API, черги. Будь-який код, що збирає дані поза звичайним контролером, - звіт, CSV, ендпойнт пошуку, - теж має обмежувати запит.
Як зробити це системно: глобальний скоп на модель для мультитенантності (з обережністю до withoutGlobalScopes()), репозиторій чи запит, що завжди починається від власника, і тест «користувач A не бачить даних користувача B» для кожного ендпойнта зі списком. UUID замість числових ID від цього не рятують - вони лише ускладнюють перебір.
Права ламаються тихо: забута перевірка не дає помилки, вона просто дозволяє зайве. Тому їх тестують на двох рівнях.
1. Правило - напряму, через can():
it('lets only the author edit a draft', function () {
$author = User::factory()->create();
$post = Post::factory()->draft()->for($author)->create();
expect($author->can('update', $post))->toBeTrue()
->and(User::factory()->create()->can('update', $post))->toBeFalse();
});
can() проходить увесь шлях: before, саму політику, after. Тест швидкий і точно вказує, яке правило зламалося.
2. Ендпойнт - що перевірку справді викликають:
it('forbids editing someone else\'s post', function () {
$post = Post::factory()->create();
$this->actingAs(User::factory()->create())
->put("/posts/{$post->id}", ['title' => 'Чуже'])
->assertForbidden();
expect($post->fresh()->title)->not->toBe('Чуже');
});
Ідеальна політика нічого не варта, якщо контролер її не викликав - цей тест ловить саме таке.
Що варто покривати обов'язково:
- «користувач A не бачить і не змінює даних користувача B» для кожного ресурсу;
- гість, звичайний користувач, власник, адмін - матриця ролей для ключових дій;
- списки й експорт, а не лише окремі сторінки;
denyAsNotFound()- що повертається саме 404.
Архітектурний тест на кшталт «кожен метод контролера, що змінює дані, викликає Gate::authorize» складно написати надійно, тому покладаються на тести ендпойнтів.
Докладніше в документації: Авторизація через модель користувача
Довідники - ролі, статуси, категорії, налаштування - це дані застосунку, а не демо. Вони потрібні на проді, і сідер для них має витримувати повторний запуск.
Не так:
Role::create(['slug' => 'admin', 'name' => 'Адміністратор']);
Другий запуск або впаде на унікальному індексі, або створить дубль.
Так:
foreach ([
['slug' => 'admin', 'name' => 'Адміністратор'],
['slug' => 'editor', 'name' => 'Редактор'],
] as $role) {
Role::updateOrCreate(['slug' => $role['slug']], $role);
}
Ключ пошуку - стабільний ідентифікатор, а не id: автоінкремент на різних середовищах різний.
Обережно з updateOrCreate на довідниках, які редагують з адмінки. Він перезапише зміни, зроблені руками. Якщо назву дозволено міняти, оновлювати варто лише технічні поля:
Role::firstOrCreate(['slug' => 'editor'], ['name' => 'Редактор']);
firstOrCreate створює запис, якщо його немає, і не чіпає наявний.
Як запускати на проді:
php artisan db:seed --class=RolesSeeder --force
--force потрібен, бо в продакшн-середовищі Artisan питає підтвердження. Викликати db:seed без --class на проді небезпечно: DatabaseSeeder зазвичай тягне ще й демо-дані.
Альтернатива - зробити це міграцією. Тоді заповнення виконається рівно один раз і саме в потрібний момент деплою, а не за окремою командою, яку легко забути. Для довідника, від якого залежить код нового випуску, це надійніше.
Мову зазвичай визначає middleware - з URL, налаштувань користувача чи заголовка браузера:
class SetLocale
{
public function handle(Request $request, Closure $next): Response
{
$locale = $request->route('locale')
?? $request->user()?->locale
?? $request->getPreferredLanguage(['uk', 'en']);
App::setLocale($locale);
return $next($request);
}
}
Де це стикається з кешем - три різні місця:
1. Кеш застосунку. Значення, покладене під ключем nav.items, буде віддане й іншій мові. Ключ має включати локаль:
Cache::remember('nav.items.'.app()->getLocale(), 3600, fn () => ...);
2. Кеш HTTP і CDN. Якщо мова визначається заголовком Accept-Language, а не URL, то одна адреса віддає різний вміст - і проксі роздасть усім ту версію, яка потрапила в кеш першою. Рятує або Vary: Accept-Language, або мова в URL.
3. Кеш маршрутів. route:cache фіксує маршрути один раз, тож локаль не може бути частиною визначення маршруту - лише параметром.
Чому мову краще тримати в URL. Окремі адреси на кожну мову - це єдиний варіант, який нормально індексується: у пошуку зʼявляються обидві версії, hreflang їх звʼязує, а посилання веде туди, куди вело в того, хто ним поділився. Мова в сесії всього цього не дає.
Дрібниця, яку часто пропускають: App::setLocale() не змінює локаль Carbon - для дат потрібен окремий Carbon::setLocale().
Кожен провайдер завантажується на кожному запиті. Якщо він лише реєструє сервіс, потрібний в одному місці з сотні, це марна робота на всіх інших запитах.
Відкладений провайдер реєструється лише тоді, коли його сервіс справді резолвлять:
use Illuminate\Contracts\Support\DeferrableProvider;
class WeatherServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(WeatherClient::class, function ($app) {
return new WeatherClient(config('services.weather.key'));
});
}
/**
* @return array<int, class-string>
*/
public function provides(): array
{
return [WeatherClient::class];
}
}
Laravel запамʼятовує зіставлення «сервіс → провайдер» у маніфесті й піднімає провайдер лише за потреби.
Умови, за яких це працює:
- Провайдер має лише
register(). Якщо єboot(), слухачі подій, маршрути чи публікація ресурсів - вони не виконаються, поки сервіс ніхто не запросить, тобто фактично ніколи. provides()мусить перелічувати всі привʼязки. Забута привʼязка просто не знайдеться в контейнері.
Коли не варто: якщо провайдер реєструє щось, що потрібне майже завжди, відкладення дає нуль виграшу й додає місце для помилки.
Скільки це коштує. Виграш помітний на застосунку з десятками провайдерів; на невеликому - в межах похибки. Перед тим як відкладати, варто виміряти профайлером, а не робити це «про всяк випадок».
Через package discovery: пакет оголошує провайдер і фасади в секції extra.laravel свого composer.json.
"extra": {
"laravel": {
"providers": [
"Acme\\Billing\\BillingServiceProvider"
],
"aliases": {
"Billing": "Acme\\Billing\\Facades\\Billing"
}
}
}
Після composer install чи update Laravel виконує package:discover, збирає ці оголошення з усіх пакетів і кешує список. Провайдер реєструється автоматично - у bootstrap/providers.php нічого дописувати не треба.
Як застосунку відмовитися від автопідключення - наприклад, щоб зареєструвати провайдер умовно лише локально:
"extra": {
"laravel": {
"dont-discover": ["barryvdh/laravel-debugbar"]
}
}
"*" у dont-discover вимикає виявлення для всіх пакетів.
Що варто знати авторові пакета:
- провайдер пакета має бути легким і бажано відкладеним, якщо лише реєструє прив'язки - він завантажується в кожному застосунку, що встановив пакет;
- конфігурацію, міграції й шаблони пакет пропонує опублікувати (
publishes()), а не копіює сам; mergeConfigFrom()уregister()дає значення за замовчуванням, які застосунок може перекрити своїм файлом.
Якщо після встановлення пакета щось «не підхопилося», перевіряють кеш: php artisan package:discover і optimize:clear.
Правило unique - це SELECT перед вставкою. Між перевіркою й записом інший запит встигає вставити той самий email.
Запит A: SELECT ... email = 'a@x.com' -> немає
Запит B: SELECT ... email = 'a@x.com' -> немає
Запит A: INSERT a@x.com -> ок
Запит B: INSERT a@x.com -> дубль
Що робити:
- Унікальний індекс у базі - єдина справжня гарантія. Валідація лишається для зрозумілого повідомлення в нормальному випадку.
- Обробити порушення індексу - Laravel кидає
UniqueConstraintViolationException, його перетворюють на ту саму помилку валідації. АбоcreateOrFirst(), який робить це сам.
try {
$user = User::create($data);
} catch (UniqueConstraintViolationException) {
throw ValidationException::withMessages(['email' => __('validation.unique', ['attribute' => 'email'])]);
}
Деталі правила, про які питають:
- при оновленні виключають поточний запис:
Rule::unique('users')->ignore($user->id);ignore()ніколи не беруть з даних запиту - лише з моделі, інакше клієнт підставить чужий ID; withoutTrashed()не враховує м'яко видалені записи;- регістр:
Ada@x.comіada@x.comрізні дляunique, тож email нормалізують до нижнього регістру ще до валідації.
Для API валідація - це контракт: клієнти парсять помилки програмно, тож формат і поведінка мають бути передбачуваними.
Що Laravel дає з коробки: якщо запит очікує JSON (Accept: application/json), замість перенаправлення повертається 422:
{
"message": "The email field is required. (and 1 more error)",
"errors": {
"email": ["The email field is required."],
"items.0.quantity": ["The items.0.quantity field must be at least 1."]
}
}
Вкладені ключі сплющуються в «крапкову» нотацію.
Що варто налаштувати:
- Невідомі поля.
#[FailOnUnknownFields]на запиті форми (або глобальноFormRequest::failOnUnknownFields()) відхиляє поля, яких немає в правилах. Помилки клієнта на кшталтemialстають помітними одразу, а не тихо ігноруються. - Стабільні повідомлення. Клієнти не мають залежати від тексту - лише від ключів полів і HTTP-коду. Якщо потрібні машинні коди помилок, їх додають власним форматом відповіді.
- Межі вхідних даних:
maxдля рядків і масивів, ліміт розміру тіла запиту на вебсервері. - Мова повідомлень за заголовком
Accept-Language, якщо API багатомовний.
Тести: assertUnprocessable() і assertInvalid([...])/assertJsonValidationErrors() фіксують контракт, щоб зміна правил не зламала клієнтів непомітно.
Докладніше в документації: Формат відповіді з помилками валідації
php artisan route:cache компілює всі маршрути в один PHP-файл у bootstrap/cache. Laravel більше не виконує routes/*.php на кожен запит, і реєстрація маршрутів стає майже миттєвою - на застосунках із сотнями маршрутів це помітно.
Коли він ламається чи заважає:
- Маршрути не оновлюються. Після кешування нові маршрути не з'являються, доки кеш не перебудувати. Тому
route:cache(чиoptimize) виконують на кожному деплої, а локально не вмикають. - Маршрути, що залежать від стану під час реєстрації. Якщо в
routes/web.phpмаршрути генеруються з бази чи з конфігурації, яка змінюється без деплою, кеш «заморозить» їх на момент збирання. - Конфлікт імен. Два маршрути з однаковим ім'ям без кешу мовчки перезаписують одне одного, а при кешуванні команда падає з помилкою - це навіть корисно.
Маршрути на замиканнях у сучасних версіях кешуються (їх серіалізують), але звичайна практика - контролери: їх легше тестувати й шукати.
Діагностика: php artisan route:list показує маршрути з кешу, якщо він є; route:clear прибирає кеш.
Неявна прив'язка за замовчуванням шукає модель за первинним ключем. Є кілька рівнів налаштування - від простого до повного контролю.
Інша колонка для одного маршруту:
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
Інша колонка для моделі завжди:
public function getRouteKeyName(): string
{
return 'slug';
}
Власна логіка - наприклад, лише опубліковані пости для гостей:
public function resolveRouteBinding($value, $field = null): ?Model
{
return $this->where($field ?? $this->getRouteKeyName(), $value)
->when(! auth()->user()?->isEditor(), fn ($q) => $q->published())
->first();
}
null дає 404.
Явна прив'язка в провайдері - коли параметр не відповідає моделі напряму:
Route::bind('user', fn (string $value) => User::where('name', $value)->firstOrFail());
Поряд корисне:
->withTrashed()на маршруті - знаходити м'яко видалені моделі;->missing(fn () => redirect(...))- що робити замість 404;scopeBindings()- вкладена модель шукається через батьківську, і чужий ID не пройде.
Важливо: прив'язка моделі - це пошук, а не авторизація. Перевірку прав робить політика.
Blade компілює шаблони в звичайний PHP і кешує в storage/framework/views, тож сам синтаксис майже нічого не коштує. Повільними сторінки роблять інші речі.
Що зазвичай гальмує:
- Запити з шаблонів.
$post->author->nameу циклі безwith()- N+1, найчастіша причина.Model::preventLazyLoading()ловить це в розробці. - Сотні компонентів. Кожен компонент з класом - створення об'єкта через контейнер, злиття атрибутів, окремий шаблон. Таблиця на 500 рядків з п'ятьма компонентами в рядку - тисячі рендерів.
- Вкладені Livewire-компоненти в циклі - кожен зі своїм станом і запитами.
@includeу великих циклах і важкі обчислення в шаблоні.- Перший запит після деплою, якщо шаблони не прекомпільовані.
Як виміряти: Debugbar чи Telescope показують кількість і час шаблонів і запитів; профайлер (SPX, Blackfire, Xdebug) - де саме витрачено час. Корисно порівняти час до рендеру й сам рендер.
Що робити:
php artisan view:cache(входить вoptimize) на деплої;- жадібно завантажувати дані в контролері й передавати в шаблон готове;
- у великих списках - простіший HTML замість глибоко вкладених компонентів; анонімні компоненти дешевші за компоненти з класом;
- кешувати фрагменти, що рідко змінюються (
Cache::rememberнавколоview()->render()), або всю гостьову сторінку на рівні CDN; - пагінація замість виводу всього.
Після змін - знову виміряти: оптимізація без вимірювань часто прискорює не те.
Докладніше в документації: Оптимізація завантаження представлень
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії