Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
design patternslaravel architectureservice containercode review

Laravel Best Practices: Design Patterns That Scale

Controller fat, service thin is backwards. A threshold-based rule for choosing Laravel design patterns, plus a five-command audit of any pattern.

Oct 20, 2026 · 9 min read · Updated Oct 28, 2026

The advice that circulates as laravel best practices design patterns is usually justified as testability and reuse, with no threshold attached. That matters because the same pattern costs money the moment it needs an interface, a binding, and a test double. Use these patterns when the behavior is called from more than one entry point and carries branching or side effects; keep plain controller methods when the operation is one or two queries with no branching; avoid a pattern you cannot exercise through the public interface with two requests in under ten minutes, because that is how pattern sprawl starts.

A typical mid-sized Laravel app in this category has a service container with thirty-odd bindings, most of them resolving a class with no constructor arguments and no alternate implementation. This article gives you a threshold for deciding which of those earn their keep, and a five-command audit you can run against any existing pattern before you keep it or delete it.

Recommendation in one line

Adopt a pattern when it is multiply called, carries real branching or side effects, and is verifiable through one public method. Skip it when it is a single-query pass-through, when the only implementer is the concrete class, or when its output cannot be reproduced headlessly inside a fast test suite.

Which Laravel design patterns pay for themselves?

Service classes, actions, repositories, policies, events, and job middleware all pay for themselves only when the wrapped behavior is called from multiple entry points, carries branching or side effects, and can be verified through one public method; otherwise a controller method plus a Form Request is cheaper to read and cheaper to change.

Three signals decide it:

  1. The behavior has more than one caller (web controller, Artisan command, queued job, or a second route). If there is exactly one caller, a class is indirection with no payoff.
  2. The behavior branches. Two or more decision points plus a side effect is where a method becomes hard to read and a named class starts to help.
  3. The behavior is verifiable in isolation. If you cannot assert on it with one method call and no HTTP request, you are not testing the pattern, you are testing the framework around it.

When two or three signals are true, the pattern earns its keep. One or zero, and you should leave the code where it is.

PatternCost per instanceUse whenSkip when
Service class1 file, 1 test doubleMultiple callers with branching and side effectsSingle-query pass-through from one controller
Action class1 file, small interfaceOne operation, one public method, called from jobs and HTTPThe operation is part of a larger transaction you own elsewhere
Repository1 interface, 1 binding, 1 fakeYou swap drivers per environment under testYou use Eloquent as the only implementation and swap nothing
Policy1 file, 1 registrationAuthorization rules are shared by controllers, jobs, and viewsThe rule is a single ownership check on one route
Domain event plus queued listener1 event, 1 listener, 1 queue hopThe side effect is slow, retryable, and not part of the responseThe caller needs the side effect's result before it can respond

The container binding cost is the one most often underestimated. Every interface you bind to a concrete class is one line in a service provider, one line in a fake for tests, and one more thing a new engineer has to trace before they understand what runs. That is a real cost, paid every time someone reads the code. For the mechanics of binding and resolution, see our walkthrough of Laravel service container bindings and contextual injection.

The Laravel documentation describes container binding and resolution directly; see Laravel service container documentation for the current binding API.

The interface-with-one-implementation trap

An interface whose only implementation is the concrete class next to it adds a binding, a test fake, and a layer of indirection while changing nothing about runtime behavior. It is the most common pattern mistake in Laravel codebases because it looks like good design and audits like dead weight.

You see it as a service provider full of lines like this, with no second class implementing the contract anywhere in the codebase or in any test:

// Laravel 12.x, app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(
        \App\Contracts\InvoiceTotalCalculator::class,
        \App\Services\InvoiceTotalCalculator::class,
    );
}

The fix is not to delete the class. It is to delete the contract and bind nothing, letting the container resolve the concrete class by autowiring. If you later need a second implementation, the interface takes one commit to add back, and until then you have three fewer things to trace.

This does not apply to contracts that Laravel itself resolves or that you swap per environment, such as a payment gateway fake in tests. The audit question is whether a second implementation exists today, not whether one might exist someday.

Audit a pattern in five commands

These five commands tell you whether a pattern in your codebase is load-bearing or decoration. Run them against one suspicious class before deciding whether to keep it, refactor it, or delete it, and record the output rather than trusting your memory of the code.

1. Count the callers

A pattern called from one place has no reuse payoff. Anything with a single caller is a candidate for inlining:

grep -rn "InvoiceTotalCalculator" app routes tests | wc -l

If the only hits are the class definition, the test file, and one controller, the pattern is not yet earning its interface.

2. Count the implementers

A contract with one implementer is usually not a contract. Count concrete classes that declare the interface:

grep -rn "implements InvoiceTotalCalculatorInterface" app

One result means the interface is documentation, not abstraction. Two or more means it is doing real work.

3. Trace the bindings

Bindings are invisible at the call site, so they get stale. List every container binding in the app in one pass:

grep -rn "\$this->app->bind\|\$this->app->singleton" app/Providers

Each line is a thing a new engineer must read before they understand what runs. If you cannot justify a binding in one sentence, delete it and let autowiring handle the class.

4. Check for hidden query cost

Patterns wrap queries, so a pattern audit is also an eloquent n+1 audit. Enable the query log in a test or a local request and print the count and the repeated shapes:

// Laravel 12.x, inside a test or tinker session
DB::enableQueryLog();

// exercise the endpoint or call the service
app(InvoiceTotalCalculator::class)->calculate($invoice);

collect(DB::getQueryLog())
    ->groupBy('query')
    ->map->count()
    ->sortDesc()
    ->take(5);

A service class that looks clean can still fire the same query fifty times. The query log catches that; reading the service constructor does not. The same N+1 shapes and their fixes are covered in our guide to fixing Eloquent N+1 problems.

5. Re-verify through the public interface

Delete the class's unit test and rerun only its feature or integration test. If the pattern is load-bearing, the integration test still passes and still has assertions on behavior. If it fails because the only thing that asserted on it was its own double, the pattern was decoration.

Where these patterns hide query problems

A service class makes eloquent n+1 more likely, not less, because the class hides which queries it fires behind a method name. The constructor tells you nothing about the query count; the query log does, and it is the only reliable check.

This is the failure mode with numbered steps and a symptom you can reproduce in a local test:

  1. Write a service method that loops over a collection and calls a relationship accessor inside the loop.
  2. Wrap the service call in a headless framework test that hits the real database.
  3. Log the query count from the test, and assert on the total number of queries as well as the response.
  4. Swap the lazy relationship access for an eager load and rerun the same test.

The observable symptom is a query count that grows with the number of rows in the collection, not with the number of distinct models in the request. A test that asserts on the response will still pass, which is why the count has to be asserted separately. The same reasoning applies to loops inside controllers.

None of this argues against using a service class. It argues for moving the query-count assertion into the test suite, where it catches the regression before the pattern does.

Skills matter more than pattern names

The laravel best practices skills that transfer between codebases are reading a service provider top to bottom, sampling the query log, and asserting query counts in tests. Knowing the name of a pattern is worth much less than being able to trace what it does at runtime.

That is also why copying a pattern library wholesale is a trap. A pattern that works in one app depends on assumptions about query volume, queue depth, and how often the operation is called. Those assumptions are not in the pattern's name, and they are exactly what changes when you move it.

If you do copy from an example, treat it the way you would treat any new dependency: run the five commands above on your own codebase before you believe it. The pattern-level guidance in Laravel best practices for production PHP apps is a reasonable baseline, but it still does not tell you when a pattern stops paying rent, which is what the signals above are for.

If you are looking for a starting point rather than a survey, treat the Laravel documentation as the reference and read it against your code, not the other way round. The Laravel documentation is the versioned source for anything this article states.

Common questions

Is a service class the same thing as a repository pattern?

No. A service class holds behavior, such as calculating a total or dispatching work. A repository wraps data access behind a contract, with bindings and a fake. The repository is the one worth skipping until you actually swap implementations per environment; the service class is often worth adopting earlier.

When should a controller stay fat in Laravel?

When the endpoint is one or two queries with no branching and a single caller. Splitting it adds a file, a binding, and a test double for no gain. The threshold is the three signals in this article: multiple callers, real branching, and a public interface you can verify in isolation.

How do I tell if a pattern is load-bearing?

Delete its unit test and rerun the feature or integration test that exercises it through the public interface. If the feature test still passes with real assertions, the pattern is load-bearing. If it only passed because the double asserted on it, the pattern is decoration and can go.

The bottom line

Patterns are a way to make branching and side effects testable, not a way to signal that code is serious. Adopt one when it has multiple callers, real branching, and a testable public interface. Skip it when it wraps a single query or owns a contract with one implementer. Verify the result in the query log, because the class that reads cleanest is the one most likely to hide an N+1 behind its method name.

DD

David Dew Mallick

Intermediate Software Engineer

GitHubLinkedInEmail

More posts

  • Oct 28, 2026 · 8 min read

    Database Sharding: When One Postgres Stops Working

    A decision guide for when database sharding pays off, the failure modes that push you to it, and how to shard without breaking SQL joins and transactions.

  • Oct 28, 2026 · 7 min read

    Database Best Practices: Primary Keys That Scale

    Pick integer, UUID, or UUIDv7 primary keys using index size and write-rate thresholds, plus a reproducible test that shows how random UUIDs bloat B-trees.

On this page

  • Which Laravel design patterns pay for themselves?
  • The interface-with-one-implementation trap
  • Audit a pattern in five commands
  • 1. Count the callers
  • 2. Count the implementers
  • 3. Trace the bindings
  • 4. Check for hidden query cost
  • 5. Re-verify through the public interface
  • Where these patterns hide query problems
  • Skills matter more than pattern names
  • Common questions
  • Is a service class the same thing as a repository pattern?
  • When should a controller stay fat in Laravel?
  • How do I tell if a pattern is load-bearing?
  • The bottom line
Back to top

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick

  • Privacy
  • Terms
  • Cookies