Action Pattern in PHP and Laravel
As a PHP or Laravel application grows, business logic often starts leaking into many places: Controllers, Models, Jobs, Listeners, Commands, Form Requests, and sometimes even Blade or Livewire components.
The Action Pattern is a simple way to keep that logic organized.
The idea is:
Put one specific business operation into one dedicated class.
For example:
CreateUserAction
SendInvoiceAction
CancelSubscriptionAction
GenerateMonthlyReportAction
DeleteFairnessBonusActionEach action class represents one meaningful operation in your application.
What is an Action?
An Action is usually a plain PHP class with one main method, commonly called handle(), execute(), or __invoke().
Example:
namespace App\Actions\Users;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class CreateUserAction
{
public function handle(array $data): User
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
}Then you use it inside a controller:
namespace App\Http\Controllers;
use App\Actions\Users\CreateUserAction;
use App\Http\Requests\StoreUserRequest;
class RegisteredUserController
{
public function store(StoreUserRequest $request, CreateUserAction $createUserAction)
{
$user = $createUserAction->handle(
$request->validated()
);
return redirect()->route('users.show', $user);
}
}Now the controller handles the HTTP layer, while the Action handles the business operation.
Why Use the Action Pattern?
Laravel makes it very easy to write logic directly inside controllers. That is fine for small features, but in larger systems, controllers can quickly become too large.
For example:
public function store(StoreUserRequest $request)
{
$data = $request->validated();
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
$user->roles()->sync($data['roles']);
Mail::to($user)->send(new WelcomeMail($user));
activity()->performedOn($user)->log('User created');
return redirect()->route('users.index');
}This method does too many things:
It validates input, creates a user, syncs roles, sends an email, writes an activity log, and returns an HTTP response.
With an Action, the business operation becomes reusable:
namespace App\Actions\Users;
use App\Mail\WelcomeMail;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
class CreateUserAction
{
public function handle(array $data): User
{
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
$user->roles()->sync($data['roles'] ?? []);
Mail::to($user)->send(new WelcomeMail($user));
activity()
->performedOn($user)
->log('User created');
return $user;
}
}And the controller becomes simple:
public function store(StoreUserRequest $request, CreateUserAction $action)
{
$user = $action->handle($request->validated());
return redirect()->route('users.show', $user);
}Action vs Service
A common question is: What is the difference between an Action and a Service?
A Service usually represents a broader area of logic.
Example:
UserService
InvoiceService
SubscriptionService
PaymentServiceA Service may contain many methods:
class UserService
{
public function create(array $data): User
{
// ...
}
public function update(User $user, array $data): User
{
// ...
}
public function delete(User $user): void
{
// ...
}
public function assignRoles(User $user, array $roles): void
{
// ...
}
}An Action is more focused. It usually does one thing:
CreateUserAction
UpdateUserAction
DeleteUserAction
AssignRolesToUserActionA good rule:
Use Services for broader domain capabilities.
Use Actions for specific application use cases.
In many Laravel projects, Actions are easier to maintain because they avoid large “God service” classes.
Action vs Job
Actions and Jobs are also different.
A Job represents something that should be dispatched, often asynchronously:
SendWelcomeEmailJob::dispatch($user);An Action represents the business operation itself:
$createUserAction->handle($data);You can use both together:
class CreateUserAction
{
public function handle(array $data): User
{
$user = User::create([...]);
SendWelcomeEmailJob::dispatch($user);
return $user;
}
}The Action contains the business logic.
The Job controls when and how it runs.
Action vs Controller
A Controller should usually deal with HTTP concerns:
$request->validated();
return redirect();
return response()->json();
abort_if();A clean Laravel controller often looks like this:
public function store(StoreInvoiceRequest $request, CreateInvoiceAction $action)
{
$invoice = $action->handle($request->validated());
return redirect()->route('invoices.show', $invoice);
}The controller knows how the request came in.
The Action knows what the application should do.
Using __invoke() Instead of handle()
Some developers prefer invokable Actions:
class CreateUserAction
{
public function __invoke(array $data): User
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
}Usage:
public function store(StoreUserRequest $request, CreateUserAction $createUser)
{
$user = $createUser($request->validated());
return redirect()->route('users.show', $user);
}This is clean, but handle() is often more explicit.
Both are fine. Pick one convention and stay consistent.
Where Should Actions Live?
A common Laravel structure is:
app/
Actions/
Users/
CreateUserAction.php
UpdateUserAction.php
DeleteUserAction.php
Invoices/
CreateInvoiceAction.php
SendInvoiceAction.php
Subscriptions/
CancelSubscriptionAction.phpFor modular applications, you might use:
Modules/
Billing/
Actions/
CreateInvoiceAction.php
SendInvoiceAction.php
Users/
Actions/
CreateUserAction.phpFor a Laravel enterprise project, grouping Actions by domain or module is usually better than putting hundreds of classes directly under app/Actions.
Should Actions Use Transactions?
Yes, when the operation changes multiple related things.
Example:
namespace App\Actions\Invoices;
use App\Models\Invoice;
use Illuminate\Support\Facades\DB;
class CreateInvoiceAction
{
public function handle(array $data): Invoice
{
return DB::transaction(function () use ($data) {
$invoice = Invoice::create([
'customer_id' => $data['customer_id'],
'due_date' => $data['due_date'],
]);
foreach ($data['items'] as $item) {
$invoice->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $item['price'],
]);
}
return $invoice;
});
}
}This keeps the use case safe. Either the invoice and its items are created together, or nothing is created.
Should Actions Validate Data?
Usually, in Laravel, validation belongs outside the Action.
For HTTP requests, use a Form Request:
public function store(StoreUserRequest $request, CreateUserAction $action)
{
$user = $action->handle($request->validated());
return redirect()->route('users.show', $user);
}The Action should receive clean, expected data.
However, the Action can still protect important business rules:
if ($user->is_suspended) {
throw new UserCannotCreateInvoiceException();
}A good distinction:
Validation answers:
Is the input structurally valid?
Business rules answer:
Is this operation allowed in the current business state?
Form Requests are good for validation.
Actions are good for business rules.
Should Actions Return Models or DTOs?
It depends on the use case.
Returning an Eloquent model is perfectly fine in many Laravel applications:
public function handle(array $data): UserFor more complex systems, especially where you want stronger boundaries, you can return a DTO:
public function handle(CreateUserData $data): UserResultExample input DTO:
readonly class CreateUserData
{
public function __construct(
public string $name,
public string $email,
public string $password,
public array $roles = [],
) {
}
}Usage:
$user = $action->handle(
new CreateUserData(
name: $request->string('name')->toString(),
email: $request->string('email')->toString(),
password: $request->string('password')->toString(),
roles: $request->input('roles', []),
)
);For many Laravel projects, arrays are pragmatic and simple.
For enterprise code, DTOs can make Actions safer and easier to refactor.
Example: A More Realistic Action
namespace App\Actions\Subscriptions;
use App\Enums\SubscriptionStatus;
use App\Models\Subscription;
use Illuminate\Support\Facades\DB;
class CancelSubscriptionAction
{
public function handle(Subscription $subscription, ?string $reason = null): Subscription
{
return DB::transaction(function () use ($subscription, $reason) {
if ($subscription->status === SubscriptionStatus::Cancelled) {
return $subscription;
}
if (! $subscription->can_be_cancelled) {
throw new SubscriptionCannotBeCancelledException();
}
$subscription->update([
'status' => SubscriptionStatus::Cancelled,
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
$subscription->invoices()
->whereNull('paid_at')
->update([
'voided_at' => now(),
]);
return $subscription->refresh();
});
}
}This is a good Action because it represents one business use case: cancel a subscription.
It is not just a wrapper around one model update. It coordinates several things and protects business rules.
When Not to Use an Action
Do not create an Action for every tiny line of code.
This is probably unnecessary:
class UpdateUserNameAction
{
public function handle(User $user, string $name): User
{
$user->update(['name' => $name]);
return $user;
}
}Unless updating the name has important rules, events, side effects, or reuse requirements, a simple model update is enough.
Use Actions when the operation has at least one of these:
business rules
multiple model changes
side effects
reuse from different entry points
transaction requirements
test value
domain meaning
Benefits of the Action Pattern
The main benefits are:
Cleaner controllers
Controllers become thin and readable.
Reusable business logic
The same Action can be called from a Controller, Command, Job, Listener, API endpoint, or Livewire component.
Better testability
You can test the use case directly without testing through HTTP.
Better naming
A class like CancelSubscriptionAction explains intent better than a generic SubscriptionService::cancel() hidden among many other methods.
Less duplication
When the same operation is needed in different places, the Action becomes the single source of truth.
Better separation of concerns
HTTP logic stays in controllers. Business logic stays in Actions.
Testing an Action
Example test:
use App\Actions\Users\CreateUserAction;
use App\Models\Role;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates a user with roles', function () {
$role = Role::factory()->create();
$user = app(CreateUserAction::class)->handle([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password',
'roles' => [$role->id],
]);
expect($user->exists)->toBeTrue();
expect($user->roles)->toHaveCount(1);
});This test does not care about routes, controllers, middleware, or HTTP responses. It tests the actual use case.
This test does not care about routes, controllers, middleware, or HTTP responses. It tests the actual use case.
A Good Action Class Checklist
A good Action should usually:
have a clear name
perform one business operation
have one public entry method
return something meaningful or return
voidavoid HTTP-specific logic
use transactions when needed
throw domain-specific exceptions when business rules fail
be reusable from multiple entry points
Example names:
CreateInvoiceAction
ApproveInvoiceAction
CancelSubscriptionAction
AssignRoleToUserAction
ImportCustomersAction
GenerateMonthlyReportAction
DeleteFairnessBonusActionFinal Thought
The Action Pattern is not a framework feature. It is an architectural convention.
In Laravel, it works very naturally because the service container can inject Actions directly into Controllers, Jobs, Commands, and Livewire components.
For a senior PHP developer, the main value is not “less code”. The real value is clearer ownership of business operations.
A well-designed Action answers one question:
What does the application do when this use case happens?
That clarity becomes very important as the project grows.