Питання на співбесіді з Laravel
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
378 питань
Cache::flexible() реалізує підхід «stale-while-revalidate»: коли значення застаріло, користувач одразу отримує старе, а нове рахується у фоні.
$stats = Cache::flexible('dashboard:stats', [300, 900], function () {
return DashboardStats::calculate(); // повільний розрахунок
});
Масив - два пороги в секундах:
- до 300 - значення свіже, віддається як є;
- від 300 до 900 - застаріле, але прийнятне: віддається одразу, а перерахунок запускається після відправлення відповіді;
- після 900 - надто старе, рахується синхронно, як у
remember().
Проблема remember(), яку це вирішує: коли TTL закінчився, саме той користувач, який прийшов першим, чекає на весь повільний розрахунок. На популярній сторінці кілька таких запитів одночасно ще й навантажать базу (cache stampede).
Коли підходить:
- дані, де хвилина затримки не має значення: статистика, рейтинги, лічильники, зовнішні API;
- розрахунок довгий, а сторінку відкривають часто.
Коли ні: ціни в кошику, залишки на складі, права доступу - там застаріле значення означає помилку, а не трохи старіші цифри.
Перерахунок у фоні виконується через відкладені функції (defer) після відправлення відповіді, тож окрема черга для цього не потрібна.
Observer групує слухачів подій моделі (creating, created, updating, saved, deleting тощо) в один клас - замість роздування boot() моделі.
class PostObserver
{
public function creating(Post $post): void
{
$post->slug = Str::slug($post->title);
}
public function deleted(Post $post): void
{
$post->image()->delete();
}
}
Реєстрація - атрибутом #[ObservedBy(PostObserver::class)] на моделі або в Service Provider. Зручно для генерації slug, очищення пов'язаних ресурсів, аудиту.
- Gate - замикання для простих, не прив'язаних до моделі перевірок.
- Policy - клас, що групує правила авторизації навколо конкретної моделі.
// Gate
Gate::define('view-admin', fn (User $u) => $u->is_admin);
// Policy
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
}
Застосування:
$this->authorize('update', $post); // у контролері
@can('update', $post) ... @endcan // у Blade
$user->can('update', $post); // будь-де
Політики автоматично відкривають 403 при відмові.
bool каже лише «можна» чи «ні». Response дає ще й пояснення та керує тим, що побачить користувач.
Повідомлення про причину:
public function update(User $user, Post $post): Response
{
if ($post->is_locked) {
return Response::deny('Статтю заблоковано модератором.');
}
return $user->id === $post->user_id
? Response::allow()
: Response::deny('Ви не автор цієї статті.');
}
Коли Gate::authorize('update', $post) кидає AuthorizationException, це повідомлення потрапляє у відповідь 403. Без винятку його можна прочитати через Gate::inspect() - наприклад, щоб показати підказку біля неактивної кнопки.
404 замість 403:
return $user->id === $invoice->user_id
? Response::allow()
: Response::denyAsNotFound();
403 підтверджує, що рахунок з таким номером існує. Для приватних ресурсів 404 нічого не розкриває - стороння людина не відрізнить чужий рахунок від неіснуючого. Є й denyWithStatus() для довільного коду.
Коли досить bool: коли причина очевидна й однакова - «не власник». Response окупається, де причин кілька або сам факт існування ресурсу приватний.
Усі три способи ведуть до тієї самої політики - різниця в тому, коли перевірка спрацьовує і які дані їй доступні.
Middleware can - найраніше, ще до контролера:
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');
Модель береться з прив'язки маршруту. Добре, коли рішення залежить лише від користувача й моделі з URL.
Form Request authorize() - перед валідацією:
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
Зручно, коли запит і так має Form Request: права й правила даних в одному класі.
У контролері Gate::authorize() - коли рішення залежить від чогось, що з'являється лише в коді: від даних запиту чи від іншої моделі.
Gate::authorize('transfer', [$account, $request->integer('amount')]);
У Laravel 11+ базовий контролер порожній, тож $this->authorize() доступний лише з трейтом AuthorizesRequests.
Що важливо незалежно від способу: перевірка має бути на сервері для кожної дії. Схована кнопка в Blade чи Livewire не захищає - запит можна відправити напряму. І в Livewire публічний метод компонента - теж ендпойнт, який перевіряє права сам.
Через перевірку, що виконується перед усіма іншими.
Для всього застосунку - Gate::before():
// AppServiceProvider::boot()
Gate::before(function (User $user, string $ability) {
return $user->isSuperAdmin() ? true : null;
});
Для однієї політики - метод before():
public function before(User $user, string $ability): ?bool
{
return $user->isSuperAdmin() ? true : null;
}
Головне - повертати null, а не false, для всіх, хто не суперадмін. null означає «не вирішую, перевіряй далі», а false заборонив би дію всім іншим, не дійшовши до політики.
Gate::after() - навпаки, спрацьовує після перевірок і впливає на результат, лише якщо гейт чи політика повернули null. Зручно для правила за замовчуванням.
Обережно з «усім можна»:
- суперадмін через
beforeобійде навіть правила, які мали б діяти для всіх, - наприклад, «не можна видалити оплачене замовлення»; такі бізнес-обмеження краще перевіряти окремо від прав; - дії суперадміна варто журналювати;
- для гнучкої моделі ролей і прав зазвичай беруть пакет на кшталт spatie/laravel-permission, а
beforeлишають для однієї надролі.
Scopes інкапсулюють часто вживані умови запитів.
Local scope - викликається вручну:
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true);
}
Post::published()->latest()->get();
Global scope - застосовується автоматично до всіх запитів моделі:
#[ScopedBy([TenantScope::class])]
class Invoice extends Model {}
SoftDeletes - приклад глобального scope (автоматично додає where deleted_at is null). Глобальний scope можна обійти через withoutGlobalScope().
Планувальник дозволяє описати періодичні завдання прямо в коді. У Laravel 11+ розклад визначається в routes/console.php через фасад Schedule:
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:send')->dailyAt('08:00');
Schedule::job(new PruneLogs)->weekly();
Schedule::call(fn () => Cache::flush())->hourly();
На сервері потрібен лише один cron-запис, що щохвилини викликає планувальник:
* * * * * cd /app && php artisan schedule:run >> /dev/null 2>&1
Корисні модифікатори: withoutOverlapping(), onOneServer(), runInBackground().
Розклад описують у routes/console.php ланцюжком методів:
Schedule::command('reports:send')
->weekdays()
->at('9:00')
->timezone('Europe/Kyiv');
Schedule::command('cache:prune-stale-tags')->hourly();
Schedule::job(new SyncRates)->everyFifteenMinutes()->between('8:00', '20:00');
Schedule::command('backup:run')->dailyAt('3:30')->environments(['production']);
Schedule::command('invoices:remind')->daily()->when(fn () => Setting::get('reminders_on'));
Групи методів:
- частота:
everyMinute(),everyFiveMinutes(),hourly(),daily(),weekly(),monthlyOn(1, '8:00'),cron('0 */6 * * *'); навітьeverySecond(); - дні:
weekdays(),weekends(),mondays(),days([1, 3]); - час:
between(),unlessBetween(); - умови:
when(),skip(),environments(); - пояс:
timezone()для завдання чиschedule_timezoneуconfig/app.phpдля всіх.
Пастка з поясом: якщо час сервера UTC, а бізнес у Києві, dailyAt('9:00') без поясу спрацює о 11 чи 12 за київським. З переходом на літній час завдання в проміжку 3:00-4:00 можуть пропуститися чи виконатися двічі - критичні запускають поза ним.
php artisan schedule:list показує, коли кожне завдання виконається наступного разу.
Дві проблеми: наступний запуск того самого завдання стартує, поки попереднє ще працює, і довге завдання затримує інші, заплановані на ту саму хвилину.
Накладання - withoutOverlapping():
Schedule::command('import:products')
->everyFiveMinutes()
->withoutOverlapping(30); // блокування на 30 хв на випадок аварії
Поки попередній запуск триває, новий пропускається. Блокування живе в кеші: якщо процес упав, воно спаде за вказаний час (за замовчуванням - 24 години, тож розумний ліміт варто задати).
Затримка інших - runInBackground():
Schedule::command('analytics:report')->daily()->runInBackground();
Завдання на той самий час виконуються послідовно; фонове не змушує наступні чекати. Працює для command() і exec().
Кращий шлях для важкого - щоб планувальник лише ставив роботу в чергу:
Schedule::job(new ImportProducts)->everyFiveMinutes();
Тоді планувальник звільняється миттєво, а повтори, таймаути й паралельність бере на себе черга (ShouldBeUnique проти дублів).
На кількох серверах до цього додається onOneServer() - зі спільним кешем.
Ключ data. Ресурс, повернений з контролера, загортається в об'єкт з ключем data:
{ "data": { "id": 1, "name": "Olena" } }
Обгортка дає місце для метаданих поруч з даними і захищає від вразливості старих браузерів з JSON-масивом на верхньому рівні.
public static $wrap = 'user'; // власний ключ для цього ресурсу
JsonResource::withoutWrapping(); // вимкнути глобально (AppServiceProvider)
withoutWrapping() не діє на пагіновані відповіді: їм data потрібен, бо поруч ідуть links і meta.
Метадані верхнього рівня:
// у класі ресурсу - щоразу, коли ресурс є кореневим
public function with(Request $request): array
{
return ['meta' => ['api_version' => '2026-10']];
}
// разово, з контролера
return UserResource::make($user)->additional(['meta' => ['cached' => false]]);
with() додається лише для кореневого ресурсу, не для вкладених.
Колекції:
return UserResource::collection(User::paginate(20));
Пагінована колекція автоматично отримує links (first, last, prev, next) і meta (current_page, total...). Для простої колекції - лише data.
Власний клас колекції - коли самій колекції потрібна логіка:
final class UserCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'summary' => ['active' => $this->collection->where('active', true)->count()],
];
}
}
Керування HTTP-відповіддю:
return UserResource::make($user)
->response()
->setStatusCode(201)
->header('Location', route('users.show', $user));
Або в ресурсі - withResponse(Request $request, JsonResponse $response) для заголовків, що потрібні щоразу.
Типові помилки:
- подвійна обгортка:
toArray()повертає['data' => [...]]- отримаєтеdata.data; - ключі колекції: за замовчуванням вони перенумеровуються; щоб зберегти, -
public $preserveKeys = true; - формат для клієнтів - це контракт: вимкнення обгортки чи зміна
$wrapна робочому API ламає всіх клієнтів, тож такі рішення приймають до першого релізу.
Через умовні методи ресурсу - вони не роблять запитів самі, а лише перевіряють, що вже завантажено.
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => new UserResource($this->whenLoaded('author')),
'comments_count' => $this->whenCounted('comments'),
'is_featured' => $this->when($request->user()?->isEditor(), $this->is_featured),
$this->mergeWhen($request->user()?->isAdmin(), [
'internal_notes' => $this->internal_notes,
]),
];
}
whenLoaded('author')- поле з'явиться, лише якщо контролер зробивwith('author');whenCounted('comments')- післяwithCount('comments');when($condition, $value)- поле зникає з відповіді зовсім, якщо умова хибна;mergeWhen()- кілька полів за однією умовою.
Навіщо так: контролер вирішує, що завантажити для конкретного ендпойнта, а ресурс просто відображає. Без whenLoaded звернення $this->author в ресурсі робить запит на кожен елемент колекції - класичне N+1.
Права й приховані поля: when() з перевіркою ролі не дає віддати внутрішні поля звичайному клієнту - корисніше, ніж $hidden у моделі, бо залежить від того, хто питає.
JsonApiResource формує відповіді за специфікацією JSON:API, тож структуру не треба писати вручну.
php artisan make:resource PostResource --json-api
class PostResource extends JsonApiResource
{
public $attributes = ['title', 'body', 'created_at'];
public $relationships = ['author', 'comments'];
}
Що ресурс робить сам:
- структура
dataзtype,id,attributes,relationships; includedдля запитаних зв'язків - кожен об'єкт один раз, навіть якщо на нього посилаються кілька записів;- розріджені набори полів:
?fields[posts]=title,created_at- лише потрібні атрибути; - включення:
?include=author- зв'язки потрапляють у відповідь лише на запит; - заголовок
Content-Type: application/vnd.api+json.
Що він не робить: не розбирає фільтри й сортування з запиту - для цього документація радить spatie/laravel-query-builder.
Коли обирати: коли клієнти (мобільні застосунки, сторонні інтеграції, фронтенд з бібліотекою під JSON:API) виграють від стандартного формату й керування полями. Для внутрішнього API одного фронтенду звичайні ресурси простіші.
Пам'ятати про N+1: include з запиту має відповідати жадібному завантаженню в контролері; includePreviouslyLoadedRelationships() віддає вже завантажені зв'язки й без параметра.
Broadcasting транслює серверні події на клієнт через WebSockets - для оновлень у реальному часі (чати, нотифікації).
class MessageSent implements ShouldBroadcast
{
public function broadcastOn(): array
{
return [new PrivateChannel('chat.'.$this->roomId)];
}
}
На клієнті Laravel Echo підписується на канал:
Echo.private(`chat.${roomId}`)
.listen('MessageSent', (e) => console.log(e.message));
Сервер WebSockets - Laravel Reverb (офіційний), Pusher або Soketi. Канали бувають public, private (з авторизацією) і presence (зі списком учасників).
FormRequest - окремий клас, що містить правила валідації та авторизацію, виносячи їх із контролера.
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return ['title' => ['required', 'max:255']];
}
}
// $request - вже провалідовано
public function store(StorePostRequest $request)
{
Post::create($request->validated());
}
Переваги: тонкі контролери, перевикористання правил, метод prepareForValidation() для нормалізації вводу, власні повідомлення в messages().
Питання з реальних технічних співбесід - 378 питань у 43 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Eloquent 31 Архітектура 19 Тестування 14 Черги 14 Продуктивність 12 Безпека 11 Автентифікація 10 Бази даних 10
Готуєтесь до співбесіди не просто так: зараз на сайті 147 відкритих вакансій Laravel і PHP. Переглянути вакансії