Stu Mason
Stu Mason
Guide

Laravel Actions Pattern: Moving Business Logic Out of Controllers

Stuart Mason6 min read

Every Laravel developer goes through the same evolution. First, you put everything in controllers. Then you put everything in models. Then you discover services and put everything there instead. Event

Laravel Actions Pattern: Moving Business Logic Out of Controllers

Every Laravel developer goes through the same evolution. First, you put everything in controllers. Then you put everything in models. Then you discover services and put everything there instead. Eventually you realise that a 400-line UserService with 15 methods is just a controller wearing a different hat.

Actions fix this properly.

What's an Action?

An Action is a class with one public method that does one thing. Not two things. Not "one thing and also this other related thing." One thing.

// app/Actions/Order/AcceptJobQuote.php

class AcceptJobQuote
{
    public function execute(JobQuote $quote, User $client): Job
    {
        throw_unless(
            $quote->status === QuoteStatus::Pending,
            QuoteAlreadyResolvedException::class
        );

        throw_unless(
            $quote->job->client_id === $client->id,
            UnauthorizedException::class
        );

        $quote->update(['status' => QuoteStatus::Accepted]);

        // Reject all other quotes for this job
        $quote->job->quotes()
            ->where('id', '!=', $quote->id)
            ->where('status', QuoteStatus::Pending)
            ->update(['status' => QuoteStatus::Rejected]);

        $quote->job->update([
            'status' => JobStatus::Assigned,
            'assigned_to' => $quote->cleaner_id,
        ]);

        event(new QuoteAccepted($quote));

        return $quote->job->fresh();
    }
}

That's a complete business operation. It validates preconditions, does the work, fires events, and returns the result. The controller that calls it looks like this:

public function accept(AcceptQuoteRequest $request, JobQuote $quote): RedirectResponse
{
    $job = (new AcceptJobQuote())->execute($quote, $request->user());

    return redirect()->route('jobs.show', $job)
        ->with('success', 'Quote accepted.');
}

Three lines. Validate, execute, respond. That's what a controller should look like.

Why Not Services?

Services aren't bad. I use them for API wrappers — GitHubService, StripeService, things that manage a connection to an external system. But for business logic, services have a structural problem: they grow.

You start with OrderService@createOrder. Then you add cancelOrder. Then refundOrder. Then partialRefundOrder. Before you know it, you've got a god class with 20 dependencies injected in the constructor and every test needs to mock half of them.

Actions don't grow. They can't. One class, one method, one operation. If your operation gets complex, you compose Actions together — you don't bolt more methods onto an existing class.

The make:action Generator

On my projects, I use a custom make:action Artisan command:

php artisan make:action Order/AcceptJobQuote

This creates the file at app/Actions/Order/AcceptJobQuote.php with the standard structure:

<?php

declare(strict_types=1);

namespace App\Actions\Order;

class AcceptJobQuote
{
    public function execute(): void
    {
        //
    }
}

Consistent naming. Consistent location. Every developer on the team knows where to find business logic: app/Actions/{Domain}/. No hunting through services, helpers, or model methods.

How Actions Compose

Complex workflows are just Actions calling Actions. Here's a simplified version of a payout flow:

// app/Actions/Payment/CreatePayout.php

class CreatePayout
{
    public function __construct(
        private CalculatePayoutAmount $calculateAmount,
        private ValidatePayoutEligibility $validateEligibility,
    ) {}

    public function execute(Job $job): Payout
    {
        $this->validateEligibility->execute($job);

        $amount = $this->calculateAmount->execute($job);

        $payout = Payout::create([
            'job_id' => $job->id,
            'cleaner_id' => $job->assigned_to,
            'amount' => $amount->netAmount,
            'platform_fee' => $amount->platformFee,
            'status' => PayoutStatus::Pending,
        ]);

        event(new PayoutCreated($payout));

        return $payout;
    }
}

Each sub-action is independently testable. CalculatePayoutAmount can be tested with different job types, durations, and fee structures without touching the database. ValidatePayoutEligibility can be tested for edge cases — incomplete jobs, suspended cleaners, whatever. And CreatePayout ties them together.

This is composability. Not inheritance. Not traits that magically add behaviour. Explicit, visible, traceable composition.

Real Example: Processing GitHub Events

On the Progress site, GitHub webhook events arrive as messy JSON blobs. The processing pipeline is a chain of Actions:

// app/Actions/FeedItem/ProcessGitHubEvent.php

class ProcessGitHubEvent
{
    public function __construct(
        private NormaliseGitHubPayload $normalise,
        private DeduplicateFeedItem $deduplicate,
    ) {}

    public function execute(string $eventType, array $payload): ?FeedItem
    {
        $normalised = $this->normalise->execute($eventType, $payload);

        if (! $normalised) {
            return null; // Event type we don't care about
        }

        if ($this->deduplicate->isDuplicate($normalised)) {
            return null;
        }

        return FeedItem::create([
            'source' => FeedSource::GitHub,
            'event_type' => $normalised->type,
            'title' => $normalised->title,
            'description' => $normalised->description,
            'metadata' => $normalised->metadata,
            'occurred_at' => $normalised->occurredAt,
        ]);
    }
}

The normalisation Action handles the mess of different GitHub event formats. The deduplication Action checks for duplicates. The parent Action orchestrates. Each piece is testable. Each piece has a single reason to change.

Dependency Injection

Actions should use constructor injection for their dependencies:

class SendInvoice
{
    public function __construct(
        private MailerInterface $mailer,
        private GenerateInvoicePdf $generatePdf,
    ) {}

    public function execute(Invoice $invoice): void
    {
        $pdf = $this->generatePdf->execute($invoice);

        $this->mailer->send(
            new InvoiceMail($invoice, $pdf),
            $invoice->client->email
        );

        $invoice->update(['sent_at' => now()]);
    }
}

Laravel's container resolves these automatically. In tests, you can mock them:

it('sends the invoice email', function () {
    Mail::fake();

    $invoice = Invoice::factory()->create();

    (new SendInvoice(
        mailer: app(MailerInterface::class),
        generatePdf: new GenerateInvoicePdf(),
    ))->execute($invoice);

    Mail::assertSent(InvoiceMail::class);

    expect($invoice->fresh()->sent_at)->not->toBeNull();
});

Naming Conventions

Action names should be verb phrases that describe what they do:

  • CreatePayout — not PayoutCreator or PayoutService
  • AcceptJobQuote — not QuoteAccepter
  • ProcessGitHubEvent — not GitHubEventHandler
  • SyncRepositoryStats — not RepositoryStatsSyncer

The execute method name is deliberate. Some people use handle or __invoke. I prefer execute because it's explicit and doesn't collide with Laravel's job/listener handle convention. Pick one and stick with it across your entire codebase.

When Not to Use Actions

Not everything needs an Action. Simple CRUD with no business logic? A controller method is fine:

public function update(UpdateProfileRequest $request): RedirectResponse
{
    $request->user()->update($request->validated());

    return back()->with('success', 'Profile updated.');
}

There's no business logic here. No preconditions to validate. No side effects to trigger. Making an UpdateProfile Action for this would be ceremony for ceremony's sake.

The threshold is: if there's a business rule, it goes in an Action. If it's just data in, data out, the controller can handle it.

Directory Organisation

Organise Actions by domain, not by type:

app/Actions/
├── Article/
│   ├── PublishArticle.php
│   ├── UnpublishArticle.php
│   └── GenerateArticleSlug.php
├── FeedItem/
│   ├── ProcessGitHubEvent.php
│   ├── NormaliseGitHubPayload.php
│   └── DeduplicateFeedItem.php
├── Payment/
│   ├── CreatePayout.php
│   ├── CalculatePayoutAmount.php
│   └── ValidatePayoutEligibility.php
└── Repository/
    ├── SyncRepositoryStats.php
    └── FetchRepositoryLanguages.php

This mirrors how you think about the application. "I need to change something about payouts" → go to Actions/Payment/. You're not hunting through a flat list of 50 Actions or a generic Services/ directory.

The Bottom Line

Actions aren't revolutionary. They're just the single-responsibility principle applied consistently to business logic. But that consistency compounds. Six months into a project, when someone asks "where does the payout calculation live?", the answer is always the same: in the Actions directory, under the domain it belongs to.

That predictability is worth more than any clever architecture.


I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.