← all posts

The service layer is not optional

·2 min read ·Laravel · Backend · Architecture

The first time I saw a 500-line controller, I thought the developer was brave. Turns out they were just in a hurry, and then everyone else had to live with it.

A controller's job is to receive a request, hand it off, and return a response. That's it. The moment your controller starts touching the database directly, calling external APIs, firing emails, and making business decisions, you've broken the contract.

What a Service Actually Does

A service class owns a piece of business logic. Nothing more. It takes inputs, does a thing, returns a result.

class UserRegistrationService
{
    public function register(array $data): User
    {
        $user = User::create([
            'name'     => $data['name'],
            'email'    => $data['email'],
            'password' => Hash::make($data['password']),
        ]);

        event(new UserRegistered($user));

        return $user;
    }
}

Your controller calls $this->registrationService->register($request->validated()) and moves on. Done.

Why This Matters for Testing

A fat controller is nearly impossible to unit test. You have to boot the whole HTTP kernel, fake requests, and mock everything in sight. With a service class, you just instantiate it and call the method. Your tests run in milliseconds and tell you exactly what's broken.

The Injection Point

When your service class is injected via the constructor, Laravel's container handles the rest. Swap implementations freely. Add decorators. Cache results. None of that touches the controller.

The Pattern Doesn't Change

Whether you're building a REST API, a Livewire application, or a queued job, the logic lives in the service. The controller, the command, the job—they all call the same service. Write the logic once. Use it everywhere.

Fat controllers are the first technical debt you accumulate. They're also the most expensive to pay back later. Start with the service layer from day one.