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

Học Laravel 13 – Bài 16: Tổ chức nghiệp vụ Laravel 13 với Service, Action và DTO

Bài 16 — Service, Action và DTO: tổ chức thao tác tạo task sao cho có thể dùng từ HTTP hoặc command mà không mang theo toàn bộ Request. Sau bài Dependency Injection, chúng ta thêm một Action và DTO nhỏ, không cài package kiến trúc.

Tổ chức nghiệp vụ Laravel 13 với Service, Action và DTO

Bài 16 — Service, Action và DTO: tổ chức thao tác tạo task sao cho có thể dùng từ HTTP hoặc command mà không mang theo toàn bộ Request. Sau bài Dependency Injection, chúng ta thêm một Action và DTO nhỏ, không cài package kiến trúc.

1. Chọn trách nhiệm trước khi chọn tên thư mục

TaskDeadline ở bài 15 là service tính một quy tắc. CreateTask ở bài này là Action thực hiện một use case. CreateTaskData chứa đầu vào có kiểu. Đây là quy ước của dự án, không phải ba lớp Laravel bắt buộc mọi ứng dụng phải có. Một ứng dụng nhỏ không cần thêm tầng chỉ để đổi tên method.

Luồng dự kiến: xác định người dùng → tìm project được phép → authorize → validate dữ liệu → tạo DTO → gọi Action → chuyển kết quả thành response. Hiện TaskFlow mới kiểm thử từ tầng ứng dụng; chưa thêm endpoint lưu công khai. Form preview vẫn giữ hành vi không lưu.

2. DTO có kiểu và ràng buộc tối thiểu

app/Data/CreateTaskData.php

<?php

namespace App\Data;

use InvalidArgumentException;

final readonly class CreateTaskData
{
    public function __construct(
        public string $title,
        public ?string $description = null,
        public string $priority = 'normal',
    ) {
        if (trim($title) === '' || mb_strlen($title) > 120) {
            throw new InvalidArgumentException('Title must contain 1 to 120 characters.');
        }
        if ($description !== null && mb_strlen($description) > 2000) {
            throw new InvalidArgumentException('Description exceeds 2000 characters.');
        }
        if (! in_array($priority, ['low', 'normal', 'high'], true)) {
            throw new InvalidArgumentException('Unsupported priority.');
        }
    }
}

Readonly ngăn gán lại các property sau khởi tạo; không tự validate chuỗi. Constructor kiểm tra title không trắng và tối đa 120 ký tự, mô tả tối đa 2.000 ký tự, priority theo danh sách. DTO không chứa project_id, owner_id, assignee_id hay status để người gọi không tùy ý thay các quyết định đó qua một mảng.

Form Request vẫn cần thông báo lỗi phù hợp HTTP. DTO kiểm tra lại những invariant cần cho caller khác như command. Đây là sự lặp có chủ đích nhưng phải được giữ nhất quán; khi rule tăng phức tạp, tách nguồn định nghĩa dùng chung thay vì để hai nơi lệch nhau. Không coi type string là bằng chứng dữ liệu hợp lệ.

3. Action tạo task trong project được truyền vào

app/Actions/CreateTask.php

<?php

namespace App\Actions;

use App\Data\CreateTaskData;
use App\Models\Project;
use App\Models\Task;
use InvalidArgumentException;

final class CreateTask
{
    // The caller must authorize access to the project before invoking this action.
    public function handle(Project $project, CreateTaskData $data): Task
    {
        if (! $project->exists) {
            throw new InvalidArgumentException('A persisted project is required.');
        }

        return $project->tasks()->create([
            'title' => trim($data->title),
            'description' => $data->description,
            'priority' => $data->priority,
            'status' => 'todo',
        ]);
    }
}

Action gán status=todo, trim title và tạo qua project->tasks(). Project chưa lưu bị từ chối trước thao tác ghi. Project đã lưu vẫn có thể không được caller phép truy cập; exists không thay policy. Precondition authorize được ghi rõ để tránh hiểu nhầm class này tự bảo vệ mọi caller.

Hiện chỉ có một insert nên không thêm transaction bao bọc cho đủ hình thức. Nếu sau này thao tác ghi thêm audit record cần tính nguyên tử, xác định ranh giới transaction trong Action hoặc lớp điều phối. Không thêm email/HTTP call vào cùng transaction chỉ vì nó thuộc use case tạo task.

4. Ánh xạ field rõ ràng ở ranh giới

$data = new CreateTaskData(
    title: $validated['title'],
    description: $validated['description'] ?? null,
    priority: $validated['priority'],
);
$task = $createTask->handle($authorizedProject, $data);

Đây là đoạn ghép minh họa, chưa phải controller hoàn chỉnh. $validated đến từ validation; $authorizedProject phải là project đã kiểm tra quyền. Không dùng new DTO(...$request->all()) và không lấy Project::find($request->project_id) rồi bỏ qua policy. Tên biến “authorized” tự nó không thực thi bảo mật.

Action trả model; controller tương lai quyết định redirect hoặc Resource JSON. Command quyết định thông báo console. Không trả redirect từ Action vì như vậy nghiệp vụ bị buộc vào HTTP. Cũng không cần repository bọc toàn bộ Eloquent khi chưa có lợi ích rõ ràng.

5. Kiểm thử hành vi thay vì chỉ mock method

tests/Feature/CreateTaskActionTest.php

<?php

namespace Tests\Feature;

use App\Actions\CreateTask;
use App\Data\CreateTaskData;
use App\Models\Project;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Tests\TestCase;

class CreateTaskActionTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
    }

    public function test_action_persists_explicit_data_in_the_supplied_project(): void
    {
        $project = Project::factory()->create();
        $task = app(CreateTask::class)->handle($project, new CreateTaskData(' Review change ', priority: 'high'));
        $this->assertDatabaseHas('tasks', [
            'id' => $task->id, 'project_id' => $project->id,
            'title' => 'Review change', 'priority' => 'high', 'status' => 'todo', 'assignee_id' => null,
        ]);
    }

    public function test_dto_rejects_invalid_priority_before_persistence(): void
    {
        $this->expectException(InvalidArgumentException::class);
        new CreateTaskData('Review', priority: 'urgent');
    }

    public function test_action_rejects_an_unsaved_project(): void
    {
        $this->expectException(InvalidArgumentException::class);
        app(CreateTask::class)->handle(new Project, new CreateTaskData('Review'));
    }
}
php artisan test --filter=CreateTaskActionTest
php artisan test

Test dùng SQLite bộ nhớ và thực sự kiểm tra record đã ghi, không chỉ assert gọi create một lần. Nó còn kiểm tra priority sai và project chưa lưu. Toàn suite pass 40 test, 132 assertions. Những test này chưa kiểm chứng authorization, retry idempotency hoặc gửi notification.

6. Những giới hạn cần giữ trong đầu

Gọi Action hai lần sẽ tạo hai task; Action không tự idempotent. DTO readonly không ngăn record database bị người khác sửa. Kiểm tra exists không khóa project trước insert; nếu bị xóa cạnh tranh, database constraint có thể từ chối và caller cần xử lý phù hợp. Không catch mọi exception rồi trả model giả để che lỗi.

Khi mở endpoint thật, cần thêm test người không có quyền, payload cố đổi ownership, validation sai và phản hồi thành công. Quy tắc trạng thái ban đầu nên kiểm thử ở Action như hiện tại để mọi caller đều dùng cùng lựa chọn. Sự phân chia giúp tìm nơi sửa dễ hơn, không làm biến mất trách nhiệm kiểm thử.

7. Bài tập

Thêm test title toàn khoảng trắng, title 121 ký tự và mô tả quá dài. Thử gọi Action hai lần và giải thích số record; đề xuất contract idempotency nếu use case yêu cầu. Vẽ luồng xử lý từ controller tương lai đến database và đánh dấu nơi authorize, validate, transaction và render response.

Điều hướng: Bài 15 · Lộ trình. Bài tiếp theo bổ sung Event, Listener và Observer.

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.