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

Питання

Найпопулярніші питання з реальних Laravel/PHP співбесід для всіх рівнів

171 питань

  • Interface - це контракт: перелік методів, які клас зобов'язаний реалізувати. Не містить реалізації.
  • Trait - механізм повторного використання коду: набір готових методів, які «вмішуються» в клас (горизонтальне перевикористання).
interface Loggable { public function logChannel(): string; }

// реалізація для багатьох моделей
trait HasUuid
{
    public static function bootHasUuid(): void { /* ... */ }
}

class Order extends Model implements Loggable
{
    use HasUuid;
    public function logChannel(): string { return 'orders'; }
}

У Laravel трейти всюди: SoftDeletes, HasFactory, Notifiable. Інтерфейси («контракти») дають змогу підміняти реалізації через контейнер.

Поліморфний зв'язок дозволяє моделі належати кільком різним типам моделей через один зв'язок.

class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Таблиця comments має commentable_id + commentable_type. Тож Comment може належати і Post, і Video без окремих таблиць. Бувають також many-to-many поліморфні зв'язки (morphToMany), напр. теги.

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

Єдиний API поверх драйверів для зберігання результатів важких обчислень чи запитів. Драйвери: database (за замовчуванням у нових застосунках), file, redis, memcached, dynamodb, array (для тестів). Задається через CACHE_STORE у .env.

remember - найпоширеніший патерн (дістати з кешу або обчислити й закешувати):

$users = Cache::remember('active_users', 3600, function () {
    return User::where('active', true)->get();
});

Cache::put('key', $value, now()->addMinutes(10));
$value = Cache::get('key', 'default');
Cache::forget('key');

Теговане кешування (лише Redis/Memcached) - для групового скидання:

Cache::tags(['posts'])->put('post.1', $post, 600);
Cache::tags(['posts'])->flush(); // скинути всю групу

Атомарні блокування проти гонок (один процес у критичній секції):

Cache::lock('processing', 10)->get(function () {
    // критична секція
});

Найскладніше - інвалідація: кеш скидають у подіях/обзерверах моделей при зміні даних. Застарілий кеш часто гірший за його відсутність.

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

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)

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

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

API Resource - шар трансформації між Eloquent-моделлю та JSON-відповіддю. Дає повний контроль над структурою API, відв'язуючи її від схеми БД.

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => UserResource::make($this->whenLoaded('author')),
            'createdAt' => $this->created_at->toIso8601String(),
        ];
    }
}

return PostResource::collection($posts);
  • whenLoaded() додає зв'язок лише якщо він eager-завантажений (без N+1).
  • Resource Collections дозволяють додавати метадані (meta, links).

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

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 валідація

Класична скарга: користувач вибрав фільтри, перейшов на другу сторінку - і фільтри злетіли. Причина в тому, що посилання пагінації за замовчуванням несуть лише page.

Додати поточні параметри:

$posts = Post::filter($request)->paginate(15)->withQueryString();

withQueryString() переносить усі параметри рядка запиту в посилання пагінації. Якщо потрібні конкретні - appends():

$posts->appends(['sort' => $request->input('sort')]);

Друга частина проблеми - сортування без унікального ключа. Якщо сортувати за колонкою з повторами, рядки з однаковим значенням база може віддати в різному порядку на різних сторінках - і один запис зʼявиться двічі, а інший зникне:

// хитко: багато записів з однаковою датою
Post::orderByDesc('published_at')->paginate(15);

// стабільно: дозволяємо ключем
Post::orderByDesc('published_at')->orderByDesc('id')->paginate(15);

Третя - сторінка поза межами. Після зміни фільтра запис може стати менше, ніж потрібно для сторінки 7, і користувач бачить порожньо. Скидання номера сторінки при зміні фільтра вирішує це; у Livewire для того є resetPage().

Для SEO варто памʼятати ще про одне: кожна комбінація фільтрів із номером сторінки - окрема URL. Якщо їх багато, сторінки з фільтрами зазвичай закривають від індексації, лишаючи в ній базовий список.

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

Валідацію можна писати просто в контролері:

public function store(Request $request)
{
    $data = $request->validate([
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required'],
    ]);
}

Для двох правил цього досить. Коли їх десяток, а та сама форма ще й редагується, контролер розпухає, а правила дублюються між store() і update().

Form Request виносить це в окремий клас:

php artisan make:request StorePostRequest
class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Post::class);
    }

    /**
     * @return array<string, list<string>>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'published_at' => ['nullable', 'date'],
        ];
    }

    public function messages(): array
    {
        return ['title.required' => 'Вкажіть заголовок вакансії.'];
    }
}

Використання - тип у сигнатурі:

public function store(StorePostRequest $request)
{
    // сюди виконання дійде лише з валідними даними
    $post = Post::create($request->validated());
}

Що це дає: валідація й перевірка прав відбуваються до входу в метод; контролер лишається про свою справу; правила лежать в одному місці й перевикористовуються; prepareForValidation() дозволяє нормалізувати вхід (обрізати пробіли, привести формат) до перевірки.

Деталь, яку часто пропускають: authorize(), що повертає false, дає 403 - тобто Form Request закриває і валідацію, і доступ.

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

Транзакція гарантує атомарність: або всі операції виконуються, або жодна.

DB::transaction(function () use ($order) {
    $order->save();
    $order->items()->createMany($items);
    Inventory::decrement($order->product_id, $order->qty);
});

При винятку всередині замикання Laravel автоматично робить rollBack(). Ручний контроль:

DB::beginTransaction();
try {
    // ...
    DB::commit();
} catch (Throwable $e) {
    DB::rollBack();
    throw $e;
}

Другий аргумент transaction($cb, 3) задає кількість повторів при deadlock.

Докладніше в документації: Транзакції БД

migrate:rollback відкочує останній «батч» міграцій, викликаючи їхні методи down():

php artisan migrate:rollback              # останній батч
php artisan migrate:rollback --step=1     # рівно одну міграцію

migrate:reset відкочує всі міграції по черзі. migrate:refresh - відкочує все й накатує заново. migrate:fresh - видаляє всі таблиці й накатує міграції з нуля, взагалі не заглядаючи в down().

Що з цього небезпечне в проді: усі чотири. Кожна знищує дані, а migrate:fresh робить це найшвидше й без шансу на down(). У проді припустимий лише php artisan migrate.

Laravel сам питає підтвердження в продакшн-середовищі, і саме тому --force не варто вписувати в скрипти «щоб не заважало».

Практика, що рятує:

  • Перевіряйте відкат локально одразу після написання: migratemigrate:rollback --step=1migrate. Половина міграцій має неробочий down(), і виявляється це в найгірший момент.
  • Пишіть down() чесно, а якщо відкат неможливий - хай кидає виняток, це краще за мовчазну порожню реалізацію.
  • Не редагуйте вже застосовану міграцію - додавайте нову. Виправлений файл не перезастосується там, де він уже відпрацював.
  • Видалення колонки й перейменування - окремими релізами від коду, що їх читає, інакше деплой ламає працюючий застосунок у проміжку.

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

Dependency Injection - патерн, за якого клас отримує залежності ззовні (зазвичай через конструктор), а не створює їх сам. Це знижує зв'язування й полегшує тестування (можна підсунути мок).

class OrderController
{
    public function __construct(
        private PaymentGateway $gateway, // інжектується контейнером
    ) {}
}

У Laravel DI працює «з коробки»: контейнер читає type-hints і автоматично будує граф залежностей. Інжектити можна й у методи контролера (method injection), зокрема сам Request.

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

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

Рівні
Junior 52 Middle 57 Senior 62

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