Streamlining Laravel Controllers Keeping Your Code Clean and Maintainable
- Published on
- -5 mins read
- Authors
- Name
- Muhammad Iqbal
- @dibaliqaja
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:
indexcreatestoreshoweditupdatedestroyThey 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.