The repository pattern in Laravel, when it’s worth it and when it’s just extra folders
"Not a rule to follow on every project, a tool to reach for on the right one"
Eloquent is genuinely good, expressive enough that you can write a working query in one line and move on with your day. Which is exactly why the repository pattern question comes up so often on our projects: if Eloquent already reads this cleanly inside a controller, why bother wrapping it in another layer?
We’ve built Laravel apps both ways, with a repository layer and without one, and we’ve landed on an answer that’s less “always do this” and more “here’s what it’s actually solving, so you can tell when you need it.” That’s what this post is.
A few terms first, in case you need them
Skip ahead if interfaces and dependency injection are already familiar ground.
The repository pattern puts a class between your controllers and your Eloquent models, whose only job is fetching and storing data. The controller asks the repository for what it needs and never talks to Eloquent directly.
An interface, in this context, is a contract, a list of methods a class promises to implement, without saying how. UserRepositoryInterface says “something that implements me has a find() method,” it doesn’t say whether that something hits MySQL, Redis, or a third-party API.
Dependency injection means a class receives what it needs through its constructor instead of reaching out and creating it itself. A controller that takes UserRepositoryInterface $repository as a constructor argument doesn’t know or care which concrete class Laravel hands it.
Service container binding is how Laravel decides which concrete class to hand over when something asks for an interface. You bind the interface to a real implementation once, usually in a service provider, and every constructor injection after that just works.
A service, distinct from a repository, holds actual business logic, the workflow, not just the data access. This distinction trips people up more than anything else in this pattern; more on that below.
What it’s actually solving
The problem repositories solve isn’t really about code organization for its own sake; it’s about what happens when the same query needs to change, and it’s been copy-pasted into five different controllers.
Picture something like this sitting in a controller:
public function index()
{
$users = User::where('status', 1)
->orderBy('created_at', 'desc')
->paginate(10);
return view('users.index', compact('users'));
}
Fine on its own. But User::where('status', 1)->latest()->get() has a way of showing up in more than one place once an app grows past a handful of controllers, and the moment the business rule changes- say active users now also need a verified email- you’re not making one change; you’re hunting down every place that query got duplicated and hoping you found all of them. Miss one and you’ve got a bug that only shows up in production, in the one screen nobody thought to check.
A repository turns that into a single method that everything else calls:
public function activeUsers()
{
return User::where('status', 1)
->where('email_verified_at', '!=', null)
->latest()
->get();
}
Change it once, in one place, and every controller calling $this->userRepository->activeUsers() picks up the new behavior automatically.
Setting one up: the actual steps
Start with the interface, the contract every implementation has to honor:
interface UserRepositoryInterface
{
public function all();
public function find($id);
public function create(array $data);
public function update($id, array $data);
public function delete($id);
}
Then the class that actually implements it:
class UserRepository implements UserRepositoryInterface
{
public function all()
{
return User::all();
}
public function find($id)
{
return User::findOrFail($id);
}
public function create(array $data)
{
return User::create($data);
}
}
Bind the interface to the implementation, usually in a service provider, so Laravel knows what to hand over whenever something asks for the interface:
$this->app->bind(
UserRepositoryInterface::class,
UserRepository::class
);
And then inject it wherever it’s needed:
public function __construct(
UserRepositoryInterface $repository
)
{
$this->repository = $repository;
}
Once that’s wired up, the controller genuinely doesn’t know or care whether find() is hitting MySQL, reading from a cache, or calling out to some other service entirely. That’s kind of the whole point.
The caching example is where it actually clicks
Testing is the usual example people reach for, and it’s a real benefit, but the one that tends to land better in practice is caching. Say you decide to cache user lookups. Without a repository, you’re editing every place User::findOrFail() gets called. With one, you change exactly one method:
public function find($id)
{
return Cache::remember(
"user_$id",
600,
fn () => User::findOrFail($id)
);
}
Every controller calling $this->userRepository->find($id) is now cached, and not one of them had to change. That’s the actual payoff, not abstraction for its own sake, a real architectural decision that used to touch a dozen files now touches one.
Where we see people actually get this wrong
The mistake we run into most isn’t skipping repositories; it’s blurring the line between a repository and a service, and it’s an easy line to lose track of.
A repository fetches and stores data, full stop. A service orchestrates a workflow, and that workflow might call several repositories, send an email, fire an event, update inventory, whatever the actual business process requires.
// Repository: data access
$productRepository->find($id);
// Service: the actual workflow
$orderService->placeOrder($request);
Once you start writing business logic inside a repository, validating input there, injecting the Request object, returning an HTTP response, you’ve turned it into something else wearing a repository’s name. It still compiles; it just stops meaning anything, and six months later nobody on the team can tell you where a given piece of logic is supposed to live.
When we’d actually reach for this, and when we wouldn’t
This isn’t a pattern we apply automatically on every project, and we don’t think it should be.
It earns its place on medium to large applications, especially with more than one developer touching the same models, where a query genuinely gets reused across several controllers, where automated tests are actually part of the plan, or where swapping the underlying data source is a real possibility down the line rather than a hypothetical.
It’s usually not worth it on a small CRUD app with one or two developers, where a query only ever gets used in exactly one place, or where Eloquent’s own expressiveness already gets you where you need to go without adding a layer nobody’s going to benefit from. We’ve seen teams add this structure to a small project purely because it’s “best practice,” and all it bought them was more files to navigate for the same functionality.
Where we landed
The repository pattern was never really about moving queries into a different folder for the sake of tidiness. It’s about drawing a real line between business logic and data access, so that a query that needs to change only needs to change once, and so a controller can stay focused on handling a request instead of also knowing the shape of your database.
It adds a bit of structure up front, and on the right kind of project that structure pays for itself many times over. On the wrong kind of project it’s just ceremony. Knowing which situation you’re actually in is most of the skill here; the pattern itself is the easy part.




