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

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

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

378 питань

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

Як цього досягти:

  • окремий крок деплою (job у CI, init-контейнер, release-фаза) замість migrate у старті кожного контейнера;
  • якщо запускають з кожного сервера - php artisan migrate --isolated --force: перший бере атомарне блокування в кеші, інші тихо завершуються. Кеш має бути спільним;
  • --force потрібен, щоб команда не питала підтвердження на продакшені.

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

  1. Розширення: додати нову колонку (nullable чи з дефолтом), код пише в обидві.
  2. Перенесення даних фоново порціями.
  3. Перемикання коду на нову колонку.
  4. Звуження: видалити стару колонку окремим наступним релізом.

Що ще враховують:

  • довгі блокування на великих таблицях: instant() на MySQL, online() для індексів на PostgreSQL, нічні вікна для важкого;
  • PostgreSQL обгортає міграцію в транзакцію, а CREATE INDEX CONCURRENTLY у транзакції не працює;
  • сідери довідників - окремо від міграцій і ідемпотентні.

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

Сотні файлів сповільнюють створення бази для тестів і CI, а історія давно не потрібна покроково.

php artisan schema:dump --prune

Команда записує поточну схему в database/schema/{connection}-schema.sql і з --prune видаляє наявні міграції. Нова база спершу виконує SQL зі схеми, а потім - міграції, створені після дампу.

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

  • Різні СУБД. Дамп робиться для конкретного підключення. Якщо тести йдуть на іншій базі (SQLite замість PostgreSQL), потрібен дамп і для неї - інакше тести не зберуть схему.
  • Інструменти. Для дампу потрібні mysqldump чи pg_dump на машині, де виконується команда.
  • Наявні середовища не зачіпаються: продакшен уже має всі ці міграції в таблиці migrations.
  • Дані в міграціях. Якщо старі міграції наповнювали довідники, після стискання цих даних у новій базі не буде - їх переносять у сідери.

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

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

Довгу команду переривають деплой, зупинка контейнера, OOM. Вона має завершуватися чисто й продовжувати, а не починати все з нуля чи дублювати роботу.

1. Реагувати на сигнал:

public function handle(): int
{
    $this->trap([SIGTERM, SIGINT], fn () => $this->shouldStop = true);

    Order::query()->where('synced', false)->lazyById(500)->each(function (Order $order) {
        if ($this->shouldStop) {
            return false;            // вийти після поточного запису
        }
        $this->sync($order);
        $order->update(['synced' => true]);
    });

    return self::SUCCESS;
}

2. Пам'ятати прогрес у даних, а не в пам'яті: позначка synced, останній оброблений id у таблиці чи кеші. Перезапуск продовжить з місця зупинки.

3. Ідемпотентність кроку: повторна обробка того самого запису не має шкодити - перевірка стану чи унікальний ключ.

4. Один екземпляр: Isolatable і --isolated або withoutOverlapping() у планувальнику.

5. Обходити дані порціями (lazyById, chunkById) - щоб пам'ять не росла, а зміни не зсували сторінки.

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

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

Команду запускають cron, CI, скрипти деплою - не люди. Їй треба повідомляти результат так, щоб це зрозуміла машина.

Коди виходу: 0 - успіх, будь-що інше - збій. Скрипт деплою й CI зупиняються саме на ненульовому коді.

if ($failed > 0) {
    $this->error("{$failed} записів не оброблено");
    return self::FAILURE;
}
return self::SUCCESS;

// або з будь-якого місця
$this->fail('Немає підключення до API');

Команда, що ловить винятки й завжди повертає 0, приховує збої від усього навколо.

Інше, що варто мати:

  • неінтерактивність: без confirm() і ask() у шляху виконання за розкладом; підтвердження руйнівних дій - через --force і перевірку isProduction();
  • журнал, а не лише консоль: $this->info() бачить лише той, хто дивиться в термінал; підсумок і помилки - ще й у Log;
  • --dry-run для команд, що змінюють дані, - показати, що буде змінено;
  • прогрес і підсумок (оброблено, пропущено, помилки) - щоб було видно, що команда не зависла;
  • тести: $this->artisan('reports:send', ['user' => 7])->assertSuccessful()->expectsOutputToContain('Надіслано').

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

Через Http::pool() або Http::batch() - запити відправляються одночасно, загальний час близький до найдовшого, а не до суми.

$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('rates')->timeout(3)->get('https://api.example.com/rates'),
    $pool->as('news')->timeout(3)->get('https://api.example.com/news'),
    $pool->as('weather')->timeout(3)->get('https://api.example.com/weather'),
]);

$rates = $responses['rates']->json();

batch() додає колбеки:

Http::batch(fn (Batch $batch) => [
    $batch->get('https://api.example.com/a'),
    $batch->get('https://api.example.com/b'),
])->then(fn (Batch $batch, array $results) => /* усі успішні */)
  ->catch(fn (Batch $batch, $key, $response) => /* один з помилкою */)
  ->send();   // або ->defer() - виконати після відповіді користувачу

Що враховувати:

  • кожен запит у пулі налаштовують окремо - спільні заголовки не успадковуються від зовнішнього виклику;
  • аргумент concurrency обмежує одночасні запити, щоб не впертися в ліміт стороннього API;
  • результат з помилкою - це Response з 5xx або виняток з'єднання в масиві, перевіряють кожен;
  • pool не замінює чергу: якщо результат не потрібен відповіді, краще поставити завдання.

Для паралельного виконання довільного PHP-коду, а не HTTP, - Concurrency::run().

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

Виклики Http::... розкидані по контролерах - це однакові заголовки, таймаути й обробка помилок у двадцяти місцях і жодної можливості підмінити сервіс у тестах окремо від HTTP.

1. Базова конфігурація в одному місці - макрос:

// AppServiceProvider::boot()
Http::macro('github', fn () => Http::baseUrl('https://api.github.com')
    ->withToken(config('services.github.token'))
    ->acceptJson()
    ->timeout(5)
    ->retry(2, 200, throw: false));

2. Клієнт-клас з операціями предметної області:

final class GitHubClient
{
    public function stars(string $repo): int
    {
        return Http::github()->get("/repos/{$repo}")->throw()->json('stargazers_count');
    }
}

Код застосунку викликає stars('laravel/framework') і не знає про URL, заголовки й формат відповіді.

3. Свої DTO замість сирих масивів - формат API змінився, і правка в одному місці.

4. Наскрізні речі - глобальні middleware клієнта (Http::globalRequestMiddleware) для User-Agent чи кореляційного ID і подія ConnectionFailed для журналу.

5. Тести на двох рівнях: клієнт - через Http::fake() з реальними прикладами відповідей; решта коду - через підміну самого GitHubClient (інтерфейс і фейкова реалізація в контейнері).

Для великих API з десятками ендпойнтів беруть пакет Saloon - він формалізує саме цю структуру.

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

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

Варіант 1 - зашифрований файл у репозиторії:

php artisan env:encrypt --env=production          # створює .env.production.encrypted
php artisan env:decrypt --env=production --key=... # на сервері чи в CI

Зашифрований файл комітять разом з кодом - історія змін і рев'ю як для коду. Ключ зберігають окремо (секрет CI, LARAVEL_ENV_ENCRYPTION_KEY). Опція --readable шифрує лише значення, лишаючи назви змінних видимими в диффах.

Варіант 2 - секрет-менеджер платформи (AWS Secrets Manager, Vault, Doppler, секрети GitHub Actions, змінні Laravel Cloud чи Forge): значення потрапляють у змінні оточення процесу, файлу немає взагалі.

Принципи незалежно від інструмента:

  • різні секрети для кожного оточення - ключ стейджингу не відкриває продакшен;
  • мінімальні права: CI має лише те, що потрібно для деплою;
  • ротація: після звільнення людини чи витоку - змінити, а не лише видалити доступ;
  • секрети не в журналах, не у виводі config:show, не в стектрейсах (APP_DEBUG=false);
  • APP_KEY при ротації - через APP_PREVIOUS_KEYS, щоб не розлогінити всіх.

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

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

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

throttle() у bootstrap/app.php - вибіркове звітування:

use Illuminate\Support\Lottery;
use Illuminate\Cache\RateLimiting\Limit;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->throttle(function (Throwable $e) {
        // кожен 1000-й звіт про часті помилки
        if ($e instanceof ApiMonitoringException) {
            return Lottery::odds(1, 1000);
        }

        // не більше 300 звітів на хвилину для збоїв зовнішніх сервісів
        if ($e instanceof BroadcastException) {
            return Limit::perMinute(300);
        }

        // ліміт окремо для кожного типу винятку
        return Limit::perMinute(300)->by($e::class);
    });
})

Два режими:

Lottery Limit
що робить звітує випадкову частку звітує не більше N за період
стан не потрібен лічильник у кеші (потрібен спільний драйвер, наприклад Redis)
для чого дуже часті, неважливі за кількістю сплески, при яких важливо бачити кожен перший випадок

by() визначає, що вважається «тим самим»: за класом винятку, за повідомленням, за ідентифікатором тенанта. Без by() усі винятки ділять один спільний ліміт.

Чого throttle не замінює:

  • circuit breaker - якщо сервіс лежить, не варто взагалі звертатися до нього тисячі разів: це і зайве навантаження, і повільні запити користувачів;
  • агрегацію в Sentry чи Flare: вони групують однакові помилки, але кожна подія все одно рахується в квоту - тому обмеження на боці застосунку економить гроші;
  • сповіщення: про те, що помилка стала частою, мають сповіщати метрики й моніторинг, а не кількість записів у лозі.

Ще кілька захистів від шуму:

  • dontReportDuplicates() - один виняток, переданий у report() кілька разів, звітується раз;
  • рівні: level(ConnectException::class, LogLevel::WARNING) - збій зовнішнього сервісу не повинен виглядати як error застосунку;
  • ротація логів (канал daily з days) і збір логів у централізоване сховище замість файлу на диску.

Перевірка: зробити штучний сплеск у staging і подивитися, скільки записів реально пішло в лог і в Sentry.

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

Файл storage/logs/laravel.log зручний локально, але на продакшені з кількома серверами чи контейнерами логи мають збиратися централізовано (Loki, ELK, CloudWatch, Datadog), і читати їх буде не людина в tail, а система пошуку.

1. Канал у stderr для контейнерів:

// config/logging.php
'stderr' => [
    'driver' => 'monolog',
    'level' => env('LOG_LEVEL', 'info'),
    'handler' => StreamHandler::class,
    'handler_with' => ['stream' => 'php://stderr'],
    'formatter' => env('LOG_STDERR_FORMATTER'),
    'processors' => [PsrLogMessageProcessor::class],
],
LOG_CHANNEL=stderr
LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter

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

2. Процесори Monolog - додають дані до кожного запису:

'processors' => [
    PsrLogMessageProcessor::class,   // підставляє {placeholders} з контексту в повідомлення
    WebProcessor::class,             // url, метод, ip
    MemoryUsageProcessor::class,
],

3. tap - довільне налаштування логера каналу класом:

'stack' => [
    'driver' => 'stack',
    'tap' => [App\Logging\AddHostname::class],
    'channels' => ['stderr'],
],
final class AddHostname
{
    public function __invoke(Logger $logger): void
    {
        foreach ($logger->getHandlers() as $handler) {
            $handler->pushProcessor(function (LogRecord $record): LogRecord {
                return $record->with(extra: [...$record->extra, 'host' => gethostname()]);
            });
        }
    }
}

4. Стек каналів - різні рівні в різні місця:

'stack' => ['driver' => 'stack', 'channels' => ['stderr', 'slack'], 'ignore_exceptions' => false],
'slack' => ['driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'level' => 'critical'],

Що ще важливо:

  • кореляція: Context::add('trace_id', ...) у middleware - і всі записи запиту та його завдань у черзі пов'язані одним ідентифікатором;
  • канал deprecations - окремо від основного логу, щоб попередження про застарілий код не тонули й не засмічували його;
  • рівень на продакшені - info чи warning; debug швидко генерує гігабайти;
  • локальні файли без ротації на сервері рано чи пізно заповнять диск - daily з days або зовнішній збирач;
  • логи - не сповіщення: про критичні помилки має повідомляти система моніторингу помилок, а лог - для розслідування.

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

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

1. Інтерфейс Responsable - об'єкт сам знає, як перетворитися на відповідь:

use Illuminate\Contracts\Support\Responsable;

final class CsvExport implements Responsable
{
    public function __construct(
        private iterable $rows,
        private string $filename,
    ) {}

    public function toResponse($request): StreamedResponse
    {
        return response()->streamDownload(function () {
            $out = fopen('php://output', 'w');
            foreach ($this->rows as $row) {
                fputcsv($out, $row);
            }
            fclose($out);
        }, $this->filename, ['Content-Type' => 'text/csv']);
    }
}
public function export(): CsvExport
{
    return new CsvExport(User::query()->cursor()->map->only('id', 'email'), 'users.csv');
}

Контролер повертає намір («ось експорт»), а не деталі HTTP. Так влаштовані API Resources, Uri, Inertia-відповіді.

Responsable з урахуванням формату запиту:

public function toResponse($request)
{
    return $request->expectsJson()
        ? response()->json(['id' => $this->order->id], 201)
        : to_route('orders.show', $this->order)->with('status', 'Замовлення створено');
}

Один контролер обслуговує і форму, і API.

2. Макроси відповідей - новий метод на фабриці response():

// AppServiceProvider::boot()
Response::macro('problem', function (string $title, int $status, array $extra = []) {
    return Response::json(
        ['type' => 'about:blank', 'title' => $title, 'status' => $status, ...$extra],
        $status,
        ['Content-Type' => 'application/problem+json'],
    );
});
return response()->problem('Недостатньо коштів', 422, ['balance' => $balance]);

Що обрати:

Responsable Макрос
де живе логіка у власному класі у сервіс-провайдері
тестування клас тестується окремо через HTTP-тест
автодоповнення в IDE повне потрібні PHPDoc чи ide-helper
для чого складні відповіді з даними короткі допоміжні методи формату

Ризики:

  • макроси глобальні й неявні: новий розробник не знайде, де визначено response()->problem(), а статичний аналіз їх не бачить без додаткових анотацій;
  • конфлікт імен з майбутніми методами фреймворку чи пакетів;
  • Responsable не повинен робити побічних дій (запис у базу, відправку листів) - він має лише формувати відповідь; інакше middleware чи тести, що створюють відповідь, викличуть побічні ефекти.

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

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

Задача: усі маршрути мають префікс {locale} чи {team}:

Route::prefix('{locale}')->group(function () {
    Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
    Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
});

Тепер кожен виклик route() вимагає locale: route('posts.show', ['locale' => app()->getLocale(), 'post' => $post]). Забули - виняток Missing required parameter. Посилань сотні.

URL::defaults - значення параметра за замовчуванням на весь запит:

final class SetUrlDefaults
{
    public function handle(Request $request, Closure $next): Response
    {
        URL::defaults(['locale' => $request->route('locale') ?? config('app.locale')]);

        return $next($request);
    }
}

Далі просто route('posts.show', $post) - locale підставиться сам.

Пастка з прив'язкою моделей. Якщо маршрут використовує неявну прив'язку моделей ({post}), middleware SubstituteBindings будує й перевіряє параметри маршруту. Якщо ваш middleware зі значеннями за замовчуванням виконується після нього, прив'язка спрацює раніше, ніж з'являться значення, - і ви отримаєте помилки на кшталт неправильних параметрів. Тому middleware ставлять у пріоритеті перед SubstituteBindings:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->prependToPriorityList(
        before: \Illuminate\Routing\Middleware\SubstituteBindings::class,
        prepend: \App\Http\Middleware\SetUrlDefaults::class,
    );
})

Де ще потрібні значення за замовчуванням:

  • черги, команди, листи - там немає поточного запиту й middleware не виконується. Завдання, яке генерує посилання для листа, має встановити URL::defaults саме (наприклад, з локалі отримувача) - інакше знову Missing required parameter;
  • Octane: значення за замовчуванням зберігаються в генераторі URL між запитами в тому самому воркері, тож middleware має встановлювати їх на кожен запит, а не лише коли значення змінилося;
  • тести: у тестах генерації посилань поза HTTP-запитом значення треба задати явно.

Альтернативи:

  • субдомени ({team}.app.com) - те саме з параметром домену в Route::domain('{team}.example.com');
  • локаль без префікса URL (з налаштувань користувача чи заголовка) - простіше для маршрутів, але гірше для SEO і кешування: одна адреса з різним вмістом.

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

Eloquent за замовчуванням поблажливий: багато помилок не падають, а мовчки дають неправильний результат. Model::shouldBeStrict() вмикає три перевірки одночасно:

// AppServiceProvider::boot()
Model::shouldBeStrict(! $this->app->isProduction());

1. preventLazyLoading - заборона лінивого завантаження зв'язків:

$posts = Post::all();
foreach ($posts as $post) {
    $post->author->name;   // LazyLoadingViolationException
}

Ловить N+1 у момент написання коду, а не після скарг на повільну сторінку. Виправлення - Post::with('author').

2. preventSilentlyDiscardingAttributes - помилка при масовому заповненні полями, яких немає в $fillable:

User::create(['name' => 'Olena', 'is_admin' => true]);   // MassAssignmentException

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

3. preventAccessingMissingAttributes - помилка при зверненні до атрибута, якого немає в моделі:

$user = User::select('id', 'name')->first();
$user->email;   // MissingAttributeException замість тихого null

Ловить запити з неповним select() і опечатки в назвах атрибутів.

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

Model::preventLazyLoading();

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
    if (app()->isProduction()) {
        Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation]);

        return;
    }

    throw new LazyLoadingViolationException($model, $relation);
});

Нюанси:

  • одна модель - лінивий виклик зв'язку для моделі, отриманої не в колекції, не вважається порушенням (N+1 там неможливий);
  • Model::automaticallyEagerLoadRelationships() - альтернативний підхід: Eloquent сам довантажує зв'язок для всієї колекції при першому зверненні;
  • увімкнення на старому проєкті покаже десятки порушень - вмикайте поступово й лагодьте, інакше команда просто вимкне перевірку;
  • тести - найкраще місце для суворого режиму: порушення в тестах ламають CI ще до рев'ю.

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

Традиційно модель налаштовують захищеними властивостями й методами booted(). Laravel поступово додає PHP-атрибути класу, що описують ту саму конфігурацію декларативно:

use Illuminate\Database\Eloquent\Attributes\{Table, Fillable, Hidden, ObservedBy, ScopedBy, UseFactory, CollectedBy};

#[Table('legacy_orders', key: 'order_id', timestamps: false)]
#[Fillable(['customer_id', 'total', 'status'])]
#[Hidden(['internal_note'])]
#[ObservedBy(OrderObserver::class)]
#[ScopedBy(TenantScope::class)]
#[UseFactory(OrderFactory::class)]
#[CollectedBy(OrderCollection::class)]
final class Order extends Model {}

Те саме через властивості й методи:

class Order extends Model
{
    protected $table = 'legacy_orders';
    protected $primaryKey = 'order_id';
    public $timestamps = false;
    protected $fillable = ['customer_id', 'total', 'status'];
    protected $hidden = ['internal_note'];

    protected static function booted(): void
    {
        static::observe(OrderObserver::class);
        static::addGlobalScope(new TenantScope);
    }
}

Що дають атрибути:

  • конфігурація відокремлена від поведінки: метадані над класом, а в тілі лишаються зв'язки, касти, бізнес-методи;
  • видно одразу, які спостерігачі й глобальні області видимості підключені до моделі - не треба шукати по booted() і сервіс-провайдерах;
  • немає побічних ефектів у booted() і ризику забути parent::booted() у спадкоємцях;
  • статичний аналіз: IDE й інструменти читають атрибути через Reflection без запуску коду.

Що варто знати:

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

Інші атрибути Laravel у тому самому стилі: #[UsePolicy] для зв'язку моделі з політикою, #[UseResource] для API Resource, #[Scope] для позначення методу локальною областю видимості без префікса scope.

Практично: для нових моделей атрибути зручніші; переписувати старі заради стилю немає сенсу - поведінка однакова.

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

Багато класів Laravel використовують трейт Macroable: Str, Stringable, Arr, Collection, Request, Builder, Http, Number, Uri та інші. Макрос додає метод «ззовні» без успадкування.

// AppServiceProvider::boot()
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;

Str::macro('phone', function (string $value): string {
    return preg_replace('/\D+/', '', $value);
});

Stringable::macro('phone', function (): Stringable {
    return new Stringable(Str::phone($this->value));
});

Str::phone('+38 (067) 123-45-67');        // '380671234567'
str('+38 (067) 123-45-67')->phone();      // Stringable

Важливі деталі:

  • Str і Stringable - різні класи: макрос на Str не з'являється у fluent-ланцюжку. Для обох стилів потрібні два макроси;
  • $this у замиканні прив'язується до екземпляра (для Stringable - до об'єкта з властивістю value), а в статичному виклику - ні;
  • mixin реєструє кілька макросів з класу, кожен публічний чи захищений метод якого повертає замикання:
Str::mixin(new StrMixin);

Ризики:

  1. Конфлікт з майбутніми методами фреймворку. Макроси викликаються через __call / __callStatic, тобто лише якщо справжнього методу немає. Якщо в наступній версії Laravel з'явиться Str::phone() з іншою поведінкою, ваш макрос мовчки перестане викликатися. Захист - префікси (Str::appPhone) і тести на поведінку макросів;
  2. Невидимість для інструментів: IDE й PHPStan не бачать макросів без додаткових анотацій чи ide-helper; автодоповнення зникає, а аналізатор лається на «невідомий метод»;
  3. Глобальний стан: макрос зареєстрований для всього процесу. У пакеті він може зіткнутися з макросом іншого пакета чи застосунку з тим самим ім'ям - перемагає той, хто зареєструвався останнім;
  4. Прихована логіка: бізнес-правила в макросах (Str::orderNumber()) важче знайти й тестувати, ніж у звичайному класі.

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

Коли краще звичайний клас чи value object: доменна логіка, щось із залежностями, чи метод, потрібний в одному модулі. PhoneNumber::fromString($raw)->normalized() явніший, тестується окремо й підтримується IDE без підказок.

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

Склеювання URL рядками - джерело тонких помилок: забутий ? чи &, подвійне кодування, втрачені параметри, // у шляху, неекранований пробіл.

// крихко
$url = $base . '/search?q=' . $query . '&page=' . $page;

Illuminate\Support\Uri (на основі бібліотеки League URI) дає незмінний (immutable) об'єкт для розбору й зміни адрес:

use Illuminate\Support\Uri;

$uri = Uri::of('https://example.com/search?q=php')
    ->withQuery(['page' => 2])          // злиття з наявними параметрами
    ->withFragment('results');

(string) $uri;   // 'https://example.com/search?q=php&page=2#results'

$uri->host();                 // 'example.com'
$uri->path();                 // 'search'
$uri->query()->get('q');      // 'php'
$uri->pathSegments();         // колекція сегментів

Змінення параметрів запиту:

Метод Що робить
withQuery([...]) додає й перезаписує параметри (merge: false - замінює всі)
withQueryIfMissing([...]) лише відсутні - зручно для значень за замовчуванням
withoutQuery(['utm_source']) прибрати параметри
replaceQuery([...]) замінити весь рядок запиту
pushOntoQuery('tags', 'php') додати значення до масиву tags[]

Кожен with... повертає новий об'єкт - вихідний не змінюється, тож базову адресу можна безпечно перевикористовувати.

Інтеграція з маршрутизацією:

Uri::route('posts.show', ['post' => $post]);
Uri::signedRoute('unsubscribe', ['user' => $user]);
Uri::temporarySignedRoute('download', now()->addHour(), ['file' => $file]);

return Uri::to('/dashboard')->withQuery(['tab' => 'billing'])->redirect();

Поточний запит: $request->uri() повертає той самий об'єкт, тож «поточна сторінка з іншим фільтром» будується без ручного розбору $_GET:

$request->uri()->withQuery(['sort' => 'price'])->withoutQuery(['page']);

Де це особливо важливо:

  • очищення URL від трекінгових параметрів перед збереженням чи порівнянням;
  • побудова посилань на сторонні API з набором параметрів;
  • перевірка редиректів: Uri::of($next)->host() замість регулярних виразів - щоб не відкрити відкритий редирект на чужий домен.

Обмеження: Uri не валідує, що адреса безпечна чи досяжна. Для SSRF-захисту потрібна окрема перевірка схеми, хоста й IP-адреси, до якої він резолвиться.

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

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

Рівні
Junior 101 Middle 147 Senior 130

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