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

Питання на співбесіді з 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) після відправлення відповіді, тож окрема черга для цього не потрібна.

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

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, очищення пов'язаних ресурсів, аудиту.

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

  • 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 при відмові.

Докладніше в документації: Авторизація (Gates та Policies)

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().

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

Планувальник дозволяє описати періодичні завдання прямо в коді. У 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 ламає всіх клієнтів, тож такі рішення приймають до першого релізу.

Докладніше в документації: 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() віддає вже завантажені зв'язки й без параметра.

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

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 (зі списком учасників).

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

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().

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

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

Рівні
Junior 101 Middle 147 Senior 130

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