Lập trình · 22/09/2026

Học Laravel 13 – Bài 18: Xử lý Exception và lỗi API trong Laravel 13

Bài 18 — Exception và lỗi API: TaskFlow cần trả lỗi có ý nghĩa khi người dùng hoàn thành một task đã hoàn thành. Chúng ta thêm Action đổi trạng thái, một exception riêng và kiểm thử HTTP để phân biệt xung đột nghiệp vụ với lỗi lập trình.

Xử lý Exception và lỗi API trong Laravel 13

Bài 18 — Exception và lỗi API: TaskFlow cần trả lỗi có ý nghĩa khi người dùng hoàn thành một task đã hoàn thành. Chúng ta thêm Action đổi trạng thái, một exception riêng và kiểm thử HTTP để phân biệt xung đột nghiệp vụ với lỗi lập trình.

1. Xác định contract trước khi catch

Ví dụ này cho phép chuyển todo hoặc doing sang done. Gọi lại khi task đã done trả 409, không âm thầm coi là thành công. Đó là lựa chọn contract của bài học, không phải quy tắc bắt buộc cho mọi API: một API idempotent có thể chọn trả trạng thái hiện tại. Quan trọng là client biết điều gì xảy ra và test giữ nguyên lựa chọn đó.

Validation sai vẫn là 422 kèm errors theo field; tài nguyên không tìm thấy là 404; lỗi không dự kiến là 500. Không catch Throwable rồi biến mọi lỗi thành 200 hoặc 422. Nếu database mất kết nối, người dùng sửa title không giải quyết được vấn đề, và hệ thống giám sát cũng cần thấy lỗi máy chủ.

2. Exception nghiệp vụ không phụ thuộc HTTP

app/Exceptions/TaskStateConflict.php

<?php

namespace App\Exceptions;

use RuntimeException;

final class TaskStateConflict extends RuntimeException
{
    public function __construct()
    {
        parent::__construct('The task state changed. Refresh and try again.');
    }
}

app/Actions/CompleteTask.php

<?php

namespace App\Actions;

use App\Exceptions\TaskStateConflict;
use App\Models\Task;

final class CompleteTask
{
    // Caller must authorize the task. No public write route is added here.
    public function handle(Task $task): void
    {
        if (! $task->exists || Task::query()->whereKey($task->id)
            ->whereIn('status', ['todo', 'doing'])->update(['status' => 'done']) !== 1) {
            throw new TaskStateConflict;
        }

        $task->refresh();
    }
}

CompleteTask dùng một UPDATE có điều kiện trạng thái. Chỉ khi đúng một dòng được đổi thì mới refresh model. Không kiểm tra status trên model cũ rồi save vô điều kiện; dữ liệu trong bộ nhớ có thể đã lỗi thời. Gọi lại trên task done ném TaskStateConflict. Model chưa lưu cũng bị từ chối.

Action không tự authorize; caller phải kiểm tra quyền trước. Chưa có endpoint ghi công khai ở mốc này. Task bị xóa sau lúc tải có thể cho kết quả 409 ở Action, trong khi controller tương lai tìm không thấy ban đầu sẽ trả 404. Nếu có tính năng mở lại task, điều kiện status không phát hiện mọi thay đổi trung gian; cần version khi contract yêu cầu optimistic locking đầy đủ.

UPDATE qua query không gọi observer từng model như bài 17 đã chứng minh. Action này chưa phát TaskCompleted; nếu cần notification phải bổ sung event rõ ràng và test tương ứng. Refresh đọc trạng thái sau UPDATE, không phải ảnh chụp bất biến trước mọi thao tác cạnh tranh.

3. Chuyển exception thành phản hồi ở ranh giới HTTP

bootstrap/app.php

<?php

use App\Exceptions\TaskStateConflict;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
        $exceptions->dontReport([TaskStateConflict::class]);
        $exceptions->render(function (TaskStateConflict $exception, Request $request) {
            if ($request->is('api/*') || $request->expectsJson()) {
                return response()->json([
                    'message' => 'The task state changed. Refresh and try again.',
                    'code' => 'task_state_conflict',
                ], 409);
            }

            return response('The task state changed. Refresh and try again.', 409);
        });
    })->create();

Renderer dùng message công khai cố định và code=task_state_conflict để client xử lý mà không so khớp câu tiếng Anh. Request dưới api/* luôn nhận JSON, kể cả thiếu Accept; request khác nhận JSON nếu yêu cầu, hoặc response văn bản 409. Đây chưa phải giao diện lỗi HTML được thiết kế riêng.

Chỉ exception xung đột dự kiến được dontReport để tránh làm nhiễu log lỗi. Không bỏ report RuntimeException hoặc lỗi database. Reporting phục vụ người vận hành; rendering phục vụ client. Hai việc khác nhau: che chi tiết khỏi response không có nghĩa bỏ ghi nhận lỗi trong hệ thống nội bộ có kiểm soát truy cập.

4. Không làm mất thông tin lỗi chuẩn

Chúng ta không thay toàn bộ exception handler. Validation vẫn giữ message và errors, 404 giữ status, lỗi bất ngờ giữ 500. Contract code tùy chỉnh chỉ áp dụng cho TaskStateConflict; đừng nói mọi response lỗi đều có code. Khi thêm authentication, cần test riêng 401/403; khi thêm rate limit cần giữ 429 và Retry-After.

Production phải đặt APP_DEBUG=false và xây lại config cache trong quy trình deploy. Không gửi stack trace, SQL, đường dẫn máy chủ hoặc nội dung exception tùy ý cho client. Đồng thời phải xem xét dữ liệu nhạy cảm trong log: message của thư viện có thể chứa thông tin không nên chia sẻ công khai.

5. Kiểm thử qua handler thật

tests/Feature/TaskErrorHandlingTest.php

<?php

namespace Tests\Feature;

use App\Actions\CompleteTask;
use App\Exceptions\TaskStateConflict;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use RuntimeException;
use Tests\TestCase;

class TaskErrorHandlingTest extends TestCase
{
    public function test_api_conflict_has_a_stable_code_without_accept_header(): void
    {
        Route::get('/api/_test/conflict', fn () => throw new TaskStateConflict);
        $this->get('/api/_test/conflict')->assertStatus(409)->assertExactJson([
            'message' => 'The task state changed. Refresh and try again.',
            'code' => 'task_state_conflict',
        ]);
    }

    public function test_validation_and_missing_routes_keep_their_status(): void
    {
        Route::post('/api/_test/validate', fn (Request $request) => $request->validate([
            'title' => ['required', 'string', 'max:120'],
        ]));
        $this->postJson('/api/_test/validate', [])->assertUnprocessable()
            ->assertJsonValidationErrors('title');
        $this->get('/api/_test/missing')->assertNotFound()->assertJsonStructure(['message']);
    }

    public function test_unexpected_failure_is_redacted_with_debug_disabled(): void
    {
        config(['app.debug' => false]);
        Route::get('/api/_test/failure', fn () => throw new RuntimeException('private-database-detail'));
        $this->get('/api/_test/failure')->assertStatus(500)
            ->assertExactJson(['message' => 'Server Error'])
            ->assertDontSee('private-database-detail');
    }

    public function test_html_conflict_is_not_a_success_or_redirect(): void
    {
        Route::get('/_test/conflict', fn () => throw new TaskStateConflict);
        $this->get('/_test/conflict')->assertStatus(409)
            ->assertSeeText('The task state changed.');
    }

    public function test_completion_rejects_a_repeated_transition(): void
    {
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        $task = Task::factory()->create();
        app(CompleteTask::class)->handle($task);
        $this->assertSame('done', $task->status);
        $this->expectException(TaskStateConflict::class);
        app(CompleteTask::class)->handle($task);
    }
}
php artisan test --filter=TaskErrorHandlingTest
php artisan test

Các route _test chỉ được tạo trong test, không có trong routes/web.php của ứng dụng. Không tạo endpoint ném lỗi công khai để thử trên production. Test 500 tắt debug và kiểm tra JSON chính xác chỉ có Server Error; test validation giữ lỗi title, test conflict kiểm tra code và 409 cả JSON lẫn HTML.

Test Action chạy SQLite bộ nhớ và kiểm tra lần hoàn thành thứ hai bị từ chối. Đây là test tuần tự, không phải benchmark hoặc kiểm thử hai connection cạnh tranh. Bộ test tại mốc bài 18 pass 48 test, 158 assertions. Chưa có test HTTP authorization vì endpoint thật sẽ được ghép sau bài policy.

6. Bài tập

Thêm test task ở trạng thái doing chuyển sang done, task bị xóa trước khi gọi Action, và request ngoài api/* có Accept: application/json. Thiết kế client hiển thị thông báo tải lại khi nhận task_state_conflict, nhưng không tự retry POST tạo task sau lỗi 500. Giải thích vì sao retry phải phụ thuộc contract idempotency.

Tham khảo Laravel Error Handling. Điều hướng: Bài 17 · Lộ trình. Tiếp theo: cache, rate limit và atomic lock.

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.