Ehsan Bagherzadegan
Back to articles
15 min read

Action Pattern in PHP and Laravel

The **Action Pattern** in PHP and Laravel is a clean architectural approach where each important business operation is placed inside its own dedicated class. Instead of putting application logic directly inside controllers, models, jobs, or services, an Action class represents one specific use case, such as `CreateUserAction`, `SendInvoiceAction`, or `CancelSubscriptionAction`. This pattern helps keep controllers thin, improves reusability, and makes business logic easier to test. A controller should handle HTTP concerns, while an Action should handle what the application actually needs to do. In Laravel, Actions work naturally with dependency injection, Form Requests, Jobs, Commands, and Livewire components. For senior PHP developers, the main benefit of the Action Pattern is not simply reducing code, but creating clearer ownership of business operations. Each Action gives a meaningful name to a use case and keeps the application easier to maintain as it grows.

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
DeleteFairnessBonusAction

Each 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
PaymentService

A 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
AssignRolesToUserAction

A 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.php

For modular applications, you might use:

Modules/
  Billing/
    Actions/
      CreateInvoiceAction.php
      SendInvoiceAction.php
  Users/
    Actions/
      CreateUserAction.php

For 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): User

For more complex systems, especially where you want stronger boundaries, you can return a DTO:

public function handle(CreateUserData $data): UserResult

Example 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:

  1. Cleaner controllers

Controllers become thin and readable.

  1. Reusable business logic

The same Action can be called from a Controller, Command, Job, Listener, API endpoint, or Livewire component.

  1. Better testability

You can test the use case directly without testing through HTTP.

  1. Better naming

A class like CancelSubscriptionAction explains intent better than a generic SubscriptionService::cancel() hidden among many other methods.

  1. Less duplication

When the same operation is needed in different places, the Action becomes the single source of truth.

  1. 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 void

  • avoid 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
DeleteFairnessBonusAction

Final 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.