Back to Blog
Lesson 1 of the Intermediate Laravel: Real-World Application Patterns course
LaravelJune 25, 20263 min read

Architecting for Maintainability: Refactoring Laravel Controllers

Stop writing fat controllers. Learn how to identify controller bloat, extract logic into dedicated classes, and use dependency injection for cleaner code.

LaravelArchitectureRefactoringDependency InjectionBest Practicesphpbackend

Welcome to the first step in our journey to build professional-grade Laravel applications. We are starting our project board application today, and the most important habit you can form is protecting your controllers from becoming "junk drawers."

In this course, we will focus on building a robust, testable, and scalable architecture. While Laravel interfaces and service contracts for cleaner architecture will be our goal later, we start here by mastering the basics of separation of concerns.

Identifying Controller Bloat

A controller's primary responsibility is to handle the HTTP request, coordinate with the application layer, and return a response. When you find yourself writing database queries, calculating business logic, or sending emails directly inside a controller method, you have "controller bloat."

Signs of bloat include:

  • Methods exceeding 10 lines of code.
  • Direct calls to DB::table() or complex Eloquent queries inside the controller.
  • Business rules (e.g., "a user can only have 5 active projects") hardcoded in the method.
  • Difficulty writing unit tests because the controller is tightly coupled to the database.

Refactoring Logic into Dedicated Classes

Let's look at a "bloated" example. Imagine we are creating a project in our project board.

PHP
public function store(Request $request)
{
    $request->validate(['name' => 'required']);

    #6A9955">// Logic bloat: calculating limits and setting defaults
    $count = Project::where('user_id', auth()->id())->count();
    if ($count >= 5) {
        return response()->json(['error' => 'Limit reached'], 403);
    }

    $project = Project::create([
        'name' => $request->name,
        'user_id' => auth()->id(),
        'slug' => Str::slug($request->name),
    ]);

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

This logic belongs in a service class. By moving it, the controller becomes a thin "traffic cop." We will create a ProjectCreator class to handle this.

PHP
namespace App\Actions;

use App\Models\Project;
use Illuminate\Support\Str;

class ProjectCreator
{
    public function execute(array $data, int $userId): Project
    {
        if (Project::where('user_id', $userId)->count() >= 5) {
            throw new \Exception("Limit reached");
        }

        return Project::create([
            'name' => $data['name'],
            'user_id' => $userId,
            'slug' => Str::slug($data['name']),
        ]);
    }
}

Implementing Dependency Injection

Now, we inject this class into our controller. Laravel’s Service Container automatically resolves the class, making our controller clean and testable.

PHP
public function store(Request $request, ProjectCreator $creator)
{
    $request->validate(['name' => 'required']);

    try {
        $project = $creator->execute($request->all(), auth()->id());
        return response()->json($project, 201);
    } catch (\Exception $e) {
        return response()->json(['error' => $e->getMessage()], 403);
    }
}

This approach mirrors the principles discussed in Laravel Contextual Binding: Injecting Different Implementations Easily, where we treat components as interchangeable services rather than hardcoded dependencies.

Hands-on Exercise

  1. Create a directory app/Actions.
  2. Move the logic for adding a "Task" to a project (e.g., setting a default status) into a new class called TaskCreator.
  3. Inject TaskCreator into your TaskController.
  4. Observe how much smaller your store method becomes.

Common Pitfalls

  • Over-engineering: Don't create a service class for a simple User::find($id). Keep the architecture proportional to the complexity.
  • Circular Dependencies: If Service A needs Service B, and Service B needs Service A, you've likely missed a layer of abstraction.
  • Hidden Dependencies: Avoid using the app() helper inside your service classes. Always inject dependencies through the constructor to keep them visible and testable.

Recap

We've moved from writing procedural code in controllers to an architecture that separates concerns. By extracting logic into dedicated classes and using dependency injection, we ensure our project board remains maintainable as it grows. You now have a blueprint for "thin" controllers that will serve you throughout this course.

Up next: We'll dive deeper into structuring these classes by Implementing the Service Layer to standardize how our application handles complex business processes.

Similar Posts