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

Питання на співбесіді з Laravel

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

378 питань

Усе підключається в методі boot сервіс-провайдера пакета:

public function boot(): void
{
    $this->loadRoutesFrom(__DIR__.'/../routes/web.php');
    $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
    $this->loadViewsFrom(__DIR__.'/../resources/views', 'invoices');
    $this->loadTranslationsFrom(__DIR__.'/../lang', 'invoices');
    $this->loadJsonTranslationsFrom(__DIR__.'/../lang');

    if ($this->app->runningInConsole()) {
        $this->commands([InstallCommand::class]);
    }
}

Використання з простором імен пакета:

return view('invoices::pdf', ['invoice' => $invoice]);
__('invoices::messages.paid');
<x-invoices::status-badge :status="$invoice->status" />

Як застосунок перевизначає представлення без форку пакета: loadViewsFrom реєструє два шляхи - спершу resources/views/vendor/invoices застосунку, потім каталог пакета. Достатньо опублікувати чи створити файл з тим самим іменем:

$this->publishes([
    __DIR__.'/../resources/views' => resource_path('views/vendor/invoices'),
], 'invoices-views');

Переклади працюють так само: файли в lang/vendor/invoices застосунку мають пріоритет.

Особливості кожного ресурсу:

Ресурс На що зважати
маршрути loadRoutesFrom нічого не робить, якщо маршрути закешовано; дайте можливість вимкнути маршрути чи змінити префікс і middleware через конфігурацію
міграції loadMigrationsFrom запускає міграції пакета разом з міграціями застосунку - зручно, але застосунок не може їх змінити. Альтернатива - лише публікація
представлення ім'я простору - унікальне, щоб не зіткнутися з іншими пакетами
компоненти Blade::componentNamespace('Acme\\Invoices\\Views', 'invoices') чи анонімні компоненти з каталогу представлень
команди реєструвати лише в консолі - runningInConsole()

Маршрути пакета - найбільша зона конфліктів: пакет, що безумовно реєструє /login чи /admin, може перекрити маршрути застосунку. Гарна практика:

if (config('invoices.routes.enabled', true)) {
    Route::prefix(config('invoices.routes.prefix', 'invoices'))
        ->middleware(config('invoices.routes.middleware', ['web', 'auth']))
        ->group(__DIR__.'/../routes/web.php');
}

Міграції з моделями користувача: таблиці, що посилаються на users, мають враховувати, що модель користувача й тип ключа (int, UUID) у застосунку можуть бути іншими - краще брати їх із конфігурації.

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

Пакет не має власного застосунку - немає bootstrap/app.php, контейнера, конфігурації. Orchestra Testbench створює мінімальний Laravel-застосунок для тестів пакета.

composer require --dev orchestra/testbench pestphp/pest

Версії Testbench відповідають версіям Laravel:

Testbench Laravel
9.x 11.x
10.x 12.x
11.x 13.x

Базовий тест-кейс:

namespace Acme\Invoices\Tests;

use Acme\Invoices\InvoicesServiceProvider;
use Orchestra\Testbench\TestCase as Orchestra;

abstract class TestCase extends Orchestra
{
    protected function getPackageProviders($app): array
    {
        return [InvoicesServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('database.default', 'testing');
        $app['config']->set('invoices.currency', 'UAH');
    }

    protected function defineDatabaseMigrations(): void
    {
        $this->loadLaravelMigrations();
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
    }
}
// tests/Pest.php
uses(Acme\Invoices\Tests\TestCase::class)->in(__DIR__);

Тепер у тестах працює все, що й у застосунку: HTTP-тести маршрутів пакета, фабрики, Mail::fake(), artisan():

it('generates an invoice pdf', function () {
    $this->get('/invoices/1/pdf')->assertOk()->assertHeader('content-type', 'application/pdf');
});

it('publishes the config', function () {
    $this->artisan('vendor:publish', ['--tag' => 'invoices-config'])->assertSuccessful();
});

Workbench - для ручної перевірки: vendor/bin/testbench workbench:install створює каталог workbench/ з маршрутами й моделями, а vendor/bin/testbench serve запускає вебсервер з підключеним пакетом.

Що обов'язково протестувати в пакеті:

  • пакет працює без опублікованої конфігурації;
  • реєстрація провайдера не падає за відсутності необов'язкових залежностей;
  • міграції застосовуються й відкочуються;
  • поведінку на всіх підтримуваних версіях Laravel і PHP - матриця в CI (laravel: [12.*, 13.*] з відповідними версіями Testbench);
  • --prefer-lowest у CI - що пакет справді працює з мінімальними версіями, вказаними в composer.json.

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

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

Структурований вивід: агент описує JSON-схему відповіді, а провайдер змушує модель повернути дані саме в такій формі.

php artisan make:agent ReviewClassifier --structured
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;

class ReviewClassifier implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): string
    {
        return 'Класифікуй відгук покупця. Не вигадуй фактів, яких немає у тексті.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'sentiment' => $schema->string()->enum(['positive', 'neutral', 'negative'])->required(),
            'topics' => $schema->array()->items($schema->string())->required(),
            'needs_reply' => $schema->boolean()->required(),
            'score' => $schema->integer()->min(1)->max(5)->required(),
        ];
    }
}

Відповідь поводиться як масив:

$result = (new ReviewClassifier)->prompt($review->body);

$review->update([
    'sentiment' => $result['sentiment'],
    'needs_reply' => $result['needs_reply'],
]);

Що дає схема:

  • enum обмежує значення - у базу не потрапить "Positive!" замість positive;
  • типи й межі (integer, min, max) - менше перевірок у коді;
  • вкладені об'єкти й масиви об'єктів - для витягання списків (позиції з рахунку, контакти з листа).

Чого схема не гарантує:

  • правильності змісту: модель поверне валідний JSON з неправильною класифікацією чи вигаданим значенням. Схема перевіряє форму, а не правду;
  • однакової підтримки в усіх провайдерів - строгий режим відрізняється, і частина обмежень схеми може ігноруватися.

Тому дані від моделі - це вхідні дані користувача:

  • валідувати перед збереженням (Validator::make($result->toArray(), [...]) чи enum PHP з tryFrom);
  • не використовувати як SQL, шлях до файлу чи HTML без екранування;
  • для критичних рішень (повернення коштів, блокування) - людина в процесі.

Практичні поради:

  • описи полів (->description('...')) у схемі допомагають моделі так само, як інструкції;
  • низька температура (#[Temperature(0)]) для класифікації й витягання даних - стабільніші результати;
  • у тестах ReviewClassifier::fake([['sentiment' => 'negative', ...]]) повертає структуровану відповідь без звернення до API.

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

Інструмент - функція застосунку, яку модель може попросити викликати: знайти замовлення, перевірити залишок, створити заявку. Модель не виконує код сама - вона повертає «виклич інструмент X з аргументами Y», SDK виконує handle і передає результат назад моделі.

php artisan make:tool FindOrder
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;

class FindOrder implements Tool
{
    public function __construct(private User $user) {}

    public function description(): string
    {
        return 'Знаходить замовлення поточного користувача за номером і повертає статус доставки.';
    }

    public function schema(JsonSchema $schema): array
    {
        return ['number' => $schema->integer()->required()];
    }

    public function handle(Request $request): string
    {
        $order = $this->user->orders()->where('number', $request['number'])->first();

        return $order ? "Статус: {$order->status->label()}" : 'Замовлення не знайдено.';
    }
}
// в агенті
public function tools(): iterable
{
    return [new FindOrder($this->user)];
}

Головне правило безпеки: модель - недовірений учасник. Її аргументи можуть бути сформовані під впливом тексту від зловмисника (prompt injection у листі, відгуку, документі). Тому:

  • авторизація всередині інструмента: шукати замовлення через $this->user->orders(), а не Order::find($number) - інакше модель «знайде» чуже замовлення;
  • мінімальний набір інструментів: агенту підтримки не потрібен інструмент видалення;
  • валідація аргументів - схема описує форму, але межі й права перевіряє код;
  • обмеження кроків: #[MaxSteps(5)] - щоб модель не зациклилася у викликах і не спалила бюджет.

Схвалення людиною для незворотних дій: інструмент реалізує Approvable з трейтом InteractsWithApprovals - тоді виконання призупиняється, доки людина не підтвердить дію (повернення коштів, видалення, відправка листа). Для цього агент має зберігати розмову (RemembersConversations), щоб продовжити її після схвалення.

Опис - це інструкція для моделі: від description() і описів полів залежить, чи правильно модель обере інструмент. Пишіть, коли його використовувати і що він повертає.

Що повертати: короткий текст чи JSON лише з потрібними полями. Повертати модель цілком ($order->toJson()) - це зайві токени й ризик віддати моделі внутрішні дані (хеші, нотатки менеджерів), які потім потраплять у відповідь користувачу.

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

Реальні запити до LLM у тестах - повільні, платні, недетерміновані й потребують ключа API в CI. AI SDK має вбудовані підробки, як Mail::fake() чи Http::fake().

Підробка агента:

use App\Ai\Agents\SupportAssistant;
use Laravel\Ai\Prompts\AgentPrompt;

SupportAssistant::fake();                        // фіксована відповідь на будь-який промпт

SupportAssistant::fake(['Перша відповідь', 'Друга відповідь']);   // по черзі

SupportAssistant::fake(fn (AgentPrompt $prompt) => str_contains($prompt->prompt, 'повернення')
    ? 'Повернення можливе протягом 14 днів.'
    : 'Не можу допомогти з цим питанням.');

Структурований вивід - масиви як відповіді:

ReviewClassifier::fake([
    ['sentiment' => 'negative', 'topics' => ['delivery'], 'needs_reply' => true, 'score' => 2],
]);

Твердження:

SupportAssistant::assertPrompted(fn (AgentPrompt $prompt) => str_contains($prompt->prompt, '№1042'));
SupportAssistant::assertNotPrompted('...');
SupportAssistant::assertNeverPrompted();

// якщо агента запускали через ->queue()
ReviewClassifier::assertQueued(fn (AgentPrompt $prompt) => ...);

Інші можливості SDK мають свої підробки: Embeddings::fake(), Image::fake(), Audio::fake(), Transcription::fake(), Files::fake(), Stores::fake().

Що варто тестувати:

Що Як
що код реагує на відповідь моделі (зберігає, відповідає, ескалює) fake з різними відповідями, зокрема з неочікуваними
що в промпт потрапляють потрібні дані й не потрапляють зайві (персональні дані) assertPrompted з перевіркою тексту
інструменти агента окремо, як звичайні класи: виклик handle з різними аргументами й користувачами
обробка збоїв провайдера fake із замиканням, що кидає виняток

Що підробки не перевіряють: якість відповідей самої моделі. Для цього потрібні оцінювальні набори (evals) - окремий набір запитів з очікуваними властивостями відповіді, який запускається проти реальної моделі вручну чи за розкладом, а не в кожному прогоні тестів.

Захист від випадкових реальних запитів: у phpunit.xml задати порожні чи фіктивні ключі провайдерів - тест, який забув підробку, впаде на автентифікації, а не витратить гроші.

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

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

Перевірка для конкретного скопу:

Feature::for($user->team)->active('billing-v2');

Визначення з урахуванням скопу:

use App\Models\Team;

Feature::define('billing-v2', function (Team $team) {
    if ($team->created_at->isAfter('2026-01-01')) {
        return true; // нові команди одразу на новому білінгу
    }

    return Lottery::odds(1 / 100); // старі - поступово
});

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

Скоп за замовчуванням можна змінити, щоб не писати for() щоразу:

Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team);

Багатші значення - прапорець може повертати не лише булеве значення:

Feature::define('purchase-button', fn (User $user) => Arr::random([
    'blue-sapphire',
    'seafoam-green',
    'tart-orange',
]));

$color = Feature::value('purchase-button');
@feature('purchase-button', 'seafoam-green')
    <!-- варіант B -->
@endfeature

Так роблять A/B-тести: кожен користувач стабільно отримує свій варіант (значення зберігається), а аналітика порівнює конверсію.

Керування вручну:

Feature::for($team)->activate('billing-v2');
Feature::deactivate('billing-v2');          // для поточного скопу
Feature::activateForEveryone('billing-v2'); // оновити всі збережені значення

Гостьовий скоп: якщо користувач не автентифікований, скоп - null. Визначення має приймати nullable-тип (?User $user), інакше Pennant поверне false, не викликаючи замикання.

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

Проблема: щоб показувати помилки під полем одразу при введенні, правила валідації часто дублюють на фронтенді (Zod, Yup, VeeValidate). Тепер є два набори правил, які розходяться: unique:users,email на фронтенді взагалі не перевірити.

Precognition дозволяє фронтенду «запитати» сервер: «чи пройшли б ці дані валідацію?» - без виконання самої дії.

Як це працює:

  1. фронтенд надсилає запит на той самий маршрут зі спеціальним заголовком Precognition: true;
  2. middleware HandlePrecognitiveRequests запускає лише middleware маршруту й валідацію (Form Request), але не викликає контролер;
  3. відповідь - 204 (усе гаразд) або 422 з помилками;
  4. фронтенд показує помилки під полями.

Бекенд:

use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;

Route::post('/users', [UserController::class, 'store'])
    ->middleware([HandlePrecognitiveRequests::class]);

Правила лишаються в одному місці - у StoreUserRequest.

Фронтенд (Vue, React чи Alpine):

<script setup>
import { useForm } from 'laravel-precognition-vue';

const form = useForm('post', '/users', { name: '', email: '' });
</script>

<template>
    <input v-model="form.email" @change="form.validate('email')" />
    <div v-if="form.invalid('email')">{{ form.errors.email }}</div>
    <button :disabled="form.processing" @click="form.submit()">Зберегти</button>
</template>

validate('email') перевіряє лише це поле - помилки інших, ще не заповнених полів не показуються.

На що зважати:

  • побічні ефекти в middleware: прекогнітивний запит проходить через middleware маршруту. Middleware, що рахує взаємодії чи пише аудит, має пропускати такі запити: $request->isPrecognitive();
  • авторизація в authorize() Form Request теж виконується - це правильно, але враховуйте вартість;
  • навантаження: кожна зміна поля - запит до сервера. Для дорогих правил (зовнішні API) варто подумати, чи потрібна жива перевірка;
  • файли: за замовчуванням файли під час прекогнітивної валідації не надсилаються, щоб не завантажувати їх повторно.

Inertia 2.3+ має вбудовану підтримку Precognition у формах, тож окремий пакет там не потрібен.

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

Laravel Prompts - бібліотека гарних інтерактивних полів у терміналі: текст із плейсхолдером і валідацією, вибір зі списку, пошук, мультивибір, підтвердження, прогрес-бар, таблиці. Вона вбудована в Laravel і використовується в його власних командах (make:model, install:api).

Основні промпти:

use function Laravel\Prompts\{text, select, multiselect, confirm, search, spin};

$name = text(
    label: 'Назва проєкту',
    placeholder: 'acme-shop',
    required: true,
    validate: ['name' => 'alpha_dash|max:40'],
);

$plan = select('Тариф', ['free' => 'Free', 'pro' => 'Pro'], default: 'free');

$userId = search(
    label: 'Власник',
    options: fn (string $value) => User::where('email', 'like', "{$value}%")
        ->limit(10)->pluck('email', 'id')->all(),
);

$result = spin(fn () => $this->provision($name), 'Створюю середовище...');

Валідація приймає правила Laravel чи замикання, що повертає текст помилки. Користувач бачить помилку під полем і виправляє введене, а не отримує виняток наприкінці.

Форми - кілька промптів з можливістю повернутися на попередній крок:

$data = form()
    ->text('Ім\'я', required: true, name: 'name')
    ->password('Пароль', validate: ['password' => 'min:8'], name: 'password')
    ->confirm('Надіслати запрошення?', name: 'invite')
    ->submit();

Прогрес для довгих операцій:

progress(label: 'Імпорт', steps: $rows, callback: fn ($row) => $this->import($row));

Що важливо для команд, які запускають і люди, і скрипти:

  • неінтерактивний режим: у CI чи з --no-interaction промпти не можуть питати. Значення мають приходити з аргументів і опцій, а промпт - лише запасний варіант, коли аргумент не передано. Для цього в Laravel є інтерфейс PromptsForMissingInput;
  • Windows без WSL - Prompts автоматично переходить на запасну реалізацію Symfony Console;
  • тести: $this->artisan('project:create')->expectsQuestion('Назва проєкту', 'acme')->assertSuccessful() працює і з Prompts.

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

Канал присутності (presence channel) - приватний канал, який додатково знає, хто саме в ньому зараз. Ідеальний для чатів, спільного редагування, списку «зараз переглядають».

Авторизація відрізняється від звичайного приватного каналу: колбек повертає масив даних про користувача, а не true:

// routes/channels.php
Broadcast::channel('chat.{roomId}', function (User $user, int $roomId) {
    if ($user->canJoinRoom($roomId)) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});

Ці дані побачать усі учасники каналу - не повертайте туди email, телефони чи інші приватні поля.

Приєднання на фронтенді:

Echo.join(`chat.${roomId}`)
    .here((users) => { online.value = users; })          // хто вже в каналі
    .joining((user) => { online.value.push(user); })      // хтось прийшов
    .leaving((user) => { remove(user); })                 // хтось пішов
    .listen('NewMessage', (e) => { messages.value.push(e.message); });

Клієнтські події (whisper) - повідомлення від одного клієнта іншим без звернення до Laravel:

// відправник
Echo.private(`chat.${roomId}`).whisper('typing', { name: user.name });

// отримувачі
Echo.private(`chat.${roomId}`).listenForWhisper('typing', (e) => {
    showTyping(e.name);
});

Застосунок не бачить цих повідомлень, тому:

  • whisper - лише для ефемерних, неважливих сигналів: «набирає», курсор співрозмовника, «переглядає зараз»;
  • нічого, що має зберігатися чи перевірятися (повідомлення чату, зміни даних), через whisper не йде - тільки через HTTP-запит до застосунку, який валідує, зберігає й транслює подію;
  • whisper працює лише в приватних каналах і каналах присутності; у Pusher клієнтські події треба окремо увімкнути в налаштуваннях застосунку.

toOthers() - щоб відправник не отримав власну подію повторно:

broadcast(new NewMessage($message))->toOthers();

Echo автоматично додає заголовок X-Socket-ID до запитів, і Laravel виключає це з'єднання з отримувачів.

Нюанси: користувач з двома вкладками - два з'єднання, але в списку присутності він один (учасники об'єднуються за id). Події leaving приходять із затримкою при обриві мережі - сервер виявляє мертве з'єднання не миттєво.

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

Markdown-листи - шаблони з готовими компонентами, які Laravel перетворює на адаптивний HTML з вбудованими стилями й одночасно на текстову версію:

php artisan make:mail OrderShipped --markdown=mail.orders.shipped
<x-mail::message>
# Замовлення відправлено

Ваше замовлення №{{ $order->number }} вже в дорозі.

<x-mail::button :url="$trackingUrl">
Відстежити посилку
</x-mail::button>

<x-mail::table>
| Товар | Кількість |
|:------|----------:|
@foreach ($order->items as $item)
| {{ $item->name }} | {{ $item->quantity }} |
@endforeach
</x-mail::table>

Дякуємо,<br>{{ config('app.name') }}
</x-mail::message>

Компоненти й CSS можна опублікувати (vendor:publish --tag=laravel-mail) і змінити під свій бренд. Головна перевага - не потрібно вручну писати табличну верстку, яку вимагають поштові клієнти.

Листи в черзі. Надсилання через SMTP чи API провайдера займає сотні мілісекунд і може впасти. Користувач не має на це чекати:

Mail::to($user)->queue(new OrderShipped($order));
Mail::to($user)->later(now()->addMinutes(10), new OrderShipped($order));

Або завжди в черзі - інтерфейс ShouldQueue на класі, і тоді навіть send() ставить лист у чергу.

SerializesModels у черзі зберігає лише ідентифікатор моделі, а воркер перечитує модель з бази перед надсиланням.

Навіщо afterCommit:

DB::transaction(function () use ($order) {
    $order->update(['status' => 'shipped']);
    Mail::to($order->customer)->queue(new OrderShipped($order));
    // ... ще операції, які можуть впасти
});

Проблеми без нього:

  • воркер може взяти завдання до коміту транзакції - модель ще не оновлена чи взагалі не існує, і лист міститиме старі дані або завдання впаде з ModelNotFoundException;
  • якщо транзакція відкотилася, лист про «відправлене замовлення» все одно піде.
Mail::to($order->customer)->queue((new OrderShipped($order))->afterCommit());

Тепер лист ставиться в чергу лише після успішного коміту, а при відкаті - не ставиться взагалі. Глобально це вмикає after_commit => true у конфігурації підключення черги.

Обробка збоїв: невдалі листи потрапляють у failed_jobs; можна задати $tries і backoff на класі листа, як у звичайного завдання.

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

Звичайно сповіщення надсилають моделі з трейтом Notifiable: $user->notify(...). Модель сама знає свої адреси - email для пошти, phone для SMS (через методи routeNotificationFor...).

Але інколи отримувач - не користувач: гість, що залишив заявку, адреса з форми «Повідомити, коли з'явиться товар», канал Slack команди, email бухгалтерії.

Сповіщення на льоту (on-demand) - через фасад з явною маршрутизацією:

use Illuminate\Support\Facades\Notification;

Notification::route('mail', 'accounting@example.com')
    ->route('slack', '#billing')
    ->notify(new InvoicePaid($invoice));

Можна вказати ім'я отримувача пошти:

Notification::route('mail', ['buyer@example.com' => 'Олена Петренко'])
    ->notify(new OrderReceived($order));

Як це працює всередині: route() створює об'єкт AnonymousNotifiable, який зберігає маршрути для каналів. Сповіщення отримує його як $notifiable у методах via() і toMail().

Що з цього випливає:

  • via() має працювати з анонімним отримувачем. Якщо ви вибираєте канали за налаштуваннями користувача ($notifiable->prefers_sms), для анонімного отримувача таких властивостей немає:
public function via(object $notifiable): array
{
    return $notifiable instanceof AnonymousNotifiable
        ? ['mail']
        : $notifiable->notificationChannels();
}
  • канал database недоступний - немає моделі, до якої прив'язати запис;
  • немає бажаної локалі - мову доведеться задати явно: ->locale('uk');
  • маршрут береться з route(), а не з routeNotificationForMail.

Типові задачі:

  • службові сповіщення команді (новий платіж у Slack, помилка імпорту на пошту);
  • листи гостям до реєстрації;
  • кілька отримувачів: Notification::send($users, ...) для моделей і route() для зовнішніх адрес.

Тести:

Notification::fake();
// ...
Notification::assertSentOnDemand(InvoicePaid::class,
    fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'accounting@example.com');

Докладніше в документації: Сповіщення: сповіщення на льоту

Трейт Searchable підписується на події моделі: після saved модель надсилається в індекс, після deleted - видаляється з нього. Окремо нічого викликати не потрібно.

Що потрапляє в індекс - визначає toSearchableArray:

public function toSearchableArray(): array
{
    return [
        'id' => (int) $this->id,
        'title' => $this->title,
        'body' => strip_tags($this->body),
        'author' => $this->author->name,
        'tags' => $this->tags->pluck('name')->all(),
        'published_at' => $this->published_at?->timestamp,
    ];
}

Правила:

  • лише поля для пошуку й фільтрів, а не вся модель - менший індекс, швидший пошук, менше витоків даних;
  • дати - числом (timestamp), щоб по них можна було фільтрувати й сортувати;
  • дані зі зв'язків - денормалізуються в документ. Але тоді зміна імені автора не оновить його статті в індексі - про це треба подбати окремо.

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

public function shouldBeSearchable(): bool
{
    return $this->isPublished();
}

Чернетки не потраплять у пошук, а при знятті з публікації запис видалиться з індексу.

Коли оновлювати:

public function searchIndexShouldBeUpdated(): bool
{
    return $this->wasRecentlyCreated || $this->wasChanged(['title', 'body']);
}

Без цього кожне оновлення лічильника переглядів відправляло б документ в індекс.

Черга - обов'язкова для зовнішніх рушіїв:

// config/scout.php
'queue' => ['connection' => 'redis', 'queue' => 'scout'],

Інакше кожне збереження моделі чекає HTTP-запиту до Meilisearch, а недоступний пошуковий сервер ламає збереження. Завдання Scout унікальні - дублікати для тієї самої моделі не ставляться, поки попереднє в черзі.

Масові операції:

  • Post::where(...)->searchable() / ->unsearchable() - додати чи прибрати набір;
  • Post::withoutSyncingToSearch(fn () => ...) - імпорт чи міграція без тисяч завдань індексування, з одним scout:import потім;
  • масовий update() через query builder не викликає подій моделі - індекс не оновиться, його треба синхронізувати вручну.

Початкове наповнення: php artisan scout:import "App\Models\Post" або scout:queue-import --chunk=500 для великих таблиць, а makeAllSearchableUsing - для жадібного завантаження зв'язків під час імпорту.

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

Каталог lang у новому Laravel-застосунку відсутній - переклади фреймворку (повідомлення валідації, пагінації, автентифікації) живуть у самому пакеті. Щоб їх змінити чи додати свої:

php artisan lang:publish

Команда створює lang/en/ з файлами auth.php, pagination.php, passwords.php, validation.php. Для української переклади фреймворку зазвичай беруть з пакета спільноти (наприклад, laravel-lang/lang), а не перекладають вручну.

Два формати перекладів.

1. PHP-файли з короткими ключами:

// lang/uk/orders.php
return [
    'status' => [
        'paid' => 'Оплачено',
        'shipped' => 'Відправлено',
    ],
    'empty' => 'Замовлень поки немає',
];
__('orders.status.paid');
  • ключі групуються за файлами й вкладеністю;
  • зручно для системних рядків: статуси, повідомлення валідації, тексти листів;
  • ключ не змінюється, коли змінюється текст.

2. JSON-файли, де ключ - сам текст:

// lang/uk.json
{
    "Save changes": "Зберегти зміни",
    "Welcome back, :name!": "З поверненням, :name!"
}
__('Save changes');
  • у шаблонах видно справжній текст, а не orders.empty;
  • якщо перекладу немає, показується сам ключ - англійський текст, а не технічний ідентифікатор;
  • зручно для великого інтерфейсу, де вигадувати ключ для кожної кнопки обтяжливо.

Порівняння:

PHP-файли JSON
ключ orders.status.paid Save changes
вкладеність так ні
без перекладу показується ключ англійський текст
зміна вихідного тексту ключ лишається ключ змінюється, переклади треба переносити

Пастки:

  • конфлікт імен: рядок __('Orders') за наявності файлу lang/uk/orders.php і відсутності ключа в JSON поверне весь масив файлу;
  • запасна мова (APP_FALLBACK_LOCALE) використовується, коли рядка немає в поточній локалі - зручно, але приховує неперекладені рядки. Знаходити їх допомагає Lang::handleMissingKeysUsing() для логування;
  • переклади пакетів перевизначаються у lang/vendor/{пакет}/{локаль}/.

На практиці часто поєднують: PHP-файли - для системних рядків і повідомлень валідації, JSON - для текстів інтерфейсу.

Докладніше в документації: Локалізація: рядки перекладу як ключі

Подивитися SQL одного запиту без виконання:

User::where('active', true)->orderBy('name')->toSql();
// select * from "users" where "active" = ? order by "name" asc

User::where('active', true)->toRawSql();
// select * from "users" where "active" = 1 ...   - з підставленими значеннями

Вивести й продовжити чи зупинитися:

User::where('active', true)->dumpRawSql()->get();  // вивести й виконати
User::where('active', true)->ddRawSql();           // вивести й зупинити

toRawSql() зручний, щоб скопіювати запит у консоль бази й запустити EXPLAIN. Але для реального виконання Laravel завжди використовує підготовлені запити з окремими параметрами - підставлені значення лише для читання людиною.

Усі запити за запит чи команду:

DB::enableQueryLog();
// ... код ...
dump(DB::getQueryLog());   // SQL, параметри й час кожного

Слухач запитів - для логування в розробці:

// AppServiceProvider::boot()
if (app()->isLocal()) {
    DB::listen(function (QueryExecuted $query): void {
        Log::debug($query->toRawSql(), ['ms' => $query->time]);
    });
}

Сповіщення про повільну роботу з базою за весь запит:

DB::whenQueryingForLongerThan(500, function (Connection $connection, QueryExecuted $event): void {
    Log::warning('Багато часу в базі', ['connection' => $connection->getName()]);
});

Як знайти проблеми системно:

  • N+1: Model::preventLazyLoading(! app()->isProduction()) - виняток при лінивому завантаженні зв'язку в циклі;
  • Laravel Debugbar чи Telescope - список запитів сторінки з дублікатами й часом;
  • сторінка помилки Laravel також показує виконані запити;
  • на продакшені - журнал повільних запитів бази (pg_stat_statements, slow query log), а не логування кожного запиту.

Пастка: enableQueryLog у довгому процесі (воркер, команда з мільйоном записів) накопичує всі запити в пам'яті - вмикайте його лише на час налагодження.

Докладніше в документації: Налагодження запитів

Класичний спосіб - tail -f storage/logs/laravel.log. Він працює лише з файловим каналом, показує сирий текст разом з багаторядковими стеками, і його незручно фільтрувати.

Laravel Pail - Artisan-команда, яка показує записи логів у реальному часі в зручному вигляді:

composer require --dev laravel/pail
php artisan pail

Головна перевага: Pail отримує записи напряму від застосунку, тому працює з будь-яким драйвером логів - навіть якщо записи йдуть у Sentry, Papertrail чи stderr і локального файлу немає.

Фільтри:

php artisan pail --filter="QueryException"   # тип винятку, файл, текст
php artisan pail --message="Payment"         # лише за текстом повідомлення
php artisan pail --level=error               # рівень
php artisan pail --user=42                   # записи, зроблені в запитах користувача 42
php artisan pail -v                          # без скорочень
php artisan pail -vv                         # зі стеком викликів

Фільтр за користувачем особливо корисний: можна відтворити проблему під потрібним обліковим записом і бачити лише свої записи серед загального потоку.

Типові сценарії:

  • налагодження черги чи планувальника, де немає сторінки помилки: запустити pail поруч з воркером;
  • розробка інтеграції з вебхуками - бачити, що прийшло й що записано, одразу;
  • composer run dev у новому Laravel-проєкті запускає Pail разом із сервером, чергою й Vite.

Обмеження:

  • потрібне розширення PCNTL - на Windows лише через WSL;
  • Pail показує записи, що з'являються після запуску, - історію дивляться в логах;
  • це інструмент розробника: на продакшені для пошуку причин збоїв потрібне централізоване сховище логів з пошуком і зберіганням, а не перегляд у терміналі.

Щоб логи були корисними в Pail: структурований контекст замість склеювання рядків - Log::info('Order paid', ['order_id' => $order->id]).

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

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

Рівні
Junior 101 Middle 147 Senior 130

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