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: великі об'єкти й моделі - контекст серіалізується з кожним завданням і пишеться в кожен рядок логу. Ідентифікатори, а не сутності.
Не кожна помилка має зупиняти запит. Якщо не вдалося оновити рекомендації чи відправити аналітику, користувач усе одно має отримати сторінку - але розробник має про це дізнатися.
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 - майже завжди баг.