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

Organizing Laravel 13 Business Logic with Services, Actions and DTOs

Lesson 16 — Services, Actions and DTOs: organize task creation so HTTP and console callers do not pass an entire Request into business code. Following Dependency Injection, we introduce a small Action and DTO without an architecture package.

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

Lesson 16 — Services, Actions and DTOs: organize task creation so HTTP and console callers do not pass an entire Request into business code. Following Dependency Injection, we introduce a small Action and DTO without an architecture package.

1. Define responsibilities before folders

TaskDeadline evaluates a rule. CreateTask executes one use case. CreateTaskData describes typed input. These are project conventions, not three mandatory Laravel layers. A small application does not need additional abstractions merely to rename methods.

The intended boundary is actor identification, accessible-project lookup, authorization, validation, DTO construction, Action invocation and response rendering. This milestone tests the application layer only. No public persistence endpoint is added and the preview remains non-persistent.

2. A typed DTO still needs invariants

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 prevents property reassignment after construction; it does not validate strings. The constructor rejects blank/overlong titles, excessive descriptions and unknown priorities. Ownership, assignment and status are deliberately absent so callers cannot change those decisions through arbitrary fields.

Form Requests still provide useful HTTP validation errors. DTO checks also protect other callers such as commands. Keep shared invariants consistent; extract common definitions if duplicated rules become complex. A string type alone does not establish valid business input.

3. Create within the supplied project

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',
        ]);
    }
}

The Action chooses todo, trims the title and creates through the project relationship. Unsaved projects are rejected, but persisted does not mean authorized. Caller authorization is an explicit precondition, not something this class silently implements.

One insert does not require an extra transaction wrapper merely for appearance. If a later audit write must be atomic with creation, choose that transaction boundary deliberately. External email or HTTP effects do not become transactional merely by placing them beside database writes.

4. Map fields explicitly at the boundary

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

This is integration pseudocode, not a complete controller. Validated input and an authorized project must actually exist. Do not unpack request->all() into the DTO or fetch a client-selected project without a policy. Naming a variable authorizedProject does not enforce permission.

The Action returns a model; future controllers choose redirects or JSON Resources while commands choose console output. Returning a redirect from the Action would couple it to HTTP. An Eloquent repository wrapper is also unnecessary without a concrete benefit.

5. Test real behavior

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

The test checks a real in-memory SQLite record rather than only a mocked create call. It also rejects invalid priority and unsaved projects. The full suite passed 40 tests with 132 assertions. Authorization, retry idempotency and notifications are not covered by these new tests.

6. Keep the limitations explicit

Calling this Action twice creates two tasks; it is not automatically idempotent. Readonly DTOs do not protect database records from other writers. The exists check does not lock the project; concurrent deletion may cause a constraint failure that callers must handle appropriately. Do not catch every exception and return a fabricated success.

Before exposing HTTP persistence, test unauthorized actors, ownership-changing payloads, validation failures and successful responses. Keeping initial status in the Action makes that choice consistent across callers. Clear responsibilities make testing easier to locate, not optional.

7. Exercise

Test whitespace-only and 121-character titles and an oversized description. Invoke the Action twice, explain the record count and propose an idempotency contract if required. Diagram the future controller-to-database flow and label authorization, validation, transaction and response rendering.

Navigation: Lesson 15 · Roadmap. Next comes Events, Listeners and Observers.

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.