Laravel Action Classes: Stop Writing Fat Controllers (Refactor 200 Lines to 15)

Avatar
D

Daniel O'Connor

Staff Systems Engineer•
4Views
5mRead
0Reactions

Laravel Action Classes: Stop Writing Fat Controllers (Refactor 200 Lines to 15)

TL;DR: Stuffing validation, payment API calls, database transactions, and email dispatches into a 200-line controller creates fragile code you can't reuse or easily unit test. Moving that workflow into an invokable Laravel Action Class shrinks your controller from 200 lines down to 15 lines (a 92% reduction) and lets you run isolated unit tests in 12ms without hitting the HTTP stack.

📺Watch the 45-Second Video Summary: Want the quick visual breakdown? Watch it on YouTube Shorts: Stop Writing Fat Controllers in Laravel.

The Checkout Nightmare: One Merge Conflict Away From an Outage

It starts innocently enough.

A junior developer writes a store() method in OrderController to handle checkout. Six months later, product asks for promo codes, Stripe 3D Secure handling, inventory decrementing, Slack alerts, and PDF receipts.

Your store() method is now 214 lines long.

Friday at 4:45 PM, someone tweaks the Stripe charge block. A merge conflict slips in, checkout throws a 500 error in production, and your CI suite didn't catch it because nobody could write a clean unit test for a method tangled up with Request, Auth::user(), Stripe SDK calls, and Mail::to().

And now the CLI team needs the exact same order creation flow inside a recurring subscription renewal job. You can't call OrderController::store() from a queue worker. So someone copy-pastes 120 lines into an Artisan command.

Now you have two bugs in two places.

The Code Trap: The 200-Line Fat Controller

Look familiar? Here's the classic bloated controller pattern hiding in thousands of production Laravel repos:

// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Mail\OrderConfirmed;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;

class OrderController extends Controller
{
    public function store(Request $request, StripeClient $stripe): JsonResponse
    {
        // 1. Inline validation rules (30+ lines in real apps)
        $validated = $request->validate([
            'product_id' => ['required', 'integer', 'exists:products,id'],
            'quantity'   => ['required', 'integer', 'min:1', 'max:20'],
            'token'      => ['required', 'string'],
        ]);

        // ❌ THE ARCHITECTURE TRAP:
        // Business rules, inventory checks, external payment I/O, DB transactions,
        // and notifications are all trapped inside an HTTP Controller.
        $product = Product::findOrFail($validated['product_id']);

        if ($product->stock < $validated['quantity']) {
            return response()->json(['error' => 'Insufficient stock.'], 422);
        }

        $totalCents = $product->price_cents * $validated['quantity'];

        $charge = $stripe->charges->create([
            'amount'   => $totalCents,
            'currency' => 'usd',
            'source'   => $validated['token'],
        ]);

        $order = DB::transaction(function () use ($request, $product, $validated, $totalCents, $charge) {
            $product->decrement('stock', $validated['quantity']);

            return Order::create([
                'user_id'          => $request->user()->id,
                'product_id'       => $product->id,
                'quantity'         => $validated['quantity'],
                'total_cents'      => $totalCents,
                'stripe_charge_id' => $charge->id,
            ]);
        });

        Mail::to($request->user())->queue(new OrderConfirmed($order));

        return response()->json(['data' => $order], 201);
    }
}

Under the Hood: Why Fat Controllers Break Down

This code passes code review when it's 35 lines. By line 200, four structural failures kick in:

  1. HTTP Lock-In: The method expects an Illuminate\Http\Request and returns a JsonResponse. You can't invoke it from a queued job, webhook listener, or CLI command without faking an HTTP request.

  2. Impossible Unit Isolation: Testing the stock deduction or total calculation forces you to spin up Laravel's HTTP router, middleware stack, authentication guard, and JSON serialization.

  3. Bloated Constructor Dependencies: As OrderController adds index, update, cancel, and refund, its constructor accumulates 9 injected services—every single endpoint pays to resolve dependencies it doesn't even use.

  4. High Cyclomatic Complexity: Validation branches, payment exceptions, discount edge cases, and response formatting collide inside one scope.


The Optimal Architecture: Thin Controller + Single Action Class

Core Rule: Controllers only speak HTTP. Business logic belongs in dedicated, invokable Action classes.

When you split HTTP plumbing from domain execution, your request lifecycle becomes predictable and reusable across web routes, API endpoints, console commands, and background workers:

flowchart TD
    Req[HTTP POST /api/orders] --> FormReq[StoreOrderRequest: Auth & Validation]
    FormReq --> Ctrl[OrderController@store - 15 Lines]
    Ctrl -->|Passes OrderData DTO| Action[CreateOrderAction::__invoke]
    Action -->|DB Transaction + Stripe| DB[(MySQL + Stripe API)]
    Action -->|Returns Order Model| Ctrl
    Ctrl -->|OrderResource| Res[201 JSON Response]

    Job[Queued Renewal Job] -->|Reuses same DTO| Action
    Test[Unit Test - 12ms] -->|Mocks Stripe, skips HTTP| Action

Notice the difference. CreateOrderAction has zero knowledge of HTTP status codes, JSON headers, or Request bags.

Step-by-Step Implementation

Step 1: Isolate Validation in a FormRequest

Move input validation and authorization out of the controller completely:

// app/Http/Requests/StoreOrderRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    public function rules(): array
    {
        return [
            'product_id' => ['required', 'integer', 'exists:products,id'],
            'quantity'   => ['required', 'integer', 'min:1', 'max:20'],
            'token'      => ['required', 'string'],
        ];
    }

    // 👈 1. Convert validated HTTP input into a clean typed DTO
    public function toDto(): \App\Data\OrderData
    {
        return new \App\Data\OrderData(
            userId: (int) $this->user()->id,
            productId: (int) $this->integer('product_id'),
            quantity: (int) $this->integer('quantity'),
            paymentToken: (string) $this->string('token'),
        );
    }
}

Step 2: Define a TypedreadonlyDTO

A small PHP 8.2+readonly class prevents associative-array key typos ('product_id'vs'productId') across your app:

// app/Data/OrderData.php
namespace App\Data;

readonly class OrderData
{
    public function __construct(
        public int $userId,
        public int $productId,
        public int $quantity,
        public string $paymentToken,
    ) {}
}

Step 3: Build the Invokable Action Class

Put the entire domain operation inside app/Actions/Orders/CreateOrderAction.php. One class, one public __invoke() method:

// app/Actions/Orders/CreateOrderAction.php
namespace App\Actions\Orders;

use App\Data\OrderData;
use App\Exceptions\InsufficientStockException;
use App\Mail\OrderConfirmed;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;

class CreateOrderAction
{
    // 👈 1. Inject only what THIS single action needs
    public function __construct(
        private readonly StripeClient $stripe,
    ) {}

    // 👈 2. Accept a typed DTO and return a Domain Model, never JsonResponse
    public function __invoke(OrderData $data): Order
    {
        $product = Product::findOrFail($data->productId);

        if ($product->stock < $data->quantity) {
            throw new InsufficientStockException("Only {$product->stock} units left.");
        }

        $totalCents = $product->price_cents * $data->quantity;

        $charge = $this->stripe->charges->create([
            'amount'   => $totalCents,
            'currency' => 'usd',
            'source'   => $data->paymentToken,
        ]);

        $order = DB::transaction(function () use ($data, $product, $totalCents, $charge): Order {
            $product->decrement('stock', $data->quantity);

            return Order::create([
                'user_id'          => $data->userId,
                'product_id'       => $product->id,
                'quantity'         => $data->quantity,
                'total_cents'      => $totalCents,
                'stripe_charge_id' => $charge->id,
            ]);
        });

        Mail::to($order->user)->queue(new OrderConfirmed($order));

        return $order;
    }
}

Step 4: Shrink the Controller to 15 Clean Lines

Laravel's service container automatically resolves CreateOrderAction right inside the store() method signature:

// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Actions\Orders\CreateOrderAction;
use App\Http\Requests\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use Illuminate\Http\JsonResponse;

class OrderController extends Controller
{
    public function store(
        StoreOrderRequest $request,
        CreateOrderAction $createOrder,
    ): JsonResponse {
        // 👈 3. Invoke the Action like a function, then format HTTP output
        $order = $createOrder($request->toDto());

        return (new OrderResource($order))
            ->response()
            ->setStatusCode(201);
    }
}

Look how readable that is. You can understand the entire HTTP contract in three seconds.

Bonus: Testing the Action in Complete Isolation

Now watch how clean a Pest or PHPUnit test becomes when business logic isn't trapped behind HTTP middleware:

// tests/Unit/Actions/CreateOrderActionTest.php
it('throws an exception when stock is insufficient without calling Stripe', function () {
    $product = Product::factory()->create(['stock' => 1, 'price_cents' => 2500]);
    $stripe = Mockery::mock(StripeClient::class);
    $stripe->shouldNotReceive('charges');

    $action = new CreateOrderAction($stripe);

    expect(fn () => $action(new OrderData(
        userId: 1,
        productId: $product->id,
        quantity: 5,
        paymentToken: 'tok_visa',
    )))->toThrow(InsufficientStockException::class);
});

Zero HTTP kernel boot. Zero route matching. Runs in milliseconds.

Comparison: 200-Line Fat Controller vs. Single Action Class

Metric

200-Line Fat Controller

15-Line Controller + Action Class

Impact

Controller Method Size

~200 lines

15 lines

92% smaller controller

Reuse in Jobs / CLI

Impossible without duplication

Direct$createOrder($dto) call

Zero duplicated logic

Test Isolation

Full HTTP feature test (~180ms/test)

Direct Action unit test (~12ms/test)

15x faster feedback loop

Dependency Injection

Bloated controller constructor

Scoped per-action constructor

Zero wasted container resolves

Merge Conflict Risk

High (everyone edits one file)

Low (one file per business action)

Clean Git history

4 Production Golden Rules for Laravel Action Classes

  1. Never Pass Request Into an Action:

  • The second you type-hint Request inside CreateOrderAction, you've locked that class to HTTP. Always pass a readonly DTO or explicit typed arguments so jobs, listeners, and Artisan commands can call the exact same action.

  • Keep Actions Composable:

    • Don't cram 400 lines into one Action either. An Action can inject smaller Actions—for example, CheckoutOrderAction can orchestrate ValidateInventoryAction, ChargeCustomerAction, and ReserveStockAction.

  • Name Actions as<Verb><Noun>Action:

    • Avoid vague names like OrderService or OrderManager. Those turn into 1,000-line dumping grounds. Use crisp action verbs: CreateOrderAction, CancelSubscriptionAction, ExportInvoicePdfAction.

  • Use DB::afterCommit() for Side Effects Inside Transactions:

    • If you wrap an Action inside a larger database transaction, dispatching a queued email or webhook before commit can cause a race condition where the worker queries a row that doesn't exist yet. Use Mail::to(...)->afterCommit()->queue(...) or DB::afterCommit(...).

  • Frequently Asked Questions

    What is a Single Action Class in Laravel?

    A Single Action Class is a plain PHP class responsible for one specific business operation, such as CreateOrderAction or PublishArticleAction. It typically exposes a single public __invoke() or handle() method, keeping controllers focused strictly on HTTP requests and responses.

    Should I pass Illuminate\Http\Request directly into a Laravel Action class?

    No. Passing the HTTP Request object couples your business logic to the web layer, making it harder to reuse the action from Artisan commands or queued jobs. Extract validated data in a FormRequest first, then pass typed primitives or a readonly Data Transfer Object (DTO) into the action.

    What is the difference between Laravel Action classes and Service classes?

    Service classes tend to grow into 800-line "god classes" (like OrderService) packed with unrelated methods and tangled dependencies. An Action class enforces the Single Responsibility Principle by putting one operation per class, so each file stays small, focused, and easy to unit test.

    How do Action classes make unit testing faster in Laravel?

    Because an Action class does not depend on HTTP routing, middleware, or CSRF tokens, you can instantiate or resolve it directly in PHPUnit or Pest. You can mock external gateways like Stripe and test the core business rules in milliseconds without booting full HTTP feature tests.

    Summary & Related Guides

    Controllers have one job: accept an HTTP request, validate input, hand off work, and return an HTTP response. Moving domain workflows into Single Action Classes keeps every endpoint short, makes business rules reusable across queues and CLI commands, and turns painful integration tests into fast unit tests.

    Continue Reading on KoderSolution:

    Connect with KoderSolution 🚀

    Master modern software engineering, scalable architectures, and advanced backend systems with KoderSolution:

    • 🌐Official Website: Read more architectural deep-dives and tutorials on kodersolution.com

    • 📺YouTube Channel: Subscribe to@kodersolution on YouTube for concise, animated system design breakdowns and shorts.

    • 📱Facebook Page: Follow our official Facebook Page for daily development tips, architecture discussions, and community updates.

    💬What's your setup? How does your Laravel codebase organize business logic—Single Action Classes, Service classes, or Domain-Driven modules? Share your approach below!

    Daniel O'Connor
    D
    Written by•Staff Systems Engineer

    Daniel O'Connor

    Broke production enough times to respect database transactions and idempotent jobs. Specializes in Redis queues, Horizon workers, and webhook resilience.

    React to this article

    Recommended Articles

    View all articles