All posts

Keep Business Logic in Service Classes

Not everything a controller does is a query. File uploads, payments and API calls belong in service classes that every entry point can share.

Marijan Kopčić••3 min read
Keep Business Logic in Service Classes

"Fat models, skinny controllers" moves query logic out of the controller and onto the model. But not everything a controller does is a query. Uploading files, charging cards, calling third-party APIs, coordinating several models: that work is too big for a controller and does not belong on a model either. Its home is a service class.

The controller that does the work itself

public function store(Request $request): RedirectResponse
{
    if ($request->hasFile('image')) {
        $image = $request->file('image');
        $image->storeAs('temp', $image->getClientOriginalName(), 'public');
    }

    // ...create the article, notify the editors, clear a cache...

    return redirect()->route('articles.index');
}

The upload logic is now welded to this one HTTP action. It also has a quiet bug: storing a file under its original client name means two users uploading photo.jpg overwrite each other. When that gets fixed, it will be fixed here, and the copy in the API controller will be forgotten.

Move the work into a service

public function store(StoreArticleRequest $request, ArticleService $articleService): RedirectResponse
{
    $articleService->uploadImage($request->file('image'));

    // ...the rest of the request handling...

    return redirect()->route('articles.index');
}

class ArticleService
{
    public function uploadImage(?UploadedFile $image): ?string
    {
        if ($image === null) {
            return null;
        }

        return $image->storeAs(
            'uploads/temp',
            uniqid().'_'.$image->getClientOriginalName(),
            'public',
        );
    }
}

The controller asks for an ArticleService and Laravel's container injects it. The upload rules, including the fix for the name collision, now live in one place. Every caller gets the fix at once.

Why this pays off

One implementation of each business operation. The web controller, the API controller, an Artisan import command and a queued job all call the same method. There is nothing to keep in sync.

Testing without HTTP. A service method is plain PHP. You can call it in a test with a fake file and assert on the result, without building a request or going through middleware.

Controllers you can read in ten seconds. When every action is "validate, call a service, return a response", the controller becomes a table of contents for the feature.

A natural place for dependencies. A service that needs a payment gateway, a mailer and a cache receives them through its constructor. The controller does not need to know any of them exist.

Where services go wrong

A service is not a dumping ground. The classic failure is a UserService with forty methods, which is just the fat controller moved to a different folder. Group services by business capability, not by model, and keep each one focused.

Equally, not everything needs a service. A controller action that loads a model and returns a view does not need a ShowArticleService in between. That is ceremony, not architecture.

A useful rule of thumb:

  • Query or attribute logic about one model → the model (scopes, accessors, relationships).
  • A business operation with side effects or several collaborators → a service or action class.
  • Simple read-and-render → the controller, directly.

If a service grows several unrelated methods, consider single-purpose action classes instead, such as UploadArticleImage with one handle() method. Same idea, smaller units.

The practical test

Look at any controller method longer than fifteen lines and ask: if I had to do this from an Artisan command tomorrow, what would I copy? Whatever you would copy is the service waiting to be extracted. Move it, inject it, and the controller goes back to being about HTTP.

Keep reading