Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Repository Pattern Explained with Real Code

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.

Laravel Repository Pattern LayersControllerHTTP entryServiceBusiness rulesRepositoryInterface contractEloquent RepoConcrete implMySQLPersistenceDependency injection resolves interface to EloquentRepository at runtimeTests bind a fake in-memory repository instead
Laravel repository pattern explained with real code: application layers and where the interface sits between services and Eloquent

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 methodsfindPublishedForLocale(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:

  1. The same non-trivial query appears in three or more places (API, web, queued job, export).
  2. You need to swap persistence—for example, MySQL today and a read replica or external search index tomorrow.
  3. Unit tests must run fast without a database, especially in CI pipelines where coverage gates pressure you to test business rules in isolation.
  4. Multiple developers touch the same models and query drift causes production bugs.
  5. 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.

Repository Request FlowHTTP RequestControllerServiceRepositoryInterface methodEloquent QueryBuilder chainDatabaseMySQL 9.7Model returned to controller
Request lifecycle when Laravel repository pattern is wired through services and Eloquent

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?”

CriteriaEloquent directlyRepository pattern
Simple CRUD adminFast, idiomatic, less boilerplateOver-engineering
Shared complex queriesDuplication risk across layersSingle source of truth
Unit testing business rulesRequires DB or heavy mockingSwap interface with fake
Multiple persistence backendsTight coupling to MySQLInterface hides driver
Team size / codebase ageFine for solo or greenfield MVPsPays off as modules grow
Performance tuningScopes and with() eager loads sufficeCentral 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.

Use a Repository?Query used 3+ places?YesNoAdd repositoryKeep EloquentNeed test doubles?Simple CRUD OKRepository winsYesMaybe laterNo
Decision tree: when the Laravel repository pattern adds value over direct Eloquent access

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.

Before vs After RepositoryBeforeAfterController AController BJob ExportDuplicateEloquentQueriesController AController BJob ExportSingleRepositoryOne query fixCentralized queries reduce drift across API, web, and background jobs
Before and after: Laravel repository pattern explained with real code consolidating duplicate Eloquent access

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

It wraps data access behind an interface so controllers and services depend on contracts, not Eloquent directly. You bind the interface in the container and implement it with an Eloquent-backed class.

Reach for it when at least two conditions apply: the same non-trivial query appears in three or more places such as API, web, queued jobs, or exports; you may swap persistence later; unit tests must run without a database; multiple developers cause query drift; or an API needs filtering logic out of controllers. On legal-tech portals and booking systems I have maintained, a shared method like findPendingByOffice prevented duplicate Eloquent chains from diverging between a booking screen and a nightly report job.

Define a focused interface under app/Repositories/Contracts with intent-revealing methods such as findBySlug and paginatePublished. Implement it in app/Repositories/Eloquent using constructor-injected Eloquent models and newQuery() instead of static calls. Register the binding in RepositoryServiceProvider, add that provider to bootstrap/providers.php on Laravel 11+, inject the interface into a service or controller, and mock the interface in Pest or PHPUnit tests. The article walks through a complete Article example on Laravel 13 with PHP 8.3+.

Eloquent is an active record ORM; repositories add an explicit boundary on top, not a replacement. Simple CRUD admin panels and greenfield MVPs are fine with Eloquent directly. Shared complex queries, testable business rules, multiple persistence backends, and growing teams benefit from repositories. The article’s verdict: use Eloquent openly inside repositories, not instead of them. Repositories should embrace the query builder, scopes, and with() eager loads while keeping query composition in one place.

Manual binding works for a dozen interfaces: in RepositoryServiceProvider register(), call $this->app->bind(ArticleRepositoryInterface::class, EloquentArticleRepository::class). Use bind for stateless repositories; reserve singleton for cases where you cache internal state, which is rare. Register RepositoryServiceProvider in bootstrap/providers.php. For larger codebases, auto-bind by scanning app/Repositories/Contracts for Interface.php files and mapping OrderRepositoryInterface to EloquentOrderRepository by naming convention.

Thin wrappers that mirror generic find, all, create, update, delete with no domain vocabulary add ceremony without value—enrich the API or stay with Eloquent scopes instead. Never return query Builder instances to callers; return models, collections, paginators, or DTOs so controllers cannot chain where() and break the abstraction. Repositories should own persistence and query composition, not business rules like authorization or pricing logic—that belongs in services or policies. The article flags UserRepository with only find($id) and all() as the most common client review mistake.

No. Five-page marketing sites, simple Filament CRUD admins, solo teams, and one-liner queries do not need the extra interfaces, bindings, and files.

Returning Eloquent models from repositories is acceptable in most Laravel applications. Return DTOs or arrays at API boundaries or when implementing CQRS read models where response shaping should stay separate from persistence. The repository hides how data is fetched; the transport layer decides what leaves the application. For JSON APIs, compare repository output to API resources during test debugging rather than leaking raw model internals to clients.

Prefer injecting repositories into services when business rules exist. Controllers stay thin; services coordinate repositories, events, and transactions. That mirrors the separation between transport layer and application layer described in RESTful API design. Controllers can receive repositories directly for simple read-only pages, but once you have abort logic, validation coordination, or multi-step workflows, an ArticleService wrapping ArticleRepositoryInterface keeps persistence decoupled from HTTP concerns.

Bind or mock the repository interface in unit tests. The article’s Pest example creates a Mockery mock of ArticleRepositoryInterface, stubs findBySlug to return null, injects it into ArticleService, and asserts showBySlug throws NotFoundHttpException. Because the service depends on the contract, not EloquentArticleRepository, PHPUnit and Pest run fast in CI without database fixtures. This is one of the main reasons to adopt repositories when coverage gates pressure you to test business rules in isolation.

In RepositoryServiceProvider register(), glob app/Repositories/Contracts/*Interface.php, derive the interface class name from the filename, and map it to an Eloquent implementation by replacing Contracts with Eloquent and dropping the Interface suffix. OrderRepositoryInterface becomes EloquentOrderRepository. Deviations from that naming break auto-binding silently, so add a smoke test that resolves each interface from the container. Manual bindings remain appropriate when you use decorators or contextual overrides.

Implement a decorator class such as CachedArticleRepository that implements the same interface and wraps an inner repository. Delegate uncached methods to the inner instance; wrap read methods with Cache::remember and a TTL such as one hour. Bind the decorator in the provider so the cached class wraps the Eloquent implementation. Redis or file cache both work. Invalidate cache keys inside update and delete on the inner repository or via model observers so stale reads do not survive writes.

Use $this->app->when(TenantReportService::class)->needs(ArticleRepositoryInterface::class)->give(EloquentTenantArticleRepository::class) when one interface needs different implementations depending on the consumer. A report service for one tenant module may require tenant-scoped queries while a public API service uses the default Eloquent repository. Contextual binding suits modular monoliths where a single global bind would return the wrong persistence scope.

Keep interfaces focused on use cases, not generic CRUD unless every consumer truly needs it. Methods should read like application language: paginatePublished, findBySlug, and searchPublished beat anonymous query fragments or mirror-CRUD names like getWhereStatusEqualsOne. A booking module might expose findAvailableSlot(int $guideId, Carbon $date) instead of leaking whereHas chains into a Livewire component. Complex joins, scopes, and whereHas logic live inside the implementation, not at the call site.

Skip it 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 on client code reviews. Eloquent in controllers or Filament resources is fine until complexity crosses a threshold you can name: duplicated non-trivial queries, persistence swaps, or fast database-free unit tests. The pattern is a deliberate trade-off, not a default badge of clean architecture.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: