We’ve all been there. You start a new Laravel project, and everything feels clean. Your StoreController has three lines of code. It’s a masterpiece. Fast forward six months, and that same controller is a 500-line monster handling file uploads, third-party API calls, complex validation, and three different types of email notifications.

In the world of Laravel development, "Fat Controllers" are a common technical debt. We often start with simple logic, but as features grow—validation, authorization, business logic, and notifications—controllers become bloated and hard to maintain.

Today, we’re going to look at how to put your controllers on a diet and embrace the "Skinny Controller, Smart Architecture" philosophy.


What is "Fat Controller" : Why It’s Killing Your App

Before we fix the problem, let's understand why it’s a problem. A controller’s primary job is simple: Receive a request and return a response.

The Violations of Single Responsibility Principle (SRP)

When a controller starts doing "business logic"—like calculating a user's discount based on their purchase history or formatting a PDF summary—it violates the Single Responsibility Principle (SRP).

  • Testing Torture: To test a 200-line controller method, you have to mock the entire HTTP layer, session, and every service the controller touches.
  • Zero Reusability: If you need to perform the same logic in an Artisan command or a Job, you end up copy-pasting code because it’s "trapped" in the controller.
  • Cognitive Overload: Junior developers (and future you) will struggle to understand what the code actually does amidst the sea of if-else blocks and nested loops.

The Golden Rule: A controller should only handle the request and return the response. Everything else belongs elsewhere.


Strategy 1: Moving Validation to Form Requests

The easiest win is moving validation out of the method body. Instead of using $request->validate([...]) inside your store or update methods, move validation out of your controller and into dedicated Request classes.

This keeps your methods clean and ensures validation logic is reusable.

The "Skinny" Way

Run php artisan make:request StorePostRequest.

PHP
// app/Http/Requests/StorePostRequest.php
public function rules(): array
{
    return [
        'title' => 'required|max:255',
        'body' => 'required',
        'category_id' => 'exists:categories,id',
    ];
}
 
// In your Controller
public function store(StorePostRequest $request)
{
    // If we reach here, the data is already validated!
    Post::create($request->validated());[cite:1, 2]
   
    return redirect()->route('posts.index');[cite:1, 2]
}

By the time the controller logic starts, you know the data is safe. This one change can often shave 10–20 lines off every method.


Strategy 2: Using the Action Pattern for Business Procedures

If you have logic that feels like a "procedure" (e.g., "Create a user, assign a role, send a welcome email, and ping Slack"), use an Action Class. Actions are simple PHP classes that perform exactly one task.

They are perfect for complex logic that involves multiple steps, like registering a user and sending a welcome kit.

Example: Creating a Vacancy

Imagine you are building a job portal. Saving a vacancy isn't just a database insert; it might involve parsing a notification PDF and notifying subscribers.

PHP
namespace App\Actions;
 
use App\Models\Vacancy;
use App\Services\PdfParser;
 
class CreateVacancyAction
{
    public function execute(array $data, $pdfFile):Vacancy
    {
        // Complex logic hidden inside the action
        $content = (new PdfParser())->extractText($pdfFile);
       
        $vacancy = Vacancy::create(array_merge($data,['content_summary' =>str()->limit($content, 200),]));
 
        return $vacancy;
    }
}

Now your controller looks like this:

PHP
public function store(StoreVacancyRequest $request, CreateVacancyAction $createVacancy)
{
    $createVacancy->execute($request->validated(), $request->file('notification_pdf'));
 
    return back()->with('success', 'Vacancy published successfully!');
}

Strategy 3: Abstracting Integrations with the Service Layer

If your controller is making Http::post() calls to a payment gateway or a vacancy aggregator, that logic belongs in a Service. Use Services to encapsulate third-party API logic.

If you're fetching vacancies from a portal or sending SMS updates, put that logic in a Service class.

Why use Services?

  • Abstraction: The controller doesn't need to know about API keys or JSON headers.
  • Swappability: You can easily swap a "StripeService" for a "PayPalService" without touching the controller logic.

Strategy 4: Leveraging Eloquent Query Scopes

Stop writing massive where chains in your controller. If you’re building a portal where you often filter for "active vacancies from a specific region," move that logic to the Model.

In the Model

PHP
public function scopeActive($query)
{
    return $query->where('expires_at', '>',now())->where('status', 'published');
}
 
public function scopeFromRegion($query, $region)
{
    return $query->where('location', $region);
}

In the Controller

PHP
// Instead of this:
$jobs = Vacancy::where('status', 'published')->where('expires_at', '>',now())->where('location', 'Haldwani')->get();
 
// Do this:
$jobs = Vacancy::active()->fromRegion('Haldwani')->get();

It reads like a sentence. This is self-documenting code that is also reusable across your entire application.


Strategy 5: Offloading Latency to Background Jobs

Does your controller wait for an email to be sent or a document to be summarized before responding to the user? That’s a bad user experience.

Any task that doesn't need to happen immediately for the user to see the next page should be pushed to a Queue.

  • Generating PDF summaries.
  • Sending WhatsApp updates to 5,000+ students.
  • Syncing data with a search engine.

Use php artisan make:job ProcessVacancyNotification. Your controller just dispatches it: ProcessVacancyNotification::dispatch($vacancy);.


Summary and Action Plan

Your Next Sprint: Step-by-Step Refactoring Checklist

  1. Audit your Controllers: Find the largest file and count the lines in the biggest method.
  2. Move validation: Convert at least three methods to use FormRequests.
  3. Identify a "Service": Pick a piece of logic that communicates with an outside API and move it to app/Services.
  4. Refactor one Query: Take a long Eloquent chain and turn it into a reusable scope.

Key Takeaways for Your Architecture

  • Controllers are traffic cops: They should direct traffic, not build the road.
  • Actions for procedures: Use them for logic that "does something."
  • Services for integrations: Use them for logic that "talks to someone else."
  • Scopes for data: Use them for logic that "finds something."

Conclusion

Thin controllers are about more than just aesthetics; they are about predictability. By shifting logic to Form Requests, Actions, Services, and Jobs, you create a modular, testable, and scalable application.

Start small and refactor one method at a time. Next time you go to write an if statement in a controller, ask yourself: "Does the controller really need to know this?" If the answer is no, give it a new home.