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

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

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

378 питань

Через Http::fake() - запити не йдуть у мережу, а отримують задані відповіді.

it('imports exchange rates', function () {
    Http::preventStrayRequests();
    Http::fake([
        'api.rates.example/*' => Http::response(['usd' => 41.2, 'eur' => 44.9]),
    ]);

    app(RatesImporter::class)->import();

    expect(Rate::where('code', 'usd')->value('value'))->toBe(41.2);
    Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization'));
});

Що підміняють:

  • конкретні адреси шаблонами з *;
  • послідовності: Http::sequence()->push(...)->pushStatus(500) - перша відповідь успішна, друга з помилкою;
  • збій з'єднання: Http::failedConnection();
  • логіку: замикання, що повертає відповідь залежно від запиту.

Перевірки запитів: assertSent, assertNotSent, assertSentCount, assertNothingSent.

preventStrayRequests() - будь-який непідмінений запит кидає виняток. Його ставлять у базовому TestCase чи Pest.php: тест, що випадково ходить у справжній API, стає повільним, нестабільним і може щось змінити в чужій системі.

Що обов'язково покрити: не лише успіх, а й 500, таймаут, 429, неочікуваний формат відповіді - саме ці гілки ламаються в продакшені.

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

У Laravel 11+ обробник винятків налаштовується не в класі Handler, а в bootstrap/app.php через withExceptions:

use Illuminate\Foundation\Configuration\Exceptions;
use Psr\Log\LogLevel;

->withExceptions(function (Exceptions $exceptions): void {
    // власне звітування для конкретного типу
    $exceptions->report(function (InvalidOrderException $e) {
        Notification::route('slack', config('services.slack.ops'))->notify(new OrderAlert($e));
    });

    // не звітувати зовсім
    $exceptions->dontReport([
        CardDeclinedException::class,
    ]);

    // інший рівень логування для типу
    $exceptions->level(PDOException::class, LogLevel::CRITICAL);

    // додатковий контекст до кожного звіту
    $exceptions->context(fn () => [
        'tenant_id' => tenant()?->id,
    ]);
})

Важлива деталь report(): колбек виконується на додачу до стандартного логування. Щоб стандартне звітування не відбувалося, колбек має повернути false або ланцюжок має закінчуватися ->stop():

$exceptions->report(function (InvalidOrderException $e) {
    // ...
})->stop();

Тип винятку визначається з type hint параметра колбека - окремо вказувати клас не потрібно.

Інші способи не звітувати:

  • інтерфейс ShouldntReport на класі винятку - позначка прямо в класі;
  • dontReportWhen(fn (Throwable $e) => ...) - умова за вмістом винятку;
  • dontReportDuplicates() - той самий екземпляр винятку, переданий у report() кілька разів, звітується лише раз.

Контекст на рівні винятку: метод context() у самому класі винятку додає його дані до запису в логу:

class InvalidOrderException extends Exception
{
    public function __construct(private int $orderId) { parent::__construct('Invalid order'); }

    public function context(): array
    {
        return ['order_id' => $this->orderId];
    }
}

Laravel 13: dontRetry - список винятків, при яких завдання в черзі не повторюється, навіть якщо спроби ще лишилися. Наприклад, видалений клієнт у стороннього API: повтор нічого не змінить.

Практика: тримати в dontReport лише очікувані бізнес-ситуації. Помилка, яку ніхто не бачить, - це баг, про який ніхто не знає.

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

Замість реєструвати колбеки в bootstrap/app.php можна описати поведінку прямо в класі винятку - методами report() і render(). Обробник Laravel викличе їх сам.

namespace App\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

final class InsufficientBalanceException extends Exception
{
    public function __construct(
        public readonly int $required,
        public readonly int $available,
    ) {
        parent::__construct('Недостатньо коштів на балансі.');
    }

    public function report(): bool
    {
        // очікувана бізнес-ситуація, звітувати не потрібно
        return true;
    }

    public function render(Request $request): Response|JsonResponse
    {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => $this->getMessage(),
                'required' => $this->required,
                'available' => $this->available,
            ], 422);
        }

        return back()->withErrors(['balance' => $this->getMessage()]);
    }
}

Як працюють значення, що повертаються:

Метод Повертає Результат
report() false стандартне логування виконується
report() true чи нічого (void) виняток вважається обробленим, стандартного логування немає
render() відповідь її отримає користувач
render() false стандартний рендеринг Laravel

Навіщо власні винятки:

  • доменна мова: InsufficientBalanceException у коді й логах зрозуміліше, ніж RuntimeException('balance');
  • дані в полях: скільки потрібно, скільки є - а не розбір тексту повідомлення;
  • точкові catch: викликаючий код ловить саме цей випадок, а несподівані помилки летять далі;
  • одне місце для того, як ця ситуація виглядає для користувача.

Коли краще колбеки в bootstrap/app.php: для чужих винятків (з пакетів чи фреймворку), до класу яких немає доступу, і для спільної політики - наприклад, усі помилки API в одному форматі.

Порада щодо ієрархії: базовий виняток модуля (BillingException) і конкретні нащадки - тоді можна зловити все, що стосується модуля, одним catch, а звітування налаштувати для базового класу.

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

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

use Illuminate\Support\Facades\Context;

// middleware
public function handle(Request $request, Closure $next): Response
{
    Context::add('url', $request->url());
    Context::add('trace_id', (string) Str::uuid());

    return $next($request);
}

Тепер будь-який запис у лог під час цього запиту містить ці дані:

Log::info('Користувача автентифіковано', ['auth_id' => Auth::id()]);
local.INFO: Користувача автентифіковано {"auth_id":27} {"url":"https://example.com/login","trace_id":"e04e1a11-..."}

Передача в черги - головна перевага. Завдання, поставлене в черзу під час запиту, отримує той самий контекст, і його логи містять той самий trace_id. Ланцюжок «запит → завдання → вкладене завдання» можна відстежити одним пошуком у логах.

Основні методи:

Context::add('key', 'value');             // перезаписати
Context::addIf('key', 'value');           // лише якщо ще немає
Context::push('breadcrumbs', 'first');    // стек значень
Context::get('key');
Context::has('key');
Context::forget('key');

Context::scope(function () {
    // тимчасовий контекст лише для цього блоку
}, ['import_id' => $import->id]);

Прихований контекст - передається в черги, але не пишеться в лог:

Context::addHidden('api_token', $token);
Context::getHidden('api_token');

Події dehydrating і hydrated дозволяють вирішувати, що відбувається при передачі в чергу: наприклад, зберегти локаль запиту й відновити її в завданні.

Чим це відрізняється від Log::withContext():

Log::withContext Context
потрапляє в лог так так (крім прихованого)
передається в черги ні так
можна прочитати в коді ні так (Context::get)

Що не класти в Context: великі об'єкти й моделі - контекст серіалізується з кожним завданням і пишеться в кожен рядок логу. Ідентифікатори, а не сутності.

Докладніше в документації: Context: як це працює

Не кожна помилка має зупиняти запит. Якщо не вдалося оновити рекомендації чи відправити аналітику, користувач усе одно має отримати сторінку - але розробник має про це дізнатися.

report() - передати виняток обробнику для звітування (лог, Sentry) і продовжити виконання:

public function isValid(string $value): bool
{
    try {
        // перевірка, що може кинути виняток
    } catch (Throwable $e) {
        report($e);

        return false;
    }
}

Без report() виняток у catch просто зникає - це «проковтування» помилки, яке роками ховає баги.

rescue() - виконати замикання, а при винятку звітувати й повернути значення за замовчуванням:

$recommendations = rescue(
    fn () => $this->recommender->for($user),
    [],                 // значення при помилці
);

// без звітування - третій аргумент
$preview = rescue(fn () => $this->renderPreview($post), null, report: false);

Значенням за замовчуванням може бути й замикання - воно виконається лише при помилці.

report_if() / report_unless():

report_if($response->failed(), new PaymentGatewayException($response->body()));

Дублікати: якщо той самий виняток передали в report() кілька разів (у сервісі й ще раз у контролері), у лозі з'являться дублікати. $exceptions->dontReportDuplicates() у bootstrap/app.php звітує кожен екземпляр лише раз.

Коли rescue - погана ідея:

  • помилки, від яких залежить коректність: оплата, збереження замовлення, перевірка прав. Тиха заміна на значення за замовчуванням тут перетворює помилку на неправильні дані;
  • широкий блок коду: rescue() навколо половини методу ховає і очікувані, і зовсім несподівані збої. Загортайте лише конкретну необов'язкову операцію;
  • без звітування (report: false) - лише коли помилка справді очікувана і не цікава.

Правило: якщо помилку ловите, то або обробляєте осмислено, або звітуєте. Порожній catch - майже завжди баг.

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

Додати cookie до відповіді:

return response('Hello')->cookie('theme', 'dark', minutes: 60 * 24 * 365);

return response('Hello')->withoutCookie('theme');   // видалити

Черга cookie - коли відповіді ще немає (сервіс, middleware, слухач події):

use Illuminate\Support\Facades\Cookie;

Cookie::queue('last_seen_post', $post->id, minutes: 60);
Cookie::expire('promo_banner');

Laravel прикріпить cookie з черги до відповіді, яка буде відправлена.

Читання:

$theme = $request->cookie('theme');

Шифрування за замовчуванням. Middleware EncryptCookies шифрує й підписує всі cookie, які створює Laravel, ключем APP_KEY. Наслідки:

  • клієнт не може прочитати чи підробити значення - змінене cookie просто не розшифрується, і $request->cookie() поверне null;
  • JavaScript бачить зашифрований рядок, а не значення;
  • cookie, встановлене не Laravel (з JavaScript чи іншим сервісом), Laravel спробує розшифрувати й отримає null.

Виключення з шифрування - для cookie, які має читати фронтенд чи інший сервіс:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->encryptCookies(except: [
        'theme',
        'consent',
    ]);
})

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

Атрибути безпеки (за замовчуванням з config/session.php): secure, httpOnly, sameSite. Для власних cookie їх можна передати в cookie():

cookie('theme', 'dark', 525600, path: '/', domain: null, secure: true, httpOnly: false, sameSite: 'lax');

Що пам'ятати:

  • зміна APP_KEY робить усі зашифровані cookie, включно з сесією, недійсними - користувачів розлогінить (допомагає APP_PREVIOUS_KEYS);
  • розмір: браузер обмежує cookie приблизно 4 КБ, а шифрування збільшує значення - великі дані в cookie не кладуть;
  • кешування сторінок: відповідь з Set-Cookie CDN зазвичай не кешує, тож cookie на кожній сторінці ламає кеш на краю мережі.

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

Звичайна відповідь формується повністю, а потім відправляється. Потокова - відправляється частинами в міру готовності: користувач бачить перші дані раніше, а сервер не тримає всю відповідь у пам'яті.

stream - довільні дані частинами:

return response()->stream(function (): void {
    foreach ($this->generator->chunks() as $chunk) {
        echo $chunk;
        ob_flush();
        flush();
    }
}, 200, ['X-Accel-Buffering' => 'no']);

Простіше - передати замикання-генератор: Laravel сам відправлятиме кожне yield і скидатиме буфер:

Route::get('/answer', function (LlmClient $llm) {
    return response()->stream(function () use ($llm) {
        foreach ($llm->stream(request('prompt')) as $token) {
            yield $token;
        }
    });
});

streamJson - великий JSON, де частина даних - лінива колекція:

return response()->streamJson([
    'users' => User::query()->cursor(),
]);

Рядки читаються з бази й кодуються в JSON по одному - пам'ять не залежить від кількості записів.

eventStream - Server-Sent Events (text/event-stream):

return response()->eventStream(function () {
    while ($progress = $this->import->progress()) {
        yield new StreamedEvent(event: 'progress', data: ['percent' => $progress]);
        sleep(1);
    }
});

Браузер слухає через EventSource, а наприкінці Laravel надсилає завершальне повідомлення </stream>. Для React і Vue є готові хуки useStream і useEventStream з пакетів @laravel/stream-react / @laravel/stream-vue.

Що ламає потокову передачу:

  • буферизація на шляху: Nginx за замовчуванням буферизує відповідь від PHP-FPM - заголовок X-Accel-Buffering: no чи налаштування proxy_buffering off; так само CDN і стиснення gzip, що чекає на заповнення буфера;
  • тайм-аути: max_execution_time, тайм-аути проксі й балансувальника обривають довге з'єднання;
  • воркер зайнятий: кожен відкритий потік тримає процес PHP-FPM. Сотня користувачів, що слухають SSE, - сотня зайнятих воркерів. Для масових підписок на події краще WebSocket через Reverb;
  • сесія: блокування сесії (->block()) чи сесія, відкрита на весь час потоку, затримує інші запити того самого користувача;
  • помилка посеред потоку: статус 200 уже відправлено - про помилку доведеться повідомити в самих даних.

Коли доречно: відповіді LLM по токенах, експорт великих даних, прогрес довгої операції.

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

Основні хелпери:

url('/posts/42');                              // абсолютний URL з APP_URL чи поточного хоста
route('posts.show', $post);                    // за іменем маршруту - рекомендований спосіб
route('posts.show', ['post' => $post, 'tab' => 'comments']);  // зайві параметри стають query
route('posts.index', absolute: false);         // відносний шлях /posts
action([PostController::class, 'show'], $post);
secure_url('/checkout');                       // примусово https

Чому route(), а не рядки: змінили URL у файлі маршрутів - усі посилання оновилися. Помилка в імені маршруту - виняток одразу, а не тихе посилання в нікуди.

Модель як параметр: Laravel підставляє ключ маршруту моделі (getRouteKey()), тож якщо маршрут використовує {post:slug} чи модель перевизначає getRouteKeyName(), у URL буде slug.

Поточна й попередня адреса:

url()->current();      // без query
url()->full();         // з query
url()->previous();     // з заголовка Referer чи сесії
url()->previousPath();
request()->routeIs('posts.*');   // для активних пунктів меню

Змінити параметри поточної адреси (наприклад, сторінку чи сортування) зручніше не склеюванням рядків, а через $request->fullUrlWithQuery(['page' => 3]) чи об'єкт Uri.

Типові проблеми:

  • неправильний домен чи http замість https за балансувальником - не налаштовані довірені проксі, і Laravel не знає, що запит прийшов по HTTPS;
  • URL у листах і завданнях черги будуються з APP_URL, бо поточного запиту немає. Неправильний APP_URL - посилання на localhost у листах;
  • url()->previous() для логіки безпеки - заголовок Referer контролює клієнт;
  • підписані URL (URL::signedRoute) - для посилань, які не можна підробити: відписка, підтвердження пошти.

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

Усі три способи записують дані, але працюють на різних рівнях.

updateOrCreate - модель за моделлю:

Price::updateOrCreate(
    ['product_id' => 42, 'currency' => 'UAH'],
    ['amount' => 1999],
);

Два запити (пошук і INSERT чи UPDATE), повний життєвий цикл моделі: події saving, created/updated, спостерігачі, мутатори й касти. Для тисячі рядків - тисячі пар запитів.

upsert - один запит на всю партію:

Price::upsert(
    [
        ['product_id' => 42, 'currency' => 'UAH', 'amount' => 1999],
        ['product_id' => 43, 'currency' => 'UAH', 'amount' => 2499],
    ],
    uniqueBy: ['product_id', 'currency'],
    update: ['amount'],
);

Перетворюється на INSERT ... ON CONFLICT (...) DO UPDATE у PostgreSQL чи INSERT ... ON DUPLICATE KEY UPDATE у MySQL. Eloquent додає created_at і updated_at.

insert - масове вставлення без перевірки існування; при дублікаті - помилка унікальності. insertOrIgnore - пропускає конфлікти мовчки.

Порівняння:

updateOrCreate upsert insert
запитів на N рядків ~2N 1 1
події моделі й спостерігачі так ні ні
касти й мутатори так ні (сирі значення) ні
timestamps так так ні
атомарність між процесами ні (гонка між SELECT і INSERT) так так

Що важливо знати про upsert:

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

Гонка в updateOrCreate: два паралельні запити можуть обидва не знайти запис і обидва спробувати вставити - один отримає помилку унікальності. upsert такої проблеми не має, бо конфлікт розв'язує сама база.

Коли що: одиничне збереження з бізнес-логікою в подіях - updateOrCreate; імпорт, синхронізація цін, лічильники - upsert.

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

Багато таблиць ростуть безкінечно: журнали подій, прочитані сповіщення, прострочені токени, м'яко видалені записи. Laravel дає вбудований механізм їх періодично прибирати.

Prunable - описати, які записи застаріли:

use Illuminate\Database\Eloquent\Prunable;

class LoginAttempt extends Model
{
    use Prunable;

    public function prunable(): Builder
    {
        return static::where('created_at', '<=', now()->subMonth());
    }

    protected function pruning(): void
    {
        // перед видаленням кожної моделі: прибрати пов'язані файли тощо
    }
}

Запуск за розкладом:

// routes/console.php
Schedule::command('model:prune')->daily();

Команда знаходить усі моделі з трейтами Prunable / MassPrunable у app/Models і видаляє записи, які повертає prunable().

php artisan model:prune --pretend    # скільки буде видалено, без видалення
php artisan model:prune --model="App\Models\LoginAttempt"

Prunable проти MassPrunable:

Prunable MassPrunable
як видаляє завантажує моделі порціями й викликає delete() для кожної один DELETE за запитом (порціями)
події deleting/deleted, спостерігачі так ні
хук pruning() так ні
швидкість на мільйонах рядків повільно швидко

М'яке видалення: якщо модель використовує SoftDeletes, Prunable видаляє записи остаточно (forceDelete). Типова схема - м'яко видалені записи старші за 30 днів прибирати назавжди.

Що важливо:

  • індекс на колонці умови (created_at) - інакше щоденне очищення читає всю таблицю;
  • перший запуск на таблиці з роками даних може видалити мільйони рядків і довго тримати блокування - краще спершу почистити вручну порціями, а потім ввімкнути розклад;
  • MassPrunable не викликає подій - пов'язані файли, кеш, пошуковий індекс не очищаються автоматично;
  • політика зберігання даних (скільки зберігати журнали, персональні дані) - це юридичне питання, а prunable() - місце, де вона виконується в коді;
  • тестування: у тесті можна створити старі й нові записи фабрикою, викликати $this->artisan('model:prune') і перевірити, що лишилися лише нові.

Докладніше в документації: Eloquent: очищення моделей

Події життєвого циклу моделі:

Операція Події по порядку
створення (save нової моделі) saving → creating → INSERT → created → saved
оновлення (save існуючої) saving → updating → UPDATE → updated → saved
видалення deleting → DELETE → deleted (з SoftDeletes ще trashed)
відновлення (restore) restoring → restored (з проміжними saving/updating)
остаточне видалення forceDeleting → forceDeleted
завантаження з бази retrieved
replicate() replicating

Події «до» (-ing) можуть скасувати операцію: якщо слухач поверне false, запис не відбудеться, а save() поверне false.

saving проти creating/updating: saving - для логіки, однакової при створенні й оновленні (нормалізація, генерація slug); creating - лише для нових записів (встановити автора).

Оновлення без змін не спричиняє updating/updated: якщо жоден атрибут не змінився (isDirty() порожній), запит не виконується. Але saving і saved спрацьовують.

Що НЕ запускає події:

Post::where('status', 'draft')->update(['status' => 'archived']);   // масове оновлення
Post::where('created_at', '<', $date)->delete();                   // масове видалення
Post::insert([...]);
Post::upsert([...], ...);

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

Зберегти без подій:

$post->saveQuietly();
$post->updateQuietly(['views' => $post->views + 1]);
$post->deleteQuietly();

Post::withoutEvents(function () {
    // усі операції всередині - без подій
});

Корисно для технічних оновлень (лічильники переглядів), міграцій даних і сидерів.

Події й транзакції: подія created спрацьовує до коміту транзакції. Якщо слухач ставить завдання в чергу, воркер може почати його раніше, ніж дані стануть видимими, або транзакція відкотиться після відправленого листа. Рішення - спостерігач з інтерфейсом ShouldHandleEventsAfterCommit чи afterCommit для завдань.

Порада: логіку, без якої дані некоректні, краще тримати явно в сервісі чи дії, а події - для побічних ефектів (кеш, індекс, аудит). Інакше поведінка залежить від того, яким методом збережено модель.

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

Ці функції закривають типові шаблони коду, які інакше пишуть вручну з try, if і тимчасовими змінними.

retry - повторити операцію, що може тимчасово впасти:

$rate = retry(3, fn () => $client->fetchRate('USD'), 200);

// своя затримка для кожної спроби
retry([100, 500, 2000], fn () => $client->fetchRate('USD'));

// повторювати лише для певних помилок
retry(3, $callback, 200, fn (Throwable $e) => $e instanceof ConnectionException);

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

rescue - виконати й повернути запасне значення замість винятку:

$weather = rescue(fn () => $api->current($city), null);
$weather = rescue(fn () => $api->current($city), fn () => Cache::get("weather:$city"), report: false);

За замовчуванням виняток звітується (потрапляє в лог і Sentry), але не перериває запит. report: false - для очікуваних збоїв.

optional - безпечний доступ до властивостей null:

optional($user->address)->city;

У PHP 8 зазвичай краще nullsafe-оператор $user->address?->city. optional лишається корисним із замиканням: optional($user, fn ($u) => $u->fullName()).

tap - виконати дію над значенням і повернути саме значення:

return tap($user)->update(['last_login_at' => now()]);   // повертає $user, а не true

value - повертає значення, а якщо це замикання - результат його виклику. Зручно для параметрів «значення або callback».

once - мемоізація в межах одного об'єкта чи запиту:

public function stats(): array
{
    return once(fn () => $this->calculateExpensiveStats());
}

Повторні виклики stats() на тому самому екземплярі повернуть збережений результат. Для статичного контексту - один раз на запит.

Також: blank() / filled() (порожній рядок, пробіли, null, порожня колекція), transform() (застосувати callback, якщо значення не порожнє), when().

Міра: хелпер має робити код зрозумілішим. Ланцюжок tap(optional(value(...))) гірший за три рядки звичайного коду.

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

Str::slug() перетворює текст на фрагмент URL: транслітерує, переводить у нижній регістр і замінює все, крім літер і цифр, на роздільник.

Str::slug('Laravel 13 Framework');   // 'laravel-13-framework'

Сигнатура: Str::slug($title, $separator = '-', $language = 'en', $dictionary = ['@' => 'at']).

Третій параметр - мова транслітерації, і за замовчуванням це 'en'. Для кирилиці це дає результат за загальними правилами, а не за українськими:

Str::slug('Київ - столиця України');               // 'kiyiv-stolicia-ukrayini'
Str::slug('Київ - столиця України', '-', 'uk');    // 'kyyiv-stolytsia-ukrayiny'

Str::slug('Щастя й ґанок');                        // 'shhastia-i-ganok'
Str::slug('Щастя й ґанок', '-', 'uk');             // 'shchastia-y-ganok'

З 'uk' використовується українська таблиця (щ → shch, и → y, ц → ts), близька до офіційної транслітерації.

Unicode-slug без транслітерації:

Str::slug('Привіт', '-', null);   // 'привіт'

Браузери показують такі адреси нормально, але при копіюванні вони перетворюються на %D0%BF%D1%80... - для посилань у месенджерах і логах це незручно.

Практичні правила:

  • обрати одну мову транслітерації й використовувати її скрізь - у моделі, у міграції даних, у тестах. Якщо частина slug-ів створена з 'en', а частина з 'uk', той самий заголовок дасть різні адреси;
  • змінювати правила транслітерації на живому сайті - це зміна URL усіх сторінок. Потрібні 301-редиректи зі старих адрес, інакше втрачаються пошукові позиції й зовнішні посилання;
  • slug не має змінюватися автоматично при редагуванні заголовка опублікованого матеріалу - з тієї самої причини;
  • унікальність: Str::slug не перевіряє базу. Дублікати треба обробляти окремо (суфікс -2, ідентифікатор у URL чи унікальний індекс у базі);
  • $dictionary замінює символи до транслітерації: ['@' => 'at', '&' => 'and'].

Str::transliterate() і Str::ascii() - те саме перетворення без заміни пробілів і нижнього регістру; ascii теж приймає мову другим параметром.

Докладніше в документації: Рядки: Str::slug

Проблема sleep(): код з паузами (очікування між повторами, обмеження частоти запитів до API) робить тести повільними, а перевірити, скільки саме чекав код, неможливо.

Клас Sleep - обгортка над sleep/usleep, яку можна підробити в тестах:

use Illuminate\Support\Sleep;

Sleep::for(500)->milliseconds();
Sleep::for(2)->seconds();
Sleep::until(now()->addMinute());

// між запитами до API з лімітом частоти
foreach ($pages as $page) {
    $client->fetch($page);
    Sleep::for(1)->second();
}

У тесті:

use Illuminate\Support\Sleep;

Sleep::fake();

$importer->run();

Sleep::assertSlept(fn (Carbon\CarbonInterval $d) => $d->totalSeconds === 1, times: 3);
Sleep::assertSequence([
    Sleep::for(1)->second(),
    Sleep::for(2)->seconds(),
]);
Sleep::assertNeverSlept();

Тест проходить миттєво, а затримки перевіряються явно. Sleep::fake(syncWithCarbon: true) ще й пересуває «поточний час» Carbon на тривалість паузи.

Бонус: хелпер retry() під капотом чекає через Sleep, тож Sleep::fake() робить миттєвими й тести з повторними спробами.

Benchmark - швидко порівняти варіанти коду:

use Illuminate\Support\Benchmark;

Benchmark::dd([
    'eager' => fn () => Post::with('author')->get(),
    'lazy' => fn () => Post::all()->each->author,
], iterations: 10);
// ["eager" => "12.3ms", "lazy" => "48.7ms"]

$ms = Benchmark::measure(fn () => $report->build());   // середній час у мс

[$result, $ms] = Benchmark::value(fn () => $report->build());  // результат і час

Обмеження Benchmark:

  • це мікробенчмарк у поточному процесі: кеш бази, OPcache, прогрів впливають на результат. Перша ітерація часто повільніша - тому iterations;
  • для продакшен-продуктивності потрібні профілювальники й моніторинг (Xdebug, Blackfire, Pulse, APM), а не Benchmark::dd;
  • Benchmark::dd() зупиняє виконання - лише для локального налагодження.

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

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

Пакет має працювати одразу після встановлення з розумними значеннями за замовчуванням, але дозволяти змінити їх застосунку.

mergeConfigFrom (у register) - злиття конфігурації пакета з конфігурацією застосунку:

public function register(): void
{
    $this->mergeConfigFrom(__DIR__.'/../config/invoices.php', 'invoices');
}

Тепер config('invoices.currency') працює, навіть якщо застосунок нічого не публікував.

publishes (у boot) - дозволяє скопіювати файл у застосунок для редагування:

public function boot(): void
{
    $this->publishes([
        __DIR__.'/../config/invoices.php' => config_path('invoices.php'),
    ], 'invoices-config');
}
php artisan vendor:publish --tag=invoices-config

Як працює злиття - і головна пастка:

// всередині mergeConfigFrom
array_merge(require $packageConfig, config('invoices', []));
  • значення застосунку перемагають значення пакета;
  • злиття неглибоке - лише перший рівень ключів. Якщо пакет має 'pdf' => ['size' => 'A4', 'orientation' => 'portrait'], а застосунок у своєму файлі вказав 'pdf' => ['size' => 'Letter'], ключ orientation зникне повністю;
  • для рекурсивного злиття є replaceConfigRecursivelyFrom(), але воно має свої наслідки для масивів-списків.

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

config:cache: коли конфігурацію закешовано, mergeConfigFrom нічого не робить - значення вже в кеші. Тому змінюючи конфігурацію пакета в розробці, не забувайте config:clear.

Теги публікації:

  • конвенція - <пакет>-config, <пакет>-migrations, <пакет>-views;
  • vendor:publish --provider="Acme\Invoices\InvoicesServiceProvider" публікує все від провайдера;
  • міграції краще публікувати через publishesMigrations() - Laravel оновить мітку часу в імені файлу, щоб міграція виконалася після наявних;
  • не змушуйте публікувати конфігурацію для базової роботи: якщо без vendor:publish пакет падає - це погана інтеграція.

Значення з оточення: у конфігурації пакета можна використовувати env('INVOICES_CURRENCY', 'UAH'), а в коді пакета - лише config(), бо після config:cache виклики env() поза конфігураційними файлами повертають null.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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