Laravel Actions Pattern: Một Class, Một Nhiệm Vụ

· 10 min read

Bạn đã nghe "giữ controllers mỏng." Bạn được bảo dùng Services. Nhưng Services thường trở thành God classes — UserService với 40 methods xử lý mọi thứ từ đăng ký đến reset password.

Actions là giải pháp: một class cho mỗi thao tác. CreateUser, SendInvoice, PublishPost. Mỗi cái làm đúng một việc. Không framework, không package — chỉ là PHP classes thuần tuân theo một convention đơn giản.

Vấn Đề Với Services

class UserService
{
    public function register(array $data) { ... }
    public function login(array $data) { ... }
    public function updateProfile(User $user, array $data) { ... }
    public function changePassword(User $user, string $password) { ... }
    public function deleteAccount(User $user) { ... }
    public function exportData(User $user) { ... }
    // 30 methods nữa...
}

Vi phạm Single Responsibility. Khó test (phải mock cả class chỉ để test 1 method), khó navigate (scroll qua 500+ dòng), và phình mãi (mỗi feature mới thêm method vào đây).

Triệu chứng bạn cần refactor:

  • File service > 300 dòng
  • Constructor inject > 5 dependencies
  • Phải mock nhiều thứ để test một method
  • Tên service quá chung: UserService, OrderService

Actions: Một Class, Một Việc

// app/Actions/CreateUser.php

namespace App\Actions;

use App\Models\User;
use Illuminate\Support\Facades\Hash;

class CreateUser
{
    public function execute(string $name, string $email, string $password): User
    {
        return User::create([
            'name' => $name,
            'email' => $email,
            'password' => Hash::make($password),
        ]);
    }
}

Chỉ vậy thôi. Không interface. Không abstract class. Chỉ một PHP class thuần với method execute.

Convention:

  • Tên class = Động từ + Danh từ (CreateUser, PublishPost)
  • Một public method chính: execute() (một số team dùng handle() hoặc __invoke())
  • Parameters explicit, không array $data
  • Return type rõ ràng

Sử Dụng Actions

Trong Controllers

class RegistrationController extends Controller
{
    public function store(
        RegisterRequest $request,
        CreateUser $createUser,
        SendWelcomeEmail $sendWelcomeEmail,
    ) {
        $user = $createUser->execute(
            name: $request->name,
            email: $request->email,
            password: $request->password,
        );

        $sendWelcomeEmail->execute($user);
        Auth::login($user);

        return redirect('/dashboard');
    }
}

Controller chỉ làm ba việc: nhận request, gọi actions, trả response. Zero business logic. Đọc controller là đọc workflow, không đọc implementation.

Trong Artisan Commands

class ImportUsersCommand extends Command
{
    protected $signature = 'users:import {file}';

    public function handle(CreateUser $createUser): int
    {
        $rows = // ... đọc CSV

        foreach ($rows as $row) {
            $createUser->execute($row['name'], $row['email'], $row['password']);
        }

        return Command::SUCCESS;
    }
}

Lợi ích: Cùng CreateUser action dùng trong controller, command, job, test, seeder — logic nằm ở một nơi duy nhất.

Trong Jobs

class ProcessNewRegistration implements ShouldQueue
{
    public function __construct(
        private User $user,
    ) {}

    public function handle(
        AssignDefaultRole $assignRole,
        CreateDefaultSettings $createSettings,
        NotifyAdmins $notifyAdmins,
    ): void {
        $assignRole->execute($this->user, 'member');
        $createSettings->execute($this->user);
        $notifyAdmins->execute($this->user);
    }
}

Kết Hợp Actions (Composition)

class ProcessCheckout
{
    public function __construct(
        private ValidateInventory $validateInventory,
        private CalculateOrderTotal $calculateTotal,
        private ProcessPayment $processPayment,
        private CreateOrder $createOrder,
        private SendOrderConfirmation $sendConfirmation,
    ) {}

    public function execute(Cart $cart, PaymentMethod $payment): Order
    {
        $this->validateInventory->execute($cart->items);
        $summary = $this->calculateTotal->execute($cart);
        $charge = $this->processPayment->execute($payment, $summary->total);
        $order = $this->createOrder->execute($cart, $summary, $charge);
        $this->sendConfirmation->execute($order);

        return $order;
    }
}

Mỗi bước testable riêng biệt. Workflow đọc từ trên xuống. Thêm bước = thêm một dòng. Xóa bước = xóa một dòng.

Actions Với Validation

Có hai trường phái:

Trường phái 1: Validation trong Form Request (khuyến nghị)

// app/Http/Requests/CreatePostRequest.php
class CreatePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:100'],
            'tags' => ['array'],
        ];
    }
}

// Action nhận dữ liệu đã validated
class CreatePost
{
    public function execute(User $author, string $title, string $body, array $tags = []): Post
    {
        $post = $author->posts()->create([
            'title' => $title,
            'slug' => Str::slug($title),
            'body' => $body,
        ]);

        $post->tags()->sync($tags);

        return $post;
    }
}

Trường phái 2: Self-validating Actions

Khi action được gọi từ nhiều nơi (controller, command, job) và bạn muốn validation nhất quán:

class CreatePost
{
    public function execute(User $author, array $data): Post
    {
        $validated = Validator::make($data, [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:100'],
            'tags' => ['array'],
        ])->validate();

        $post = $author->posts()->create([
            'title' => $validated['title'],
            'slug' => Str::slug($validated['title']),
            'body' => $validated['body'],
        ]);

        if (!empty($validated['tags'])) {
            $post->tags()->sync($validated['tags']);
        }

        return $post;
    }
}

Khuyến nghị: Dùng Form Request cho HTTP requests, DTOs cho internal calls. Self-validating chỉ khi action phải work ở nhiều contexts khác nhau.

Data Transfer Objects (DTOs)

Khi action có nhiều parameters, dùng DTO thay vì parameter list dài:

// app/DataTransferObjects/CreateUserData.php
namespace App\DataTransferObjects;

class CreateUserData
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $password,
        public readonly ?string $avatarPath = null,
        public readonly string $timezone = 'UTC',
    ) {}

    public static function fromRequest(RegisterRequest $request): self
    {
        return new self(
            name: $request->validated('name'),
            email: $request->validated('email'),
            password: $request->validated('password'),
            avatarPath: $request->file('avatar')?->store('avatars'),
            timezone: $request->validated('timezone', 'UTC'),
        );
    }
}

// Action nhận DTO
class CreateUser
{
    public function execute(CreateUserData $data): User
    {
        return User::create([
            'name' => $data->name,
            'email' => $data->email,
            'password' => Hash::make($data->password),
            'avatar_path' => $data->avatarPath,
            'timezone' => $data->timezone,
        ]);
    }
}

// Controller
public function store(RegisterRequest $request, CreateUser $createUser)
{
    $user = $createUser->execute(CreateUserData::fromRequest($request));
    return redirect('/dashboard');
}

Khi nào dùng DTO: Action có > 4 parameters, hoặc parameters cùng nhóm logic (tất cả liên quan đến user data).

Invokable Actions

Thay vì execute(), dùng __invoke() cho syntax sạch hơn:

class PublishPost
{
    public function __invoke(Post $post): Post
    {
        if ($post->isPublished()) {
            throw new PostAlreadyPublishedException($post);
        }

        $post->update([
            'published_at' => now(),
            'status' => PostStatus::Published,
        ]);

        event(new PostPublished($post));

        return $post;
    }
}

// Sử dụng
$publishPost = app(PublishPost::class);
$publishPost($post); // Gọi như function

Actions Với Database Transactions

Khi action cần atomicity:

class TransferMoney
{
    public function execute(Account $from, Account $to, int $amountInCents, string $description): Transfer
    {
        return DB::transaction(function () use ($from, $to, $amountInCents, $description) {
            if ($from->balance_in_cents < $amountInCents) {
                throw new InsufficientFundsException($from, $amountInCents);
            }

            $from->decrement('balance_in_cents', $amountInCents);
            $to->increment('balance_in_cents', $amountInCents);

            return Transfer::create([
                'from_account_id' => $from->id,
                'to_account_id' => $to->id,
                'amount_in_cents' => $amountInCents,
                'description' => $description,
            ]);
        });
    }
}

Tip: Đặt events/notifications sau transaction commit để tránh gửi notification cho transaction bị rollback:

$transfer = DB::transaction(function () { ... });

// Dispatch SAU transaction commit thành công
event(new MoneyTransferred($transfer));

Tổ Chức Thư Mục

app/Actions/
├── Auth/
│   ├── CreateUser.php
│   ├── ResetPassword.php
│   └── VerifyEmail.php
├── Posts/
│   ├── CreatePost.php
│   ├── PublishPost.php
│   ├── UpdatePost.php
│   └── DeletePost.php
├── Orders/
│   ├── CreateOrder.php
│   ├── ProcessPayment.php
│   ├── RefundOrder.php
│   └── CalculateOrderTotal.php
└── Notifications/
    ├── SendWelcomeEmail.php
    └── NotifyAdmins.php

Tổ chức theo domain (Auth, Posts, Orders), không theo patterns. IDE autocomplete sẽ giúp tìm action nhanh: gõ "Create" → thấy tất cả creation actions.

Quy Tắc Đặt Tên

Động từ + Danh từ
──────────
CreateUser
UpdateProfile
PublishArticle
SendInvoice
CalculateDiscount
ApproveApplication
BanUser
ExportReport

Tránh: HandleUser (quá chung), UserCreator (noun-based), DoCreateUser (thừa).

Testing

Actions dễ test — không có HTTP layer, không middleware, không session:

class CreateUserTest extends TestCase
{
    use RefreshDatabase;

    public function test_creates_user_with_hashed_password(): void
    {
        $action = new CreateUser();
        $user = $action->execute('John', 'john@example.com', 'secret123');

        $this->assertDatabaseHas('users', [
            'name' => 'John',
            'email' => 'john@example.com',
        ]);
        $this->assertTrue(Hash::check('secret123', $user->password));
    }

    public function test_creates_user_with_default_timezone(): void
    {
        $action = new CreateUser();
        $user = $action->execute(new CreateUserData(
            name: 'Jane',
            email: 'jane@example.com',
            password: 'password',
        ));

        $this->assertEquals('UTC', $user->timezone);
    }
}

So sánh với test controller:

// Test controller: nhiều ceremony hơn
$this->post('/register', ['name' => 'John', 'email' => 'john@example.com', ...])
    ->assertRedirect('/dashboard');

// Test action: trực tiếp, focused
$user = (new CreateUser())->execute('John', 'john@example.com', 'secret');
$this->assertDatabaseHas('users', ['name' => 'John']);

Test action nhanh hơn (không qua HTTP stack), focused hơn (test đúng logic), và dễ arrange hơn (không cần fake request).

Testing Actions Với Dependencies

public function test_publish_post_dispatches_event(): void
{
    Event::fake([PostPublished::class]);

    $post = Post::factory()->draft()->create();
    $action = app(PublishPost::class); // Resolve từ container

    $action->execute($post);

    Event::assertDispatched(PostPublished::class, function ($event) use ($post) {
        return $event->post->id === $post->id;
    });
}

Actions vs Services vs Jobs

Action Service Job
Mục đích Một thao tác Nhóm thao tác liên quan Tác vụ nền
Thực thi Đồng bộ Đồng bộ Async (queue)
Testability Xuất sắc Trung bình Tốt
Tái sử dụng Cao Trung bình Thấp
Complexity Thấp Trung bình–Cao Trung bình
Khi nào dùng Default choice Khi cần shared state giữa operations Khi cần async

Không phải either/or: Actions có thể dispatch Jobs. Jobs có thể gọi Actions. Chúng bổ sung cho nhau.

// Action cho logic
class GenerateReport
{
    public function execute(ReportRequest $request): Report { ... }
}

// Job cho async scheduling
class GenerateReportJob implements ShouldQueue
{
    public function handle(GenerateReport $generateReport): void
    {
        $report = $generateReport->execute($this->request);
        $this->user->notify(new ReportReady($report));
    }
}

Migration: Từ Service → Actions

Bạn không cần refactor tất cả cùng lúc. Strategy:

1. Service method nào dùng nhiều nhất? → Extract thành Action trước
2. Service method nào phức tạp nhất? → Extract để dễ test
3. Service mới? → Viết Action luôn, đừng tạo Service
4. Dần dần Service sẽ chỉ còn 1-2 methods → Xóa Service, replace bằng Actions

Kết Luận

Actions không phải framework feature — đó là pattern. Không cần package. Chỉ cần kỷ luật: một class cho mỗi thao tác, naming rõ ràng, tham số explicit, kết quả explicit.

Checklist:

  1. Một class, một public method (execute() hoặc __invoke())
  2. Đặt tên Verb + Noun rõ ràng
  3. Parameters explicit — tránh array $data khi có thể
  4. Dùng DTOs khi > 4 parameters
  5. Test trực tiếp, không qua HTTP layer
  6. Tổ chức theo domain
  7. Bắt đầu bằng cách extract logic controller hay dùng nhất

Bình luận