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

Middle: питання на співбесіді з теми «Помилки й логування»

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

4 питання

У 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