Clean Laravel Architecture: From Fat Controllers to Services, Actions, and Form Requests
A practical, code-driven guide to turning a fat Laravel controller into a clean architecture: Form Requests for validation, Actions and Services for business logic, API Resources for responses, and when to use each.
Let's start with a scene familiar to anyone who has worked on a Laravel project that grew over time. You open a controller to change a simple line, and find a single method a hundred lines long: input validation, business logic, database queries, sending email, writing to the log, all tangled together. Any small change becomes an adventure, testing is nearly impossible, and reusing the logic elsewhere means copy-paste. This is what developers call the "fat controller," one of the most common sources of chaos in Laravel applications.
The solution is not complicated, but it requires discipline in distributing responsibilities. We'll take a single example — registering a new user — and move it from the chaos of a fat controller to a clean architecture, layer by layer.
The Starting Point: The Controller That Does Everything
Here is the shape we want to escape: a controller that validates, processes, saves, and notifies, all in one place.
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$user->assignRole('member');
Mail::to($user)->send(new WelcomeMail($user));
Log::info('New user registered: ' . $user->id);
return redirect()->route('users.index');
}
This code works, yes. But it mixes four separate responsibilities: validation, business logic, side effects (email and log), and the response. Let's separate them.
The First Layer: A Form Request for Validation
The first thing we extract from the controller is the validation logic. Laravel provides the Form Request class designed for exactly this purpose. Create it with:
php artisan make:request StoreUserRequest
Then put the validation rules and authorization in it:
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', User::class);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'min:8'],
];
}
}
Now validation and authorization live in one place responsible for them alone, and can be tested in isolation from everything else.
The Second Layer: An Action for a Single Operation
Here comes the most important architectural decision: where does the user-creation logic go? You have two common options, the Action and Service patterns, and the difference between them is practical, not doctrinal.
An Action class encapsulates a single complete business operation, with one public method (usually handle or execute). Create an app/Actions directory and put this in it:
namespace App\Actions\Users;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class CreateUserAction
{
public function handle(array $data): User
{
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
$user->assignRole('member');
return $user;
}
}
The advantage of an Action is that it "doesn't know" who called it: it runs from a controller, a console command, a queued job, or a test, with exactly the same code. One operation, one responsibility, reusable in any context.
When Action and When Service?
The question confuses many, and the answer is simpler than it seems. Use an Action when the logic is a complete operation driven by an event or a user action: CreateOrderAction, PublishPostAction, or CancelSubscriptionAction. A Service suits gathering interconnected logic around a particular entity or domain into one multi-method class, such as a service dealing with an external API, a complex calculator, or logic reused across several operations.
A useful practical rule: if what you are writing is a "verb" (create, delete, publish), it is usually an Action; if it is "data processing" or "integration with an external system," it is usually a Service. And do not burden a small project with layers it does not need; patterns are tools, not obligations.
The Final Shape: A Controller That Only Coordinates
After distributing the responsibilities, see how the controller shrinks to its essence: it receives the validated request, delegates the work, then returns the response.
public function store(StoreUserRequest $request, CreateUserAction $action)
{
$user = $action->handle($request->validated());
return redirect()->route('users.index');
}
Notice that Laravel injects CreateUserAction automatically via the service container as soon as you type-hint it in the method signature, so there is no need to instantiate it manually. The controller now coordinates only; it does not compute.
The Last Layer: An API Resource for the Response
If you are building an API, do not return a raw Eloquent model, because that exposes your database structure and makes your response change as it changes. Use an API Resource as an explicit transformation layer between the model and the response:
php artisan make:resource UserResource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'joined_at' => $this->created_at->toDateString(),
];
}
This way you precisely control the exposed fields and their format, and your response stays stable no matter how the internal schema changes.
And Where Do the Side Effects Go? Email and Log
What remains from our original example is sending a welcome email and writing to the log. The cleanest approach is for the Action not to carry them directly, but to fire an event after creating the user, with separate listeners subscribing to it. This keeps user creation pure, and side effects can be added or removed without touching the business logic. This separation makes both the action and the listener testable on their own.
The Architecture We Arrived At
We moved from a single crowded method to clear, distributed responsibilities: a Form Request validates and authorizes, an Action executes the business operation, an event handles side effects, an API Resource shapes the response, and a thin controller coordinates among them. The benefit is not merely aesthetic; each layer is now testable on its own, and reusing the user-creation logic in a console command or a bulk import becomes one line. For those who want to expand further, optional layers like Policies for centralized authorization and Query Services for complex queries are added when needed, not before.
The golden rule that sums up all of the above: the controller coordinates, it does not compute. Remember this every time you write a controller method, and you'll find your architecture organizing itself, and your colleague — or you, six months later — will thank you for every line you did not cram in the wrong place.
Was this article helpful?