مقاله

Error Handling در API Laravel — پاسخ JSON یکدست و قابل پیش‌بینی

Error Handling در API Laravel — پاسخ JSON یکدست و قابل پیش‌بینی

فرمت خطای API، Handler سفارشی، 422 validation، 404 و 500 — تجربه developer-friendly برای مصرف‌کننده API.

API بدون قرارداد خطا = کلاینت crash یا parse error. اپ موبایل باید بداند 422 یعنی ورودی نامعتبر، 401 یعنی login دوباره، 500 یعنی «بعداً تلاش کن» — نه اینکه stack trace PHP دریافت کند. در Laravel 11 و 12 تنظیم exception handling در bootstrap/app.php و داخل withExceptions() متمرکز شده و فایل جدای Handler در اسکلت جدید وجود ندارد.

خلاصه: خطای API باید status درست، بدنهٔ JSON با شکل ثابت و پیامی امن برای کاربر داشته باشد، و جزئیات فنی فقط در لاگ بماند. Laravel بخش زیادی از این کار را خودش انجام می‌دهد: 422 برای validation، 401 برای احراز هویت، 403 برای مجوز، 404 برای مدل پیدانشده و 500 با پیام عمومی وقتی APP_DEBUG=false است. کار شما یکدست کردن این قرارداد، تعریف خطاهای کسب‌وکاری و جلوگیری از نشت اطلاعات است. صفحه‌بندی و فیلتر موضوع Pagination و Filtering در API است و احراز هویت توکنی در Sanctum بررسی شده.

فرمت پیشنهادی پاسخ خطا

{
  "message": "The email field is required.",
  "errors": {
    "email": ["The email field is required."]
  }
}

برای خطای عمومی:

{
  "message": "Post not found."
}

این همان شکلی است که Laravel به‌طور پیش‌فرض تولید می‌کند. اگر کلاینت باید بین خطاهای کسب‌وکاری تفاوت بگذارد، یک کلید ثابت و ماشین‌خوان مثل "code": "insufficient_stock" اضافه کنید تا کلاینت به متن پیام (که ممکن است ترجمه یا اصلاح شود) وابسته نباشد. استاندارد Problem Details (RFC 9457، جانشین RFC 7807) هم گزینه است، اما با پکیج یا handler سفارشی؛ مهم‌تر از انتخاب قالب، ثابت ماندن آن در کل API است.

API یا مرورگر — Laravel از کجا می‌فهمد JSON برگرداند؟

به‌طور پیش‌فرض Laravel وقتی خطا را JSON برمی‌گرداند که $request->expectsJson() درست باشد — معمولاً یعنی کلاینت هدر Accept: application/json فرستاده. اگر کلاینت این هدر را نفرستد، همان خطا برای مرورگر رندر می‌شود: صفحهٔ HTML خطا یا redirect به صفحهٔ login. برای اینکه همهٔ مسیرهای api/* همیشه JSON بگیرند:

// bootstrap/app.php
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
        return $request->is('api/*') || $request->expectsJson();
    });
})

Validation 422

در درخواست JSON، شکست validation — چه با $request->validate() چه با Form Request — خودش پاسخ 422 با کلیدهای message و errors برمی‌گرداند و override لازم نیست. فقط اگر قالب متفاوتی می‌خواهید، در Form Request:

protected function failedValidation(Validator $validator): void
{
    throw new HttpResponseException(response()->json([
        'message' => __('Validation failed.'),
        'errors' => $validator->errors(),
    ], 422));
}

اگر این کار را می‌کنید، برای همهٔ Form Requestها یکسان انجامش دهید (مثلاً در یک کلاس پایه)؛ وگرنه بعضی endpointها قالب پیش‌فرض و بعضی قالب سفارشی برمی‌گردانند. جزئیات rule و پیام‌ها در Form Request پیشرفته آمده.

401 یا 403 — احراز هویت در برابر مجوز

  • 401 Unauthenticated: کاربر شناخته نشده — توکن نیست، منقضی شده یا نامعتبر است. Laravel برای AuthenticationException در درخواست JSON پاسخ {"message": "Unauthenticated."} با status 401 می‌دهد. کلاینت باید دوباره login کند.
  • 403 Forbidden: کاربر شناخته شده اما اجازهٔ این کار را ندارد — مثلاً Policy رد کرده. login دوباره مشکل را حل نمی‌کند. منطق مجوز در Policy و Authorization توضیح داده شده.

یک خطای رایج: کلاینت API هدر Accept نمی‌فرستد، Laravel درخواست را مرورگری فرض می‌کند و به route لاگین redirect می‌کند؛ اگر چنین routeی نباشد، خطای «Route [login] not defined» می‌بینید. shouldRenderJsonWhen بالا این مشکل را برای api/* حل می‌کند.

ModelNotFoundException → 404

findOrFail و route model binding وقتی رکورد پیدا نشود ModelNotFoundException می‌اندازند و Laravel آن را به 404 تبدیل می‌کند. یک نکتهٔ امنیتی: پیام پیش‌فرض این خطا شامل نام کلاس مدل و شناسه است (مثل No query results for model [App\Models\Post] 12) و چون به exception HTTP تبدیل می‌شود، حتی با APP_DEBUG=false همین پیام به کلاینت می‌رسد. برای API پیام عمومی برگردانید:

use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

$exceptions->render(function (NotFoundHttpException $e, Request $request) {
    if ($request->is('api/*')) {
        return response()->json(['message' => 'Resource not found.'], 404);
    }
});

Handler سفارشی

ترتیب کار Laravel هنگام render خطا مهم است:

  1. اگر خود exception متد render() داشته باشد و پاسخ برگرداند، همان استفاده می‌شود.
  2. بعد چند تبدیل انجام می‌شود: ModelNotFoundException به NotFoundHttpException و AuthorizationException به AccessDeniedHttpException (یا HttpException با status خودش).
  3. سپس callbackهای render() شما به ترتیب ثبت اجرا می‌شوند؛ callback بر اساس نوع پارامتر اول انتخاب می‌شود و اگر null برگرداند، نوبت بعدی است.
  4. در آخر رفتار پیش‌فرض: 401، 422 یا پاسخ عمومی.

دو نتیجهٔ عملی: callback با نوع AuthorizationException عملاً اجرا نمی‌شود چون قبلش تبدیل شده — باید AccessDeniedHttpException را بگیرید. و callback با نوع Throwable همه‌چیز را می‌گیرد، از جمله 401، 404 و 422؛ اگر بی‌شرط 500 برگرداند، همهٔ این‌ها را خراب می‌کند.

// bootstrap/app.php
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (AccessDeniedHttpException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }
    });

    $exceptions->render(function (Throwable $e, Request $request) {
        if (! $request->is('api/*') || config('app.debug')) {
            return null;
        }

        if ($e instanceof HttpExceptionInterface
            || $e instanceof ValidationException
            || $e instanceof AuthenticationException
            || $e instanceof HttpResponseException) {
            return null; // رفتار پیش‌فرض Laravel
        }

        return response()->json(['message' => 'Server error.'], 500);
    });
})

بدون callback دوم هم Laravel در production برای خطای غیرمنتظره فقط {"message": "Server Error"} برمی‌گرداند؛ این callback فقط وقتی لازم است که متن یا کلیدهای دیگری (مثل شناسهٔ درخواست) بخواهید. production: هرگز trace به کلاینت — APP_DEBUG=false. با APP_DEBUG=true، پاسخ JSON خطا شامل نام کلاس exception، مسیر فایل، شمارهٔ خط و trace است.

کدهای status رایج

کدمعنینمونه در Laravel
200/201موفق / ایجاد—
204حذف موفق بدون bodyresponse()->noContent()
400درخواست بدشکل که به validation نمی‌رسدabort(400) برای ورودی غیرقابل تفسیر — طبق قاعدهٔ تیم
401احراز هویت نشدهAuthenticationException
403مجوز نداردPolicy یا Gate رد کرده
404منبع نیستfindOrFail، route model binding
409تعارض با وضعیت فعلی منبعموجودی کافی نیست، رکورد تکراری، تغییر هم‌زمان
422validationValidationException
429rate limitThrottleRequestsException با هدر Retry-After
500خطای سرورexception پیش‌بینی‌نشده

مرز 400 و 422 در تیم‌های مختلف فرق می‌کند؛ قاعده را یک بار تعیین و مستند کنید. پاسخ موفق‌نما برای خطا (status 200 با "success": false) کار کلاینت، cache و مانیتورینگ را سخت می‌کند.

خطای قابل انتظار در برابر exception غیرمنتظره

«موجودی کافی نیست» یا «این کد تخفیف منقضی شده» باگ نیست؛ بخشی از قواعد کسب‌وکار است و باید پاسخ مشخص (معمولاً 409 یا 422) بگیرد و لازم نیست مثل خطای سرور لاگ شود. در مقابل، قطع اتصال دیتابیس یا null غیرمنتظره باگ است: کاربر پیام عمومی می‌گیرد و جزئیات کامل در لاگ ثبت می‌شود.

Exception خودتان

use Illuminate\Contracts\Debug\ShouldntReport;

class InsufficientStockException extends Exception implements ShouldntReport
{
    public function render(Request $request)
    {
        if (! $request->expectsJson()) {
            return false; // رندر پیش‌فرض برای مرورگر
        }

        return response()->json([
            'message' => 'Insufficient stock.',
            'code' => 'insufficient_stock',
        ], 409);
    }
}

interface ShouldntReport می‌گوید این خطا لاگ نشود. برای خطاهای ساده abort(409, 'Insufficient stock.') هم کافی است؛ اما دقت کنید پیام abort() در production هم به کلاینت می‌رسد، پس اطلاعات داخلی در آن ننویسید. اصول کلی exception در PHP: مدیریت خطای حرفه‌ای در PHP.

Logging

در Laravel «report» یعنی ثبت خطا (لاگ یا سرویس‌هایی مثل Sentry) و «render» یعنی پاسخی که کاربر می‌بیند؛ این دو را جدا نگه دارید. برای ردیابی، به هر درخواست یک شناسه بدهید و همان را در هدر پاسخ برگردانید تا پشتیبانی بتواند خطای گزارش‌شدهٔ کاربر را در لاگ پیدا کند:

// در یک middleware
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;

$requestId = (string) Str::uuid();
Context::add('request_id', $requestId);

$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);

return $response;

داده‌هایی که با Context (از Laravel 11) اضافه می‌شوند به‌عنوان metadata به لاگ‌های همان درخواست اضافه می‌شوند؛ Log::withContext() هم برای کانال لاگ همین کار را می‌کند. اگر پروژه پشت load balancer است که خودش شناسهٔ درخواست می‌فرستد، از همان استفاده کنید.

پیام امن در production

  • متن exception داخلی، query SQL، مسیر فایل و نام کلاس به کلاینت نرسد.
  • password، توکن، هدر Authorization و اطلاعات شخصی را در context لاگ نگذارید.
  • پیام کاربر کوتاه و قابل اقدام باشد: «دوباره تلاش کنید» یا «با پشتیبانی تماس بگیرید و این شناسه را بدهید».
  • بعد از deploy یک درخواست خطادار واقعی بزنید و مطمئن شوید trace برنمی‌گردد.

تست پاسخ خطا

public function test_store_returns_validation_errors(): void
{
    $user = User::factory()->create();

    $this->actingAs($user)
        ->postJson('/api/posts', [])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['title'])
        ->assertJsonStructure(['message', 'errors']);
}

public function test_guest_gets_401_json(): void
{
    $this->postJson('/api/posts', [])->assertUnauthorized();
}

public function test_missing_post_returns_generic_404(): void
{
    $this->getJson('/api/posts/999999')
        ->assertNotFound()
        ->assertJsonPath('message', 'Resource not found.')
        ->assertJsonMissingPath('trace');
}

postJson و getJson هدر Accept: application/json را خودشان می‌فرستند. در این تست‌ها withoutExceptionHandling() را صدا نزنید، چون handler را دور می‌زند و دیگر پاسخ واقعی خطا را تست نمی‌کنید. مسیرها و factoryها مثال‌اند؛ الگوی کامل در Feature Test در Laravel.

یکدستی در کل API

همهٔ تیم یک قالب: پاسخ‌های خطا از handler مرکزی بیایند، نه از try/catch پراکنده در controllerها. اگر helper یا trait برای خطا دارید، فقط برای خطاهای کسب‌وکاری باشد. Versioning — قالب خطا را بدون version bump عوض نکنید.

اشتباهات رایج

  • catch (Throwable $e) در controller و برگرداندن لیست خالی یا 200 — خطا پنهان می‌شود و هیچ‌جا لاگ نمی‌شود
  • برگرداندن $e->getMessage() به کلاینت
  • callback سراسری Throwable که 404 و 422 را هم 500 می‌کند
  • APP_DEBUG=true روی سرور عمومی
  • قالب خطای متفاوت در هر controller
  • استفاده از 403 برای کاربر لاگین‌نکرده یا 401 برای نبود مجوز
  • لاگ کردن توکن و password همراه request

جمع‌بندی

قرارداد خطا = بخشی از API spec: status درست، قالب JSON ثابت، پیام امن و جزئیات فقط در لاگ. Laravel پیش‌فرض‌های خوبی دارد؛ آن‌ها را با shouldRenderJsonWhen برای API قطعی کنید، 404 را از نام مدل پاک کنید، خطاهای کسب‌وکاری را با exception اختصاصی بسازید و همه را تست کنید. با REST و امنیت همراه deploy کنید.

API production-ready

اشتراک‌گذاری