The Laravel action pattern deserves to escape Laravel

The Laravel action pattern deserves to escape Laravel

~ 8 min read


Laravel applications tend to organise themselves around the framework. HTTP logic goes in controllers. Asynchronous logic goes in jobs. Scheduled logic goes in commands. Event-driven logic goes in listeners.

That structure is tidy until two of those entry points need to perform the same business operation.

The action pattern offers a better centre of gravity: model what the application does, then let controllers, jobs, commands and listeners call it. It does not require lorisleiva/laravel-actions, although I’ll cover why it adds some great abstractions later. A plain PHP class can be an action, and this is where I’ll start the examples.

Organise around operations, not delivery mechanisms

Suppose an expense report can be approved from an admin screen. Six months later, finance needs a command to approve reports imported from an older system. Then a workflow service needs to trigger the same operation from a queue.

It is easy to end up with approval logic in three places:

  • a controller validates the approver, changes the report state and sends a notification
  • a command performs roughly the same checks, except for one that was added later
  • a job updates the state but forgets to record the audit event

The duplication is rarely a literal copy and paste. It is more dangerous than that. Each implementation expresses a slightly different version of what “approve an expense report” means.

An action gives that operation a name and one implementation:

HTTP request ─┐
CLI command ──┼──> ApproveExpenseReport ──> approved report
Queue job ────┤
Event ────────┘

The entry points still matter. HTTP has status codes and authentication. Commands have arguments and exit codes. Queue jobs have retries and timeouts. Those concerns belong in thin adapters around the action rather than inside the business operation itself.

What a Laravel action looks like

A Laravel action can be an ordinary class resolved through the service container. It needs no base class, interface or trait. The pattern comes from giving one business operation a name and a dedicated home, not from a package API.

A small action without lorisleiva/laravel-actions might look like this:

use Illuminate\Support\Facades\DB;

final class ApproveExpenseReport
{
    public function __construct(
        private ExpenseApprovalPolicy $policy,
        private Clock $clock,
    ) {}

    public function handle(
        ExpenseReport $report,
        User $approver,
    ): ExpenseReport {
        $this->policy->assertCanApprove($approver, $report);

        return DB::transaction(function () use ($report, $approver) {
            $report->approve($approver, $this->clock->now());
            $report->save();

            ExpenseReportApproved::dispatch($report->id);

            return $report;
        });
    }
}

This is already the action pattern. The handle method defines an explicit application operation with typed inputs, a clear result and dependencies that can be seen in the constructor. Laravel’s container can inject the action anywhere it is needed.

A controller can translate an HTTP request into those inputs:

final class ApproveExpenseReportController
{
    public function __invoke(
        ApproveExpenseReportRequest $request,
        ExpenseReport $report,
        ApproveExpenseReport $approve,
    ): JsonResponse {
        $approved = $approve->handle($report, $request->user());

        return response()->json(ExpenseReportResource::make($approved));
    }
}

A queue job can load the same inputs and call the same action:

final class ApproveImportedExpenseReport implements ShouldQueue
{
    public function __construct(
        public int $reportId,
        public int $approverId,
    ) {}

    public function handle(ApproveExpenseReport $approve): void
    {
        $approve->handle(
            ExpenseReport::findOrFail($this->reportId),
            User::findOrFail($this->approverId),
        );
    }
}

Both adapters own their delivery concerns. Neither gets to invent its own approval rules.

Abstract Bauhaus illustration of interchangeable delivery adapters connecting to one reusable action Controllers, jobs, commands and listeners are different adapters around the same business operation.

Reuse is the second-order benefit

Avoiding duplicate code is useful, but the bigger gain is a stable application boundary.

ApproveExpenseReport, CancelSubscription and InviteTeamMember describe the capabilities of the application more clearly than ExpenseService, SubscriptionService and TeamService. A new developer can scan an Actions directory and see the system’s use cases rather than a list of technical layers or database entities.

That boundary also makes changes easier to reason about. If approval gains a spending-limit check, there is one place to add it. If it needs an audit record, every caller gets it. If the operation is exposed through a new GraphQL mutation or message consumer, the new adapter does not become a new implementation.

Tests improve for the same reason. The main behavioural test calls the action directly, without manufacturing an HTTP request or starting a queue worker. Adapter tests can then stay small: does the controller map the authenticated user and route model correctly, and does the job load the expected records?

The pattern does not need Laravel

Move beyond Laravel’s service container, Eloquent and facades, and the shape still works. Here is the same operation in plain PHP:

final readonly class ApproveExpenseReport
{
    public function __construct(
        private ExpenseReports $reports,
        private ExpenseApprovalPolicy $policy,
        private Transaction $transaction,
        private EventPublisher $events,
        private Clock $clock,
    ) {}

    public function execute(
        ExpenseReportId $reportId,
        UserId $approverId,
    ): ExpenseReport {
        return $this->transaction->run(function () use ($reportId, $approverId) {
            $report = $this->reports->get($reportId);
            $this->policy->assertCanApprove($approverId, $report);

            $report->approve($approverId, $this->clock->now());
            $this->reports->save($report);
            $this->events->publish(new ExpenseReportApproved($report->id()));

            return $report;
        });
    }
}

This is an application service with a deliberately narrow scope. The interfaces around persistence, transactions, events and time can be implemented by Laravel, Symfony, a small custom PHP application or a test double.

The same structure maps cleanly to other languages. It may be called a use case, command handler, application service or interactor. The name matters less than the boundary:

delivery adapter -> one named application operation -> domain and infrastructure ports

Martin Fowler’s description of a Service Layer makes the same architectural point: an application should expose a set of available operations to its different client interfaces. Laravel actions are a focused, class-per-operation version of that idea.

Abstract Bauhaus illustration of a reusable action core leaving a framework-specific chassis The framework supplies useful machinery, but the business operation does not have to belong to it.

Actions are not a replacement for a domain model

There is a trap here. Moving a 300-line controller into a 300-line action changes the file name, not the design.

An action should coordinate a use case. Rules that protect the state of an entity still belong close to that entity. In the example, ExpenseReport::approve() should reject an already paid or cancelled report even if the action forgets to check. A value object should decide whether money or a date is valid. The action decides which objects participate, where the transaction starts and which side effects follow.

That distinction keeps actions from becoming procedural dumping grounds:

  • adapters translate transport-specific input and output
  • actions coordinate one application operation
  • domain objects enforce their own invariants
  • infrastructure implementations handle databases, queues, clocks and external APIs

Small applications do not need elaborate domain modelling on day one. A direct model update in an action can be a reasonable starting point. The useful constraint is that callers depend on the action, so its internals can become more sophisticated without changing every entry point.

Practical rules for useful actions

Name actions with a verb and a business outcome: IssueRefund, RegisterCandidate or SuspendAccount. Names such as ProcessData and HandleUser hide the operation rather than clarify it.

Keep transport objects out of the core method. Passing an HTTP request into handle() makes the action harder to call from a command or test. Translate the request into models, identifiers, value objects or a small input DTO at the edge.

Make the result explicit. Return the created entity, an operation result or nothing by design. Do not make callers inspect hidden global state to discover what happened.

Treat authorisation as part of the use case, but pass the actor explicitly. A controller middleware check may be useful, yet the action still needs to protect itself when it can also be invoked from a job or command.

Define retry behaviour deliberately. Once an action can run through a queue, duplicate delivery is normal. Operations that charge money, send messages or call external systems may need idempotency keys, an outbox or recorded state transitions. Reuse does not make side effects safe by itself.

Do not create an action for every one-line read. The pattern earns its keep when an operation contains a rule, a state change, orchestration or more than one caller. A simple query can remain a simple query.

What the Laravel Actions package adds

Laravel does not need a dedicated Action component for this pattern to work. It is a code-organisation choice, and the plain class above is enough to adopt it.

The Laravel Actions package is optional, but it adds some nice abstractions around that class:

  • AsAction provides container-backed make() and run() methods
  • adapter methods such as asController, asJob, asListener and asCommand let one class run through different Laravel entry points
  • action fakes and mocking helpers make callers easier to isolate in tests

Those features reduce registration and adapter boilerplate. They do not create the action or decide where the business logic belongs. The handle method remains the useful boundary, whether it is called directly or through one of the package’s decorators.

The package documentation also makes the pattern unusually visible. It asks developers to stop starting with “what controllers do I need?” and instead ask what the application actually does. That is the valuable shift.

For code intended to outlive a framework boundary, I prefer separate adapters calling a plain action. It adds a few small classes, but it prevents HTTP, queue or console details from leaking into the operation. The action can then move between frameworks because it was never owned by one.

The pattern is powerful because business operations are more stable than their delivery mechanisms. Today an approval arrives over HTTP. Tomorrow it may come from a queue, a scheduled reconciliation or an AI-assisted back-office tool. Those are new ways into the application, not new meanings of approval.

Start with the operation you have already implemented twice. Give it a business name, move the rules behind one method, and make the existing entry points call it. That small refactor is usually enough to show why actions deserve a life beyond Laravel.

Further reading

all posts →