Skip to content
Muhammet Şafak
tr
Framework & Library 4 min read

Slimming Laravel Controllers with Action Classes

How to write more testable and maintainable code by moving business logic out of controllers and into single-purpose action classes.


Controllers tend to be where things bloat the most in a Laravel project over time. A single method grows to hundreds of lines; validation, business logic, notification dispatch, and model updates all become tangled together. There are several ways to break this apart. Lately I’ve been reaching for action classes — in this post I’ll explain why and how I use them.

The Problem

Controllers are the gateway of the HTTP layer: they receive a request, process it, and return a response. But when the “process it” part grows, the controller ends up carrying responsibilities that go well beyond HTTP. This creates two problems.

First, whenever you need to trigger the same business logic from a different place, you copy it. A user registration can be triggered via a web form, an API, and an admin artisan command. If the business logic lives in the controller, you either write it in three separate places or build a convoluted inheritance chain.

Second, testing a controller method means simulating an HTTP request. To test a simple business rule you bear the full cost of bootstrapping the framework.

I once looked at an OrderController@store method that had reached 180 lines. Stock checking, total calculation, order creation, invoice generation, and notification dispatch — all inside a single method. When I needed to add a new trigger (say, an incoming order from a webhook), rather than duplicating the code I started looking for a better approach.

What Is an Action Class

An action class is a plain PHP class that encapsulates a single business operation. It has minimal framework dependencies and typically exposes a single execute or handle method.

<?php

namespace App\Actions;

use App\Models\User;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class CreateOrderAction
{
    public function execute(User $user, array $items): Order
    {
        return DB::transaction(function () use ($user, $items) {
            $order = $user->orders()->create([
                'status' => 'pending',
                'total'  => collect($items)->sum('price'),
            ]);

            foreach ($items as $item) {
                $order->items()->create($item);
            }

            return $order;
        });
    }
}

The controller uses this class but has no knowledge of the business logic itself:

<?php

namespace App\Http\Controllers;

use App\Actions\CreateOrderAction;
use App\Http\Requests\CreateOrderRequest;

class OrderController extends Controller
{
    public function store(CreateOrderRequest $request, CreateOrderAction $action)
    {
        $order = $action->execute(
            $request->user(),
            $request->validated('items')
        );

        return response()->json($order, 201);
    }
}

Laravel’s service container injects CreateOrderAction automatically. No additional configuration required.

Where It Pays Off

Action classes don’t solve everything. Creating an extra class for simple CRUD operations adds unnecessary complexity. The following scenarios make for a good threshold:

  • The same business logic needs to be triggered from more than one entry point (web, API, artisan command).
  • The business logic involves more than 3–4 steps that need to be tested independently.
  • There’s a reasonable chance this logic will need to run via a queue in the future.

For simple single-step operations, keeping things inside the controller is cleaner.

There’s also a common trap: some developers over-apply action classes and end up with tiny slices like UpdateUserEmailAction, UpdateUserNameAction, UpdateUserPhoneAction. That’s unnecessary granularity. If you can’t answer “yes” to “could this logic reasonably be triggered from somewhere other than this controller?”, it’s too early to introduce an action class.

The Testing Advantage, Concretely

Testing an action class requires no HTTP layer at all:

<?php

use App\Actions\CreateOrderAction;
use App\Models\User;

it('creates order with correct total', function () {
    $user   = User::factory()->create();
    $action = new CreateOrderAction();

    $items = [
        ['name' => 'Product A', 'price' => 100],
        ['name' => 'Product B', 'price' => 50],
    ];

    $order = $action->execute($user, $items);

    expect($order->total)->toBe(150)
        ->and($order->items()->count())->toBe(2);
});

This test runs much faster and tests only what it cares about.

Comparison with Alternatives

Service classes serve a similar purpose but typically host many methods. A UserService might contain create, update, delete, and resetPassword all in one place. These classes tend to bloat over time. Action classes enforce the single responsibility principle more strictly.

Form Requests can address part of the problem, but their purpose is strictly validation and authorization. Moving business logic there creates its own set of problems.

The repository pattern is a separate layer for abstracting database access. These aren’t competing approaches — action classes and repositories work together just fine.

File Organization

Rather than keeping a flat list under App\Actions, grouping by domain is more sustainable as projects grow:

App/Actions/
├── Orders/
│   ├── CreateOrderAction.php
│   ├── CancelOrderAction.php
│   └── RefundOrderAction.php
└── Users/
    ├── RegisterUserAction.php
    └── DeactivateUserAction.php

This structure means that when someone new joins the team and asks “where does the order-related business logic live?”, they get an immediate answer. No need to go hunting inside controllers.

Ultimately this is a tool, not a doctrine. Use it when it genuinely makes the work easier and keeps things consistent in your project. Otherwise it’s just added complexity and nothing more.

Tags: #Laravel
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind