
September 06, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
You want the Laravel repository pattern explained with real code—not a textbook diagram and a vague promise that it “decouples your data layer.” In practice, most teams either skip repositories entirely and call Eloquent from controllers, or they add interfaces everywhere and end up with thin wrappers that duplicate the ORM without buying anything. Both extremes fail for the same reason: the pattern is a trade-off, not a badge of clean architecture. This guide walks through a production-style implementation on Laravel 13 with PHP 8.3+, shows exactly where repositories earn their keep, and gives you copy-pasteable files you can adapt on your next project. If you are tightening application structure after reading our Laravel best practices for clean, efficient code, repositories are one layer worth evaluating deliberately—not by default.
What is the Laravel repository pattern?
The repository pattern introduces a dedicated object—typically an interface plus a concrete class—that owns every read and write operation for one aggregate or entity type. Your controller asks OrderRepositoryInterface for an order; it does not call Order::where(...) inline. That indirection buys you a stable contract at the application boundary even when the underlying persistence mechanism shifts.
In Laravel, repositories sit between your domain/application layer and Eloquent (or another driver). They are not a replacement for Eloquent; they are a façade your business code talks to. A booking module on a production Laravel application might expose methods like findAvailableSlot(int $guideId, Carbon $date) instead of leaking query builder chains into a Livewire component.
The pattern originates from domain-driven design literature, but Laravel teams usually adopt a pragmatic subset: interface, Eloquent implementation, container binding, and optional caching decorator. You do not need a full DDD bounded context to benefit—though on larger codebases, repositories pair well with the ideas in our modern Laravel architecture guide.
Core responsibilities of a repository
- Encapsulate queries — complex
whereHas, scopes, and joins live in one place instead of scattered across controllers. - Expose intent-revealing methods —
findPublishedForLocale(string $locale)beats anonymous query fragments. - Hide persistence details — callers should not know whether you use Eloquent, raw SQL, or Redis for a read model.
- Support testing — bind a fake repository in PHPUnit/Pest without hitting the database.
When should you use a repository pattern in Laravel?
Not every Laravel app needs repositories. On a five-page marketing site or a simple CRUD admin built with Filament, Eloquent in controllers or resources is fine. The cost of interfaces, bindings, and extra files only pays off when complexity crosses a threshold you can actually name.
I reach for repositories when at least two of these conditions are true:
- The same non-trivial query appears in three or more places (API, web, queued job, export).
- You need to swap persistence—for example, MySQL today and a read replica or external search index tomorrow.
- Unit tests must run fast without a database, especially in CI pipelines where coverage gates pressure you to test business rules in isolation.
- Multiple developers touch the same models and query drift causes production bugs.
- You are building an API consumed by mobile clients where response shaping and filtering logic should not live in controllers.
On legal-tech portals and booking systems I have maintained—think appointment slots, document status filters, role-scoped case lists—the repository layer prevented duplicate Eloquent chains from diverging. A notary booking screen and a nightly report job both call findPendingByOffice(int $officeId); when the business rule changes, you edit one method.
Skip repositories when your app is small, your team is solo, and queries are one-liners. Adding UserRepository with find($id) and all() is ceremony without value. That is the most common mistake I see on client code reviews.
How do you implement the repository pattern in Laravel?
Below is a complete, minimal implementation for an Article model on Laravel 13. File paths follow Laravel conventions; adjust namespaces to match your app.
Step 1: Define the interface
// app/Repositories/Contracts/ArticleRepositoryInterface.php
<?php
namespace App\Repositories\Contracts;
use App\Models\Article;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
interface ArticleRepositoryInterface
{
public function findBySlug(string $slug): ?Article;
public function paginatePublished(int $perPage = 15): LengthAwarePaginator;
public function create(array $attributes): Article;
public function update(Article $article, array $attributes): Article;
public function delete(Article $article): bool;
public function searchPublished(string $term, int $limit = 20): Collection;
}
Keep the interface focused on use cases, not generic CRUD unless every consumer truly needs it. Methods should read like application language: paginatePublished, not getWhereStatusEqualsOne.
Step 2: Implement with Eloquent
// app/Repositories/Eloquent/EloquentArticleRepository.php
<?php
namespace App\Repositories\Eloquent;
use App\Models\Article;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class EloquentArticleRepository implements ArticleRepositoryInterface
{
public function __construct(
private readonly Article $model
) {}
public function findBySlug(string $slug): ?Article
{
return $this->model
->newQuery()
->where('slug', $slug)
->where('status', 'published')
->first();
}
public function paginatePublished(int $perPage = 15): LengthAwarePaginator
{
return $this->model
->newQuery()
->where('status', 'published')
->orderByDesc('published_at')
->paginate($perPage);
}
public function create(array $attributes): Article
{
return $this->model->newQuery()->create($attributes);
}
public function update(Article $article, array $attributes): Article
{
$article->fill($attributes)->save();
return $article->refresh();
}
public function delete(Article $article): bool
{
return (bool) $article->delete();
}
public function searchPublished(string $term, int $limit = 20): Collection
{
return $this->model
->newQuery()
->where('status', 'published')
->where(function ($query) use ($term) {
$query->where('title', 'like', "%{$term}%")
->orWhere('body', 'like', "%{$term}%");
})
->limit($limit)
->get();
}
}
Inject the model via constructor so the repository stays testable and you avoid static Article:: calls that are harder to mock. For heavy read paths, this is where you would integrate Meilisearch or cache—see our Laravel Meilisearch integration guide for a search-backed variant.
Step 3: Bind in a service provider
// app/Providers/RepositoryServiceProvider.php
<?php
namespace App\Providers;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use App\Repositories\Eloquent\EloquentArticleRepository;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
ArticleRepositoryInterface::class,
EloquentArticleRepository::class
);
}
}
Register the provider in bootstrap/providers.php on Laravel 11+. The Laravel service container documentation covers binding lifetimes: use bind for stateless repos, singleton only when you cache internal state—which is rare and usually a smell.
Step 4: Inject into a service or controller
// app/Services/ArticleService.php
<?php
namespace App\Services;
use App\Models\Article;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ArticleService
{
public function __construct(
private readonly ArticleRepositoryInterface $articles
) {}
public function listPublished(int $perPage = 15): LengthAwarePaginator
{
return $this->articles->paginatePublished($perPage);
}
public function showBySlug(string $slug): Article
{
$article = $this->articles->findBySlug($slug);
abort_if($article === null, 404);
return $article;
}
}
Prefer injecting repositories into services, not controllers, when business rules exist. Controllers stay thin; services coordinate repositories, events, and transactions. That separation mirrors what we describe in building RESTful APIs with Laravel—transport layer vs application layer.
Step 5: Test with a fake repository
// tests/Unit/ArticleServiceTest.php
<?php
use App\Models\Article;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use App\Services\ArticleService;
use Illuminate\Support\Collection;
it('returns 404 when slug is missing', function () {
$repo = Mockery::mock(ArticleRepositoryInterface::class);
$repo->shouldReceive('findBySlug')
->with('missing-slug')
->andReturn(null);
$service = new ArticleService($repo);
$service->showBySlug('missing-slug');
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
For JSON API payloads, paste expected structures through our JSON formatter tool during test debugging—it saves time when comparing repository output to API resources.
Repository pattern vs Eloquent: which should you use?
Eloquent is an active record ORM: models carry both data and persistence behaviour. Repositories add an explicit boundary on top. The question is not “Eloquent or repository?” but “where does this query belong?”
| Criteria | Eloquent directly | Repository pattern |
|---|---|---|
| Simple CRUD admin | Fast, idiomatic, less boilerplate | Over-engineering |
| Shared complex queries | Duplication risk across layers | Single source of truth |
| Unit testing business rules | Requires DB or heavy mocking | Swap interface with fake |
| Multiple persistence backends | Tight coupling to MySQL | Interface hides driver |
| Team size / codebase age | Fine for solo or greenfield MVPs | Pays off as modules grow |
| Performance tuning | Scopes and with() eager loads suffice | Central place for query plans, caching |
Verdict: use Eloquent openly in repositories, not instead of them. Repositories should embrace Eloquent’s query builder and relationships—see advanced Eloquent query patterns for N+1 prevention and chunking strategies that belong inside repository methods. Returning Eloquent models from repositories is acceptable in most Laravel apps; returning DTOs or arrays makes sense at API boundaries or when implementing CQRS read models, as covered in our CQRS in Laravel guide.
How do you bind repositories in the Laravel service container?
Manual binding per repository works for a dozen interfaces. Beyond that, convention-based registration reduces provider bloat.
Auto-bind by convention
// app/Providers/RepositoryServiceProvider.php
public function register(): void
{
$contractsPath = app_path('Repositories/Contracts');
foreach (glob($contractsPath . '/*Interface.php') as $file) {
$interface = 'App\\Repositories\\Contracts\\' . basename($file, '.php');
$implementation = str_replace(
['Contracts\\', 'Interface'],
['Eloquent\\', ''],
$interface
);
if (class_exists($implementation)) {
$this->app->bind($interface, $implementation);
}
}
}
Name consistently: OrderRepositoryInterface maps to EloquentOrderRepository. Deviations break auto-binding silently—add a smoke test that resolves each interface.
Decorator for caching
// app/Repositories/Decorators/CachedArticleRepository.php
class CachedArticleRepository implements ArticleRepositoryInterface
{
public function __construct(
private readonly ArticleRepositoryInterface $inner
) {}
public function findBySlug(string $slug): ?Article
{
return Cache::remember(
"article.slug.{$slug}",
now()->addHour(),
fn () => $this->inner->findBySlug($slug)
);
}
// delegate other methods to $this->inner
}
Bind the decorator in the provider: outer cached class wraps the Eloquent implementation. Redis 8.10 or file cache both work; invalidate cache keys inside update and delete methods on the inner repo or via model observers.
Contextual binding for multi-tenant apps
$this->app->when(TenantReportService::class)
->needs(ArticleRepositoryInterface::class)
->give(EloquentTenantArticleRepository::class);
Use contextual binding when one interface needs different implementations depending on the consumer—common in modular monoliths described in our modular monolith guide.
What are common Laravel repository pattern mistakes?
Repositories fail when they become passive CRUD pass-throughs or when they leak the ORM upward. These are the patterns I fix most often during refactors.
Thin wrappers that add no vocabulary
If your interface mirrors find, all, create, update, delete with no domain-specific methods, delete the layer. You are paying for indirection without encapsulation. Either enrich the API with intent-revealing methods or stay with Eloquent and extract query scopes.
Returning query builders to callers
Never expose Builder instances from a repository. The moment a controller chains ->where() on your return value, the abstraction is broken. Return models, collections, paginators, or DTOs—finished results.
Business logic inside repositories
Repositories own persistence and query composition. Rules like “only managers may approve refunds” belong in services or action classes validated by policies. Mixing authorisation into repositories makes them hard to reuse across CLI, HTTP, and queued contexts.
Ignoring transactions at the service layer
Repositories should not own cross-aggregate transactions. Wrap multi-step operations in a service method with DB::transaction(). On PostgreSQL 18 or MySQL 9.7, keep transactional boundaries at the use-case level—details in our PostgreSQL for Laravel developers guide.
Package overload
Composer packages like prettus/l5-repository ship generic base classes and criteria objects. They can accelerate bootstrapping but often fight Laravel 13 conventions and typed method signatures. I prefer explicit interfaces per aggregate—easier for the next developer to read and closer to what we document in creating custom Laravel packages when extracting shared repo code.
On the Nepal Gift Card Laravel platform and similar eCommerce builds, repositories around orders and gift-card inventory kept payment webhook handlers and admin dashboards aligned. When stock deduction rules changed, one repository method updated both paths—a concrete win over scattered queries.
Key Takeaways
- Define repository interfaces around use-case methods, not generic CRUD, so the layer encodes application language.
- Bind interfaces to Eloquent implementations in a dedicated service provider; use decorators for caching without polluting core query logic.
- Inject repositories into services, keep controllers thin, and return finished results—never query builders.
- Skip repositories on small CRUD apps; adopt them when queries duplicate across HTTP, jobs, and exports or when tests need fast fakes.
- Repositories wrap Eloquent—they do not replace it. Put advanced query tuning and eager-load strategy inside repository methods.
- Avoid passive wrapper classes and third-party base repos that obscure your domain; explicit code beats magic conventions.
People Also Ask
Does Laravel have a built-in repository pattern?
No. Laravel ships Eloquent and a powerful service container, but repositories are an application-level pattern you implement yourself. The framework’s official Eloquent documentation assumes direct model usage; repositories are optional architecture you add when your complexity warrants the extra files and bindings.
Should repositories return models or DTOs?
Returning Eloquent models is standard in monolithic Laravel apps and keeps relationships available for views and API resources. Return DTOs or plain arrays when you need strict API contracts, read-only projections, or CQRS separation. Mixing both in one interface gets messy—pick a convention per bounded module.
Can you use repositories with Laravel API resources?
Yes. Controllers fetch models or collections from the repository, then pass them to ArticleResource::collection() or similar. The repository handles filtering and pagination; resources handle serialization. That split keeps Laravel API best practices clean—persistence logic never leaks into JSON shape definitions.
Is the repository pattern still relevant in 2026?
Yes, for medium and large Laravel codebases where query duplication and test isolation hurt velocity. It is less relevant for greenfield MVPs, Livewire-heavy UIs with co-located data fetching, or apps where enterprise Laravel development standards mandate stricter layering. The pattern is a tool, not a Laravel requirement—use it where the maintenance cost of scattered Eloquent justifies the abstraction.
Ship cleaner data access on your next Laravel project
Getting the Laravel repository pattern explained with real code is the easy part; the hard part is knowing when one interface saves you weeks of query drift and when it just adds files. Start with one hot spot—a duplicated booking query, a report that diverged from the API, an export job copying controller logic—and extract a repository there. If that refactor feels obviously better, expand incrementally. If it feels hollow, stay with Eloquent scopes until the pain is real.
Need help structuring a growing Laravel app, refactoring tangled queries, or preparing for a Laravel 12/13 upgrade? See our custom software development services, review related work on Adventure Third Pole Trek, or contact us to talk through your architecture before the next feature sprint adds more duplication.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

