Eloquent is genuinely good, expressive enough that you 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: if Eloquent already reads this cleanly inside a controller, why wrap it in another layer?
We've built Laravel apps both ways, with a repository layer and without. Here's what it's actually solving, so you can tell when you need it.
A few terms first
The repository pattern, a class between your controllers and Eloquent models, whose only job is fetching and storing data. The controller asks the repository, never talks to Eloquent directly.
An interface, a contract, a list of methods a class promises to implement without saying how. UserRepositoryInterface says "something implementing me has a find() method," not whether that something hits MySQL, Redis, or an API.
Dependency injection, a class receives what it needs through its constructor instead of creating it itself. A controller taking UserRepositoryInterface $repository doesn't know or care which concrete class Laravel hands it.
Service container binding, how Laravel decides which concrete class to hand over when something asks for an interface. Bind once, usually in a service provider; every subsequent injection works.
A service, distinct from a repository, holds actual business logic, the workflow, not just data access. This distinction trips people up more than anything else here.
What it's actually solving
Not 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 controllers.
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() shows up in more than one place once an app grows, and when the business rule changes- active users now also need a verified email, say, you're hunting down every place that query got duplicated. Miss one, you've got a bug that only shows up in production.
A repository turns that into one method everything else calls:
public function activeUsers()
{
return User::where('status', 1)
->where('email_verified_at', '!=', null)
->latest()
->get();
}
Change it once, and every controller calling $this->userRepository->activeUsers() picks up the new behavior.
Setting one up
Interface first, the contract every implementation honors:
interface UserRepositoryInterface
{
public function all();
public function find($id);
public function create(array $data);
public function update($id, array $data);
public function delete($id);
}
The class that 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:
$this->app->bind(
UserRepositoryInterface::class,
UserRepository::class
);
Inject it wherever needed:
public function __construct(
UserRepositoryInterface $repository
)
{
$this->repository = $repository;
}
The controller genuinely doesn't know or care whether find() hits MySQL, reads from a cache, or calls another service. That's the point.
The caching example is where it actually clicks
Testing is the usual pitch, and it's real, but caching lands better in practice. Decide to cache user lookups, and 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; none of them changed. A decision that used to touch a dozen files now touches one.
Where we see this actually go wrong
Not skipping repositories, blurring the line between a repository and a service.
A repository fetches and stores data, full stop. A service orchestrates a workflow, calling several repositories, sending emails, firing events, whatever the process needs.
// Repository: data access
$productRepository->find($id);
// Service: the actual workflow
$orderService->placeOrder($request);
Write business logic inside a repository, validate input there, inject Request, return an HTTP response, and it's turned into something else wearing a repository's name. It still compiles; it just stops meaning anything.
When to actually reach for this
Not automatic on every project, and shouldn't be.
Earns its place on medium to large applications, more than one developer touching the same models, a query genuinely reused across controllers, automated tests actually part of the plan, or a real possibility of swapping the data source down the line.
Usually not worth it on a small CRUD app with one or two developers, a query used in exactly one place, or where Eloquent's own expressiveness already gets you there. Adding this structure to a small project purely because it's "best practice" just adds files to navigate for the same functionality.
Takeaways
- Repositories earn their place when a query is reused, a team is more than one person, or swapping data sources is a real possibility, not by default on every project.
- The caching use case makes the value obvious faster than the testing pitch does; a change that used to touch a dozen controllers touches one method instead.
- Keep repositories and services separate. Repository fetches and stores. Service orchestrates. Blur that line and both stop meaning anything.
- Interfaces plus dependency injection are what make a repository swappable; without the interface you've just moved the query, not decoupled anything.
Full writeup with more of the reasoning is on our Substack: https://ucodesoft.substack.com/p/the-repository-pattern-in-laravel



Top comments (0)