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

Học Laravel 13 – Bài 07: Form Request, Validation và CSRF trong Laravel 13

Bài 7 — Form Request, Validation và CSRF: xây form xem trước task trên nền layout của bài 6. Form chưa lưu database và chưa có tài khoản; mục tiêu là hiểu đầu vào hợp lệ, phản hồi lỗi và bảo vệ request trước khi triển khai CRUD.

Form Request, Validation và CSRF trong Laravel 13

Bài 7 — Form Request, Validation và CSRF: xây form xem trước task trên nền layout của bài 6. Form chưa lưu database và chưa có tài khoản; mục tiêu là hiểu đầu vào hợp lệ, phản hồi lỗi và bảo vệ request trước khi triển khai CRUD.

1. Xác định contract trước khi viết form

Task xem trước có title bắt buộc, tối đa 120 ký tự; description tùy chọn, tối đa 2.000 ký tự; priority chỉ nhận low, normal hoặc high. Không nhận owner_id, is_admin hay trạng thái quản trị từ client. HTML required và maxlength hỗ trợ người dùng, nhưng server vẫn phải kiểm tra vì client có thể bỏ qua giao diện.

Validation không phải authorization. Dữ liệu đúng định dạng không có nghĩa người gửi được phép tạo task trong dự án của người khác. Ở bài này authorize() trả true vì đây là preview công khai không lưu; khi chuyển sang nghiệp vụ có database, quyền phải được thiết kế lại, không sao chép lựa chọn này một cách máy móc.

2. Tạo Form Request

php artisan make:request PreviewTaskRequest

Nội dung app/Http/Requests/PreviewTaskRequest.php:

<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class PreviewTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // Public preview only, not permission to create persisted tasks.
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'description' => ['nullable', 'string', 'max:2000'],
            'priority' => ['required', Rule::in(['low', 'normal', 'high'])],
        ];
    }
}

nullable cho phép mô tả rỗng sau khi middleware chuẩn hóa chuỗi rỗng thành null. Rule::in giới hạn tập giá trị thay vì chỉ kiểm tra chuỗi. Chưa dùng exists hoặc unique vì chưa có bảng nghiệp vụ. Đọc tài liệu validation khi bổ sung quy tắc khác.

3. Controller chỉ nhận dữ liệu đã kiểm tra

Tạo app/Http/Controllers/TaskPreviewController.php:

<?php
namespace App\Http\Controllers;
use App\Http\Requests\PreviewTaskRequest;
use Illuminate\Contracts\View\View;

class TaskPreviewController extends Controller
{
    public function create(): View
    {
        return view('tasks.create');
    }

    public function preview(PreviewTaskRequest $request): View
    {
        return view('tasks.preview', ['task' => $request->validated()]);
    }
}

Thêm import và hai route vào routes/web.php, giữ nguyên các route trước:

use App\Http\Controllers\TaskPreviewController;

Route::get('/tasks/create', [TaskPreviewController::class, 'create'])->name('tasks.create');
Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])->name('tasks.preview');

Form Request được giải quyết trước khi method preview chạy. validated() chỉ trả dữ liệu theo bộ quy tắc, không lấy toàn bộ request bằng all(). Controller trả trang xem trước trực tiếp; reload có thể khiến trình duyệt hỏi gửi lại POST. Vì không có ghi dữ liệu, bài này chưa cần mẫu redirect sau thao tác lưu.

4. Hiển thị form, lỗi và dữ liệu cũ

Tạo resources/views/tasks/create.blade.php:

<x-layout title="Preview a task">
    <h1>Preview a task</h1>
    <p>This form does not save a task.</p>
    <form method="POST" action="{{ route('tasks.preview') }}">
        @csrf
        <label for="title">Title</label>
        <input id="title" name="title" value="{{ old('title') }}" required maxlength="120" aria-describedby="title-error">
        <p id="title-error">@error('title') {{ $message }} @enderror</p>
        <label for="description">Description</label>
        <textarea id="description" name="description" maxlength="2000" aria-describedby="description-error">{{ old('description') }}</textarea>
        <p id="description-error">@error('description') {{ $message }} @enderror</p>
        <label for="priority">Priority</label>
        <select id="priority" name="priority" aria-describedby="priority-error">
            @foreach (['low', 'normal', 'high'] as $priority)
                <option value="{{ $priority }}" @selected(old('priority', 'normal') === $priority)>{{ ucfirst($priority) }}</option>
            @endforeach
        </select>
        <p id="priority-error">@error('priority') {{ $message }} @enderror</p>
        <button type="submit">Preview only</button>
    </form>
</x-layout>

Tạo resources/views/tasks/preview.blade.php:

<x-layout title="Task preview">
    <h1>Task preview — not saved</h1>
    <h2>{{ $task['title'] }}</h2>
    <p>{{ $task['description'] ?? 'No description' }}</p>
    <p>Priority: {{ $task['priority'] }}</p>
    <a href="{{ route('tasks.create') }}">Start another preview</a>
</x-layout>

old() khôi phục dữ liệu sau validation thất bại, @error hiển thị lỗi tương ứng. Không dùng old() để điền lại mật khẩu hoặc secret. Blade vẫn escape title và description: qua validation không biến một chuỗi HTML thành nội dung đáng tin để xuất thô.

5. Hiểu đúng CSRF trong Laravel 13

@csrf sinh field token gắn với session. Route web có middleware bảo vệ request. Laravel 13 dùng PreventRequestForgery, có kiểm tra origin dựa trên Sec-Fetch-Site và nhánh token dự phòng. Vì vậy không nên khẳng định mọi POST thiếu token đều luôn trả 419: request được chấp nhận bởi nhánh origin có hành vi khác. Tham khảo CSRF protection.

Giữ @csrf cho form, không loại trừ route khỏi middleware để chữa lỗi. Nếu gặp 419, kiểm tra session cookie, hostname, HTTPS và thời hạn phiên. CSRF không thay thế đăng nhập, policy hay chống XSS. Một yêu cầu AJAX mong đợi JSON có thể nhận 422 khi validation sai; POST form thông thường được redirect cùng error bag.

6. Test validation và nhánh token riêng biệt

Tạo tests/Feature/TaskPreviewTest.php với các kiểm thử cốt lõi sau:

<?php
namespace Tests\Feature;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Tests\TestCase;

class TaskPreviewTest extends TestCase
{
    public function test_invalid_form_redirects_with_errors_and_old_input(): void
    {
        $this->from('/tasks/create')->post('/tasks/preview', ['title' => 'Keep me', 'priority' => 'urgent'])
            ->assertRedirect('/tasks/create')->assertSessionHasErrors('priority')
            ->assertSessionHasInput('title', 'Keep me');
    }

    public function test_invalid_json_returns_422(): void
    {
        $this->postJson('/tasks/preview', ['title' => str_repeat('x', 121), 'priority' => 'high'])
            ->assertUnprocessable()->assertJsonValidationErrors('title');
    }

    public function test_csrf_fallback_rejects_missing_token_and_accepts_matching_token(): void
    {
        $this->app->bind(PreventRequestForgery::class, EnforcedRequestForgery::class);
        $this->post('/tasks/preview', ['title' => 'Review', 'priority' => 'normal'])->assertStatus(419);
        $this->withSession(['_token' => 'test-session-token'])
            ->post('/tasks/preview', ['_token' => 'test-session-token', 'title' => 'Review', 'priority' => 'normal'])
            ->assertOk();
    }
}

class EnforcedRequestForgery extends PreventRequestForgery
{
    protected function runningUnitTests()
    {
        return false;
    }
}

Laravel thông thường bỏ qua CSRF khi chạy test. Lớp EnforcedRequestForgery chỉ dùng trong test để tắt sự bỏ qua đó; không thêm lớp này vào app hoặc thay middleware production. Request test không gửi Sec-Fetch-Site nên bài kiểm tra đi vào nhánh token. Token là chuỗi giả, không phải secret của ứng dụng.

npm run build
php artisan test --filter=TaskPreviewTest
php artisan test

Bản thực hành đầy đủ còn kiểm tra form có field _token và preview chỉ nhận field đã validate, đồng thời escape title. Toàn suite đã pass 14 test, 39 assertions. Con số này không phải số assertion của riêng đoạn test rút gọn ở trên.

7. Bài tập và điều kiện hoàn thành

Mở /tasks/create, thử bỏ title, gửi priority=urgent bằng công cụ HTTP, và gửi title có thẻ HTML. Phân biệt 302 của form sai, 422 của JSON sai và 419 của nhánh token không hợp lệ. Kiểm tra dữ liệu cũ được giữ sau lỗi, nhưng không có dòng task nào được ghi vào database.

Khi tự mở rộng test, thêm title 120 và 121 ký tự, mô tả rỗng và field ngoài contract. Không dùng preview công khai này làm endpoint lưu thực tế trước khi bổ sung auth, ownership và policy. Điều hướng: Bài 6 · Lộ trình. Bài tiếp theo thiết kế migration và rollback an toàn.

Điều hướng khóa học Laravel 13

Bài trước (06) · Bài sau (08) · Mục lục trọn bộ 37 bài

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.