The Laravel Actions Pattern: Single-Purpose Classes That Do One Thing Well
You've heard "keep controllers thin." You've been told to use Services. But Services often become God classes — UserService with 40 methods handling everything from registration to password resets.
Actions are the fix: one class per operation. CreateUser, SendInvoice, PublishPost. Each does exactly one thing.
The Problem with Services
// This always happens
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) { ... }
public function importFromCsv(string $path) { ... }
public function sendVerificationEmail(User $user) { ... }
public function generateApiToken(User $user) { ... }
// 30 more methods...
}
This violates the Single Responsibility Principle. It's hard to test, hard to navigate, and grows forever.
Actions: One Class, One Job
// 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' => Hash::make($email),
'password' => Hash::make($password),
]);
}
}
That's it. No interface to implement. No abstract class. Just a plain PHP class with an execute method.
Using Actions
In 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');
}
}
In Artisan Commands
class ImportUsersCommand extends Command
{
protected $signature = 'users:import {file}';
public function handle(CreateUser $createUser): int
{
$rows = // ... read CSV
foreach ($rows as $row) {
$createUser->execute($row['name'], $row['email'], $row['password']);
}
$this->info('Import complete.');
return Command::SUCCESS;
}
}
In Other Actions (Composition)
class RegisterUserForEvent
{
public function __construct(
private CreateUser $createUser,
private CreateEventRegistration $createRegistration,
private SendConfirmationEmail $sendConfirmation,
) {}
public function execute(array $userData, Event $event): Registration
{
$user = $this->createUser->execute(...$userData);
$registration = $this->createRegistration->execute($user, $event);
$this->sendConfirmation->execute($registration);
return $registration;
}
}
Practical Examples
Action with Dependencies
// app/Actions/PublishPost.php
namespace App\Actions;
use App\Models\Post;
use App\Services\IndexNowService;
use Illuminate\Support\Facades\Cache;
class PublishPost
{
public function __construct(
private IndexNowService $indexNow,
) {}
public function execute(Post $post): Post
{
$post->update([
'published_at' => now(),
'is_published' => true,
]);
Cache::forget("post_{$post->slug}");
$this->indexNow->submit($post->url);
return $post;
}
}
Action with Validation
Two approaches:
Approach 1: Validation in Form Request (recommended for HTTP)
// Form Request handles validation
class CreatePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:100'],
'tags' => ['array'],
];
}
}
// Action receives already-validated data
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;
}
}
Approach 2: Self-validating Actions (when called from multiple contexts)
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;
}
}
Recommendation: Use Form Request for HTTP requests, DTOs for internal calls. Self-validating only when the action must work across different contexts (controller, command, job).
Data Transfer Objects (DTOs)
When an action has many parameters, use a DTO instead of a long parameter list:
// app/DataTransferObjects/CreateUserData.php
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 receives 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');
}
When to use DTOs: Action has > 4 parameters, or parameters form a logical group (all user data).
Action That Returns a DTO
class CalculateOrderTotal
{
public function execute(Order $order): OrderSummary
{
$subtotal = $order->items->sum(fn ($item) => $item->price * $item->quantity);
$tax = $subtotal * $order->taxRate();
$discount = $this->calculateDiscount($order);
return new OrderSummary(
subtotal: $subtotal,
tax: $tax,
discount: $discount,
total: $subtotal + $tax - $discount,
);
}
private function calculateDiscount(Order $order): int
{
if (!$order->coupon) {
return 0;
}
return match ($order->coupon->type) {
'percentage' => (int) ($order->subtotal * $order->coupon->value / 100),
'fixed' => $order->coupon->value,
default => 0,
};
}
}
Invokable Actions
Instead of execute(), use __invoke() for cleaner syntax:
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;
}
}
// Usage
$publishPost = app(PublishPost::class);
$publishPost($post); // Call like a function
Actions with Database Transactions
When an action needs 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: Dispatch events/notifications after the transaction commits to avoid sending notifications for rolled-back transactions:
$transfer = DB::transaction(function () { ... });
// Dispatch AFTER successful commit
event(new MoneyTransferred($transfer));
Organizing Actions
Directory Structure
app/Actions/
├── Auth/
│ ├── CreateUser.php
│ ├── ResetPassword.php
│ └── VerifyEmail.php
├── Posts/
│ ├── CreatePost.php
│ ├── PublishPost.php
│ ├── UnpublishPost.php
│ └── DeletePost.php
├── Orders/
│ ├── CreateOrder.php
│ ├── CalculateOrderTotal.php
│ ├── ProcessPayment.php
│ └── RefundOrder.php
└── Notifications/
├── SendWelcomeEmail.php
└── SendOrderConfirmation.php
Naming Convention
Verb + Noun
──────────
CreateUser
UpdateProfile
DeletePost
PublishArticle
SendInvoice
CalculateDiscount
ImportCsvData
ExportReport
Always use a verb. The class name should describe what it does.
Testing Actions
Actions are trivially testable — no HTTP layer, no middleware, just input/output:
class CreateUserTest extends TestCase
{
public function test_creates_user_with_hashed_password(): void
{
$action = new CreateUser();
$user = $action->execute(
name: 'John Doe',
email: 'john@example.com',
password: 'secret123',
);
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
$this->assertTrue(Hash::check('secret123', $user->password));
}
}
class PublishPostTest extends TestCase
{
public function test_publishes_post_and_clears_cache(): void
{
$post = Post::factory()->unpublished()->create();
Cache::put("post_{$post->slug}", 'cached', 3600);
$indexNow = $this->mock(IndexNowService::class);
$indexNow->shouldReceive('submit')->once();
$action = new PublishPost($indexNow);
$result = $action->execute($post);
$this->assertTrue($result->is_published);
$this->assertNotNull($result->published_at);
$this->assertNull(Cache::get("post_{$post->slug}"));
}
}
Actions vs Services vs Jobs
| Action | Service | Job | |
|---|---|---|---|
| Purpose | One operation | Group of related operations | Background task |
| Execution | Synchronous | Synchronous | Async (queue) |
| Testability | Excellent | Moderate | Good |
| Reusability | High | Medium | Low |
| Complexity | Simple | Can grow | Medium |
Use Actions when: the operation is called from multiple places (controller, command, other actions).
Use Jobs when: the work should happen in the background.
Use Services when: you have infrastructure logic (API clients, file parsers) that isn't a business operation.
Composing Actions for Complex Workflows
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
{
// Step 1: Check stock
$this->validateInventory->execute($cart->items);
// Step 2: Calculate total
$summary = $this->calculateTotal->execute($cart);
// Step 3: Charge payment
$charge = $this->processPayment->execute($payment, $summary->total);
// Step 4: Create order record
$order = $this->createOrder->execute($cart, $summary, $charge);
// Step 5: Notify customer
$this->sendConfirmation->execute($order);
return $order;
}
}
Each step is testable in isolation. The workflow is readable top-to-bottom. Adding a step means adding one line.
Migration: From Services to Actions
You don't need to refactor everything at once. Strategy:
1. Which service method is used the most? → Extract to Action first
2. Which is most complex? → Extract for better testability
3. New feature? → Write an Action directly, don't create a Service
4. Gradually the Service will have 1-2 methods left → Remove Service, replace with Actions
Actions and Jobs complement each other:
// Action for logic
class GenerateReport
{
public function execute(ReportRequest $request): Report { ... }
}
// Job for async scheduling
class GenerateReportJob implements ShouldQueue
{
public function handle(GenerateReport $generateReport): void
{
$report = $generateReport->execute($this->request);
$this->user->notify(new ReportReady($report));
}
}
Conclusion
Actions are not a framework feature — they're a pattern. No package needed. No interfaces to implement. Just discipline:
Checklist:
- One class, one public method (
execute()or__invoke()) - Clear Verb + Noun naming
- Explicit parameters — avoid
array $datawhen possible - Use DTOs when > 4 parameters
- Test directly, no HTTP layer
- Organize by domain
- Start by extracting your most-reused controller logic