Streamlining Laravel Controllers Keeping Your Code Clean and Maintainable

Published on
-
5 mins read
Authors
Streamlining Laravel Controllers

Introduction

In applications built with the MVC (Model–View–Controller) architecture, the controller plays a crucial role.
Controllers typically handle incoming requests, perform some application logic, and then return a response, often through a view.

However, as projects grow, controllers can easily become bloated. When too much logic is placed inside them, they quickly become difficult to maintain.

In this article, we’ll walk through a few practical approaches to keep Laravel controllers clean, focused, and maintainable.


The Problem With Bloated Controllers

When controllers start to accumulate too much code, several issues usually appear.

1. Harder to navigate and maintain

When a controller grows too large, it becomes harder to quickly locate the method or logic you're looking for.

Breaking logic into smaller, well-structured pieces makes the codebase easier to explore and maintain.

2. Bugs become harder to trace

If authorization, validation, business logic, and response handling all live in the same method, debugging becomes much harder.

Separating responsibilities helps isolate problems more quickly.

3. Testing becomes more complicated

Large controller methods often handle too many responsibilities.
This makes them harder to test, especially when writing more advanced or granular tests.

Cleaner, modular code naturally leads to simpler and more reliable testing.


Example of a Bloated Controller

Let’s start with a simple example using a UserController.

<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function store(Request $request): RedirectResponse
{
$this->authorize('create', User::class);
$request->validate([
'name' => 'string|required|max:50',
'email' => 'email|required|unique:users',
'password' => 'string|required|confirmed',
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
return redirect(route('users.index'));
}
public function unsubscribe(User $user): RedirectResponse
{
$user->unsubscribeFromNewsletter();
return redirect(route('users.index'));
}
}

At first glance, this looks fine. But as the project grows, logic like this can quickly pile up.

Let’s see how we can improve it.


1. Move Validation and Authorization to Form Requests

The first step is to move validation and authorization logic into a dedicated Form Request.

Create a new request class:

php artisan make:request StoreUserRequest

Laravel will generate:

app/Http/Requests/StoreUserRequest.php

Inside this class, we define both authorization and validation rules.

<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return Gate::allows('create', User::class);
}
public function rules(): array
{
return [
'name' => 'string|required|max:50',
'email' => 'email|required|unique:users',
'password' => 'string|required|confirmed',
];
}
}

The great thing about Form Requests is that Laravel automatically runs these methods before the controller logic executes.

Your controller now becomes much cleaner:

class UserController extends Controller
{
public function store(StoreUserRequest $request): RedirectResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
return redirect(route('users.index'));
}
}

2. Move Business Logic into Actions or Services

Another improvement is to extract business logic into a dedicated class.

For smaller tasks, an Action class often works well.

Example:

class StoreUserAction
{
public function execute(Request $request): void
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
}
}

Now the controller becomes even simpler:

class UserController extends Controller
{
public function store(
StoreUserRequest $request,
StoreUserAction $storeUserAction
): RedirectResponse {
$storeUserAction->execute($request);
return redirect(route('users.index'));
}
}

At this point, the controller acts mainly as a bridge between the request and the response.

Much easier to read, right?


3. Using DTOs (Data Transfer Objects)

Another advantage of extracting business logic is that we can introduce DTOs (Data Transfer Objects).

DTOs help decouple your business logic from request structures.

For example, if your API uses email_address instead of email, passing the raw request object to your action could break things.

Using a DTO solves this.

Install Spatie's DTO package:

composer require spatie/data-transfer-object

Create a DTO:

class StoreUserDTO extends DataTransferObject
{
public string $name;
public string $email;
public string $password;
}

Then update the request class:

public function toDTO(): StoreUserDTO
{
return new StoreUserDTO(
name: $this->name,
email: $this->email,
password: $this->password,
);
}

Now the controller passes the DTO:

$storeUserAction->execute($request->toDTO());

And the action receives the DTO:

class StoreUserAction
{
public function execute(StoreUserDTO $data): void
{
$user = User::create([
'name' => $data->name,
'email' => $data->email,
'password' => $data->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
}
}

This approach makes your logic reusable across web controllers, API controllers, and even console commands.


4. Use Resource Controllers or Single-Action Controllers

A good long-term strategy is to keep controllers focused by using:

Resource Controllers

Resource controllers follow REST conventions:

index
create
store
show
edit
update
destroy

They help keep related logic grouped around a specific resource.

Single-Action Controllers

Sometimes an action doesn’t fit into a typical REST structure.

Laravel provides invokable controllers, which contain only one method: __invoke().

Create one like this:

php artisan make:controller UnsubscribeUserController -i

Example implementation:

class UnsubscribeUserController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
$request->user->unsubscribeFromNewsletter();
return redirect(route('users.index'));
}
}

This pattern works well for small, focused tasks.


Conclusion

Keeping controllers clean is mostly about separating responsibilities.

Some practical ways to achieve this include:

  • Moving validation and authorization to Form Requests
  • Extracting logic into Actions or Services
  • Using DTOs to decouple request data
  • Structuring controllers as Resource or Single-Action Controllers

There’s no single “perfect” approach. Every team and project may have slightly different preferences.

The key is to stay consistent and adopt patterns that make your codebase easier for everyone on the team to understand.

That’s all. I hope this was helpful. See you later.