Laravel Actions Pattern: Một Class, Một Nhiệm Vụ
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ùnghandle()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:
- Một class, một public method (
execute()hoặc__invoke()) - Đặt tên Verb + Noun rõ ràng
- Parameters explicit — tránh
array $datakhi có thể - Dùng DTOs khi > 4 parameters
- Test trực tiếp, không qua HTTP layer
- Tổ chức theo domain
- Bắt đầu bằng cách extract logic controller hay dùng nhất