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

Питання на співбесіді рівня Middle

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

57 питань

Sanctum дає два режими:

1. API-токени - модель випускає токен, який клієнт шле в заголовку Authorization: Bearer ...:

$token = $user->createToken('mobile', ['posts:read'])->plainTextToken;

Route::middleware('auth:sanctum')->get('/user', fn (Request $r) => $r->user());

2. SPA-автентифікація - для односторінкових застосунків на тому ж домені використовує звичайні сесійні cookie + CSRF (без зберігання токенів у JS, що безпечніше).

Токени підтримують abilities (scopes): $user->tokenCan('posts:read'). Трейт HasApiTokens додає tokens()-зв'язок і createToken().

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

Notification - одне повідомлення, яке можна доставити кількома каналами одночасно.

class InvoicePaid extends Notification
{
    public function via(object $notifiable): array
    {
        return ['mail', 'database', 'broadcast'];
    }

    public function toMail($notifiable): MailMessage { /* ... */ }
    public function toArray($notifiable): array { /* для database */ }
}

$user->notify(new InvoicePaid($invoice));

Канали з коробки: mail, database (зберігає в notifications), broadcast (WebSockets), Vonage (SMS), Slack. Є community-канали (Telegram, push). Канал database зручний для «дзвіночка» сповіщень у UI; реалізувавши ShouldQueue, відправку виносять у чергу.

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

«Тонкий контролер» - це той, що лише координує: приймає запит, викликає потрібне й повертає відповідь. Усе інше живе деінде.

Що виносять із контролера і куди:

Що Куди
Правила валідації Form Request
Перевірка прав Policy або authorize() у Form Request
Бізнес-логіка Service чи Action-клас
Складна вибірка Локальний скоп на моделі
Формат відповіді API Resource
Побічні ефекти (лист, індексація) Слухач події або джоба в черзі

Було:

public function store(Request $request)
{
    $data = $request->validate([...]);          // валідація

    if (! $request->user()->can('create', Post::class)) {   // права
        abort(403);
    }

    $post = Post::create($data);                 // логіка
    $post->tags()->sync($data['tags']);
    Mail::to($post->author)->send(new PostCreated($post));  // побічний ефект

    return response()->json([                     // формат
        'id' => $post->id,
        'title' => $post->title,
    ]);
}

Стало:

public function store(StorePostRequest $request, CreatePost $createPost)
{
    return new PostResource($createPost->handle($request->validated()));
}

Валідація й права спрацювали до входу в метод, логіка з побічними ефектами - усередині CreatePost, формат - у ресурсі.

Навіщо це насправді. Не заради краси: логіку з контролера не викликати ні з Artisan-команди, ні з черги, ні з тесту без HTTP-запиту. Щойно та сама операція знадобилась у другому місці, тонкий контролер перестає бути питанням смаку.

Де межа. Post::create($request->validated()) не потребує ні сервісу, ні ресурсу - шар заради шару лише додає файлів. Виносьте, коли з'явилася друга причина: кілька моделей в одній операції, виклик не з HTTP або контролер перестав вміщатися на екран.

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

LazyCollection використовує PHP-генератори, щоб тримати в пам'яті лише один елемент за раз - критично для величезних наборів.

LazyCollection::make(function () {
    $handle = fopen('huge.csv', 'r');
    while (($line = fgets($handle)) !== false) {
        yield $line;
    }
})->filter(...)->take(100)->each(...);

З Eloquent:

User::cursor()->each(function ($user) { /* по одному рядку */ });

На відміну від звичайної Collection, методи не матеріалізують увесь набір - обчислення «ліниві» й виконуються лише при ітерації.

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

Custom Cast інкапсулює логіку перетворення атрибута між форматом БД та об'єктом PHP.

class Money implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): MoneyValue
    {
        return new MoneyValue($value); // з БД → Value Object
    }

    public function set($model, $key, $value, $attributes): array
    {
        return ['price' => $value->cents]; // VO → у БД
    }
}

protected $casts = ['price' => Money::class];

Застосування: робота з Value Objects, шифрування полів, JSON-структури. Вбудовані касти: array, encrypted, datetime, enum-класи, AsCollection.

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

Backed enum кастується в моделі, і колонка починає повертати обʼєкт замість рядка:

enum VacancyLevel: string
{
    case Junior = 'junior';
    case Middle = 'middle';
    case Senior = 'senior';

    public function label(): string
    {
        return match ($this) {
            self::Junior => 'Junior',
            self::Middle => 'Middle',
            self::Senior => 'Senior',
        };
    }
}

class Vacancy extends Model
{
    protected function casts(): array
    {
        return ['level' => VacancyLevel::class];
    }
}

Тепер $vacancy->level - це enum, а не рядок:

$vacancy->level->label();
$vacancy->level === VacancyLevel::Senior;
$vacancy->update(['level' => VacancyLevel::Middle]);   // у базу піде 'middle'

Переваги над константами класу:

  • Обмежена множина. Значення поза переліком не існує, тоді як константа не заважає передати будь-який рядок.
  • Типізація. function assign(VacancyLevel $level) не прийме випадковий рядок, і редактор підкаже варіанти.
  • Поведінка поруч зі значенням. Enum має методи, тож підпис, колір чи іконка живуть там само, а не в розкиданих match по шаблонах.
  • match без default підсвітить пропущений випадок, коли додасте новий кейс.

Дві практичні деталі. У валідації є правило Rule::enum(VacancyLevel::class). А tryFrom() повертає null замість винятку - саме він потрібен, коли значення приходить від користувача чи з URL.

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

Laravel має тестування з коробки поверх PHPUnit; популярна надбудова - Pest із лаконічним синтаксисом.

it('creates a post', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/posts', [
        'title' => 'Hello',
    ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('posts', ['title' => 'Hello']);
});
  • Feature-тести перевіряють HTTP-флоу (більшість тестів); Unit - окремі класи ізольовано.
  • Трейт RefreshDatabase ізолює тести, відкочуючи зміни після кожного; дані генерують фабрики.
  • actingAs($user) автентифікує користувача для захищених маршрутів.

Корисні асерції: assertStatus, assertSee, assertJson, assertDatabaseHas, assertRedirect, assertAuthenticated.

Мокінг - ізоляція від зовнішніх ефектів (API, листи, черги):

Mail::fake();         Mail::assertSent(OrderShipped::class);
Queue::fake();        Queue::assertPushed(ProcessPodcast::class);
Notification::fake(); Http::fake();

// мок сервісу через контейнер
$this->mock(PaymentGateway::class)
    ->shouldReceive('charge')->once()->andReturn(true);

Запуск: php artisan test, --filter=PostTest, --parallel (швидше).

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

Тест не повинен слати справжні листи - це повільно, ненадійно й іноді доходить до реальних людей.

Mail::fake() підміняє транспорт і запамʼятовує, що мало піти:

it('sends a welcome letter on registration', function () {
    Mail::fake();

    $this->post('/register', [
        'email' => 'dev@example.com',
        'password' => 'password',
    ]);

    Mail::assertSent(WelcomeMail::class, function (WelcomeMail $mail) {
        return $mail->hasTo('dev@example.com');
    });
});

Є ще assertNotSent(), assertNothingSent() і assertSentCount().

Важлива пастка: якщо лист відправляється через сповіщення ($user->notify(...)), Mail::fake() його не побачить - потрібен Notification::fake() і assertSentTo(). Плутанина між цими двома - найчастіша причина «тест не бачить листа, хоча він точно йде».

Друга пастка: якщо mailable реалізує ShouldQueue, а в тесті стоїть Queue::fake(), лист не дійде до пошти взагалі - перевіряти треба постановку завдання.

Поза тестами для перегляду верстки зручні два інструменти. Драйвер log пише лист у storage/logs, а Mailpit чи Mailtrap ловлять пошту в локальний ящик - листи виглядають як справжні, але нікуди не йдуть.

Ще одна дрібниця: mailable можна відкрити прямо в браузері, повернувши його з маршруту - зручно для правки шаблону без повторних відправлень.

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

Їх часто плутають, бо обидва створюють записи. Різниця в тому, що саме вони створюють.

Фабрика описує, як виглядає один правдоподібний запис:

class VacancyFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => fake()->jobTitle(),
            'level' => fake()->randomElement(VacancyLevel::cases()),
            'is_published' => true,
        ];
    }

    public function draft(): static
    {
        return $this->state(fn () => ['is_published' => false]);
    }
}

Використовується переважно в тестах, де потрібен запис із конкретною властивістю:

$vacancy = Vacancy::factory()->draft()->create();

Сідер заповнює базу набором даних - і зазвичай викликає фабрики:

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call(RolesSeeder::class);       // довідник: конкретні значення
        Vacancy::factory()->count(50)->create(); // демо-дані: випадкові
    }
}

Коли що потрібне:

  • Довідники - ролі, категорії, статуси, налаштування. Це реальні дані застосунку, у них немає випадковості, і вони мають бути ідемпотентними: firstOrCreate() або updateOrCreate(), щоб повторний запуск не плодив дублів.
  • Демо-дані для локальної розробки - фабрики всередині сідера.
  • Тести - фабрики напряму, без сідера: кожен тест створює рівно те, що перевіряє.

Головне правило: сідер із довідником має бути безпечним для повторного запуску, бо його виконують і на проді. Сідер з демо-даними на прод не потрапляє взагалі.

Докладніше в документації: Фабрики моделей

Підзапит - запит, вкладений в інший. Eloquent дозволяє вставляти їх у select, where, orderBy.

// додати останню дату входу кожного користувача одним запитом
User::addSelect(['last_login_at' => Login::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
    ->limit(1),
])->get();

// сортування за підзапитом
Destination::orderByDesc(
    Flight::select('arrived_at')->whereColumn('destination_id', 'destinations.id')->latest()->limit(1)
)->get();

Підзапити допомагають уникнути N+1 і зайвих операцій JOIN, обчислюючи похідні значення в межах одного запиту.

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

Scout - це загальний інтерфейс до пошукових рушіїв: модель позначається трейтом, а рушій (Meilisearch, Algolia, Typesense, база) підмінюється конфігурацією.

class Vacancy extends Model
{
    use Searchable;

    /**
     * @return array<string, mixed>
     */
    public function toSearchableArray(): array
    {
        return [
            'title' => $this->title,
            'description' => $this->description,
            'company' => $this->company?->name,
        ];
    }
}

Vacancy::search('laravel middle')->paginate(15);

Індексація відбувається автоматично на збереженні моделі, а масово - через php artisan scout:import.

Коли вистачить LIKE:

  • Пошук по одній-двох колонках, записів - тисячі.
  • Достатньо точного входження, без урахування словоформ.
  • Не хочеться ще одного сервісу в інфраструктурі.
Vacancy::where('title', 'like', "%{$term}%")->get();

Де LIKE перестає працювати:

  • Не використовує індекс із % на початку - на великій таблиці це повний перебір.
  • Не знає словоформ. «розробник» не знайдеться за запитом «розробники», і жодних синонімів.
  • Не ранжує. Збіг у заголовку та згадка в кінці опису однакові за вагою.
  • Не прощає помилок - один зайвий символ, і результат порожній.
  • Не шукає по кількох сутностях одразу.

Проміжний варіант - повнотекстовий індекс самої бази: whereFullText() у MySQL, tsvector у PostgreSQL. Він знімає перші три пункти без окремого сервісу, хоча за якістю ранжування поступається спеціалізованим рушіям.

Практичне правило: починайте з LIKE, переходьте на повнотекстовий індекс, коли обсяг виріс, і на Scout - коли потрібні релевантність, помилки в запиті й фасети.

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

php artisan route:cache компілює всі маршрути в один файл, тож фреймворк не реєструє їх заново на кожному запиті - це помітно пришвидшує бутстрап на застосунках із сотнями маршрутів.

php artisan route:cache    # на деплої
php artisan route:clear    # скинути

Лише для продакшену. Обмеження: маршрути на основі замикань кешувати не можна - потрібні контролери. Команда php artisan optimize кешує маршрути, конфіг та події разом.

Докладніше в документації: Оптимізація: Route Caching

Коротка відповідь: після php artisan config:cache функція env() поза конфігами повертає null.

Чому. Кешування конфігурації зберігає підсумковий масив у один PHP-файл. Файл .env після цього взагалі не читається - у проді його може й не бути. Значення, зчитані під час збирання кешу, всередині конфігів залишаються; будь-який виклик env() в іншому місці виконується вже без завантаженого середовища.

Як ламається:

class WeatherClient
{
    public function __construct()
    {
        // У проді після config:cache тут null.
        $this->key = env('WEATHER_KEY');
    }
}

Найгірше, що локально все працює - кеш конфігурації зазвичай збирають лише в проді.

Правильно: env() живе тільки у файлах config/, а код читає конфіг.

// config/services.php
'weather' => [
    'key' => env('WEATHER_KEY'),
],

// будь-де в застосунку
$this->key = config('services.weather.key');

Побічна вигода: конфіг можна підмінити в тестах, а .env - ні.

config(['services.weather.key' => 'test-key']);

Виняток - файли, що виконуються до завантаження застосунку (bootstrap/), і сам AppServiceProvider тут не виняток: у ньому теж потрібен config().

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

php artisan optimize виконує пакет кешувань, які прибирають повторну роботу з кожного запиту:

php artisan config:cache    # усі config/*.php в один масив
php artisan route:cache     # маршрути в серіалізований вигляд
php artisan view:cache      # прекомпіляція Blade
php artisan event:cache     # мапа подій і слухачів

Зворотна дія - php artisan optimize:clear.

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

Коли шкодить:

  • Локально під час розробки. Закешований конфіг не помічає змін у .env, а закешовані маршрути - нових маршрутів. Півгодини пошуку помилки, якої немає.
  • route:cache не працює із замиканнями в маршрутах: команда впаде з помилкою. Маршрути мають вказувати на контролери.
  • env() поза конфігами перестає працювати - див. окреме питання про це.
  • Якщо конфіг залежить від запиту. Кеш збирається один раз під час деплою, тож щось на кшталт мультитенантності за доменом у конфігу зафіксується у стані збирання.

Порядок під час деплою має значення: спершу викласти новий код, потім кешувати. Якщо кешувати до оновлення файлів, у кеш потрапить попередня версія.

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

Згенерувати клас, що реалізує ValidationRule:

php artisan make:rule Uppercase
class Uppercase implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (strtoupper($value) !== $value) {
            $fail('Поле :attribute має бути у верхньому регістрі.');
        }
    }
}

$request->validate(['code' => [new Uppercase]]);

Для разових перевірок можна передати замикання прямо в правило: 'field' => [fn ($attr, $value, $fail) => ...].

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

Вакансії Laravel рівня Middle

Усі вакансії Laravel Middle

Full Stack Developer (PHP, React, Middle, Middle+)

Full Stack розробник для підтримки та розвитку аналітичного продукту перевірки контрагентів. Робота зі складною бізнес-логікою, базами даних та інтеграцією AI-рішень. Стек: PHP 8.x (Laravel/Symfony), React, MySQL, REST API. Вимоги: 3+ років комерційного досвіду, глибоке розуміння SQL, Git, Docker, CI/CD, Linux, OWASP.

Full Stack Developer (PHP / React) Middle / Middle+

Full Stack розробник для аналітичного продукту компанії з перевірки контрагентів та оцінки ризиків. Основна робота: підтримка та розвиток існуючої системи, рефакторинг legacy-коду, розробка бекенду на PHP/Laravel/Symfony і фронтенду на React, оптимізація баз даних MySQL та SQL-запитів, інтеграція AI-сервісів. Вимоги: 3+ років комерційної розробки, PHP 8.x, Laravel або Symfony, React, REST API, Docker, Git, Linux. Англійська B1+.

Програміст PHP (інтерн)

Вакансія на посаду інтерна PHP розробника для початківців з теоретичною базою ООП та базовими знаннями PHP. Потрібні навички Git/GitHub, власні проєкти. Стажування в офісі під керівництвом менторів з перспективою переходу на посаду Junior разробника.

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

Інші рівні
Junior 52 Senior 62