Back to blog
Laravel
Intermediate

Laravel Repository Pattern: Best Practices for Clean Architecture

Discover how to decouple your Laravel application from Eloquent by using the repository pattern. This guide covers core concepts, architecture, step-by-step implementation, real-world examples, production code, and FAQs to help you build maintainable, testable applications.

September 24, 202520 min read

Introduction

Modern Laravel applications often grow beyond simple CRUD operations, requiring a clear separation of concerns to stay maintainable as features accumulate. The repository pattern provides a way to abstract data access logic away from controllers and services, letting you work with collections of objects without worrying about the underlying persistence mechanism. By introducing an interface‑based layer between your business logic and Eloquent models, you gain testability, flexibility to swap storage implementations, and a cleaner codebase that follows SOLID principles.

In this article we will explore the repository pattern in depth, tailored specifically for Laravel developers. We will start with the core concepts, move through an architectural overview, and then provide a detailed step‑by‑step guide you can follow in your own projects. Real‑world examples will illustrate how repositories interact with typical use cases such as user management, product catalogs, and order processing. Production‑ready code snippets will demonstrate proper dependency injection, service provider binding, and unit testing strategies. Finally, we will compare the repository approach with direct Eloquent usage, list best practices, highlight common pitfalls, and offer tips for performance, security, deployment, and debugging.

Whether you are building a small SaaS product or a large enterprise system, understanding how to implement the repository pattern will give you the tools to keep your Laravel codebase adaptable and easy to evolve over time.

Table of Contents

Core Concepts

The repository pattern is a structural design pattern that mediates between the domain layer and the data mapping layer using a collection‑like interface for accessing domain objects. In Laravel, the domain layer typically consists of services, use cases, or action classes that contain business logic. The data mapping layer is usually Eloquent ORM, which maps PHP objects to database tables.

Instead of calling Eloquent methods directly inside a service, you define a repository interface that declares the operations you need—such as findById, all, create, update, and delete. An implementation of this interface interacts with Eloquent to fulfill those calls. Controllers and services then depend on the interface, not the concrete Eloquent model, allowing you to substitute a different implementation (e.g., a fake repository for testing) without changing the dependent code.

Key benefits include:

  • Separation of Concerns: Business logic stays unaware of how data is persisted.
  • Testability: You can mock repository interfaces in unit tests, leading to fast, isolated tests.
  • Flexibility: Switching from Eloquent to another ORM or a micro‑service only requires a new repository implementation.
  • Consistency: Centralizing query logic reduces duplication and makes it easier to apply caching or soft‑delete handling globally.

Core components you will encounter:

  1. Repository Interface: A PHP interface that defines the contract for data operations.
  2. Repository Implementation: A concrete class (often extending a base repository) that uses Eloquent to satisfy the interface.
  3. Service Provider Binding: Laravel's service container binds the interface to its implementation, enabling dependency injection.
  4. Repository Base Class (Optional): A abstract class providing common methods like pagination, filtering, or caching wrappers.

Understanding these pieces sets the foundation for a clean architecture where controllers are thin, services contain pure business logic, and repositories handle all data access nuances.

Architecture Overview

A typical Laravel application employing the repository pattern can be visualized as four concentric layers:

  1. Presentation Layer: Routes, controllers, and optional view components that handle HTTP requests and responses.
  2. Application Layer: Service classes, use cases, or action objects that orchestrate business workflows and depend on repository interfaces.
  3. Domain Layer: Entities and value objects representing core business concepts (often plain PHP classes or Eloquent models when used as data carriers).
  4. Infrastructure Layer: Eloquent models, database migrations, and the actual repository implementations that talk to the storage system.

The dependency rule states that inner layers should not know about outer layers. In this model, the Application Layer depends on repository interfaces (which reside in the Infrastructure Layer but are defined as contracts in the Application or Domain layer). The Infrastructure Layer knows about Eloquent, but the inner layers remain oblivious to it.

Data flow example:

  1. An HTTP request hits a route, invoking a controller method.
  2. The controller receives a service instance via constructor injection.
  3. The service calls a method on a repository interface (e.g., $userRepo->findByEmail($email)).
  4. The repository implementation uses Eloquent to query the users table and returns an Eloquent model (or a plain DTO).
  5. The service processes the result and returns data to the controller, which then returns a JSON response or view.

Because the service depends only on the interface, you can replace the Eloquent implementation with a fake repository that returns predefined data during unit tests, or with a remote API repository if you decide to move user storage to an external service later.

Laravel's service container makes this wiring seamless. You bind the interface to the implementation in a service provider, and whenever a class type‑hints the interface, the container injects the bound concrete class. This approach also enables easy swapping based on environment—for example, using an in‑memory array repository for local development and an Eloquent repository for production.

Step‑by‑Step Guide

Follow these steps to add a repository for a typical model, such as Post. We will create an interface, an Eloquent‑based implementation, a service provider binding, and demonstrate usage in a controller.

1. Create the Repository Interface

Place interfaces in app/Repositories/Interfaces to keep them discoverable.

<?php

namespace App\Repositories\Interfaces;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use App\Models\Post;

interface PostRepositoryInterface
{
    public function all(): array;

    public function findById(int $id): ?Post;

    public function findBySlug(string $slug): ?Post;

    public function create(array $data): Post;

    public function update(int $id, array $data): bool;

    public function delete(int $id): bool;

    public function paginate(int $perPage = 15): LengthAwarePaginator;

    public function where(array $filters, int $perPage = 15): LengthAwarePaginator;
}
?>

2. Create the Eloquent Repository Implementation

Implementations belong in app/Repositories/Eloquent.

<?php

namespace App\Repositories\Eloquent;

use App\Repositories\Interfaces\PostRepositoryInterface;
use App\Models\Post;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

class EloquentPostRepository implements PostRepositoryInterface
{
    protected $model;

    public function __construct(Post $model)
    {
        $this->model = $model;
    }

    public function all(): array
    {
        return $this->model->all()->toArray();
    }

    public function findById(int $id): ?Post
    {
        return $this->model->find($id);
    }

    public function findBySlug(string $slug): ?Post
    {
        return $this->model->where('slug', $slug)->first();
    }

    public function create(array $data): Post
    {
        return $this->model->create($data);
    }

    public function update(int $id, array $data): bool
    {
        $record = $this->model->find($id);
        if (!$record) {
            return false;
        }
        return $record->update($data);
    }

    public function delete(int $id): bool
    {
        $record = $this->model->find($id);
        if (!$record) {
            return false;
        }
        return $record->delete();
    }

    public function paginate(int $perPage = 15): LengthAwarePaginator
    {
        return $this->model->paginate($perPage);
    }

    public function where(array $filters, int $perPage = 15): LengthAwarePaginator
    {
        $query = $this->model->newQuery();

        foreach ($filters as $column => $value) {
            if (is_array($value)) {
                // Assume format ['operator'=>..., 'value'=>...]
                $query->where($column, $value['operator'], $value['value']);
            } else {
                $query->where($column, $value);
            }
        }

        return $query->paginate($perPage);
    }
}
?>

3. Register the Binding in a Service Provider

You can add the binding to App\Providers\AppServiceProvider or create a dedicated provider.

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Repositories\Interfaces\PostRepositoryInterface;
use App\Repositories\Eloquent\EloquentPostRepository;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(PostRepositoryInterface::class, EloquentPostRepository::class);
    }

    public function boot()
    {
        // No boot logic needed
    }
}
?>

Don't forget to add the provider to the providers array in config/app.php if you didn't use auto‑discovery.

4. Use the Repository in a Service or Controller

Inject the interface wherever you need data access.

<?php

namespace App\Http\Controllers;

use App\Repositories\Interfaces\PostRepositoryInterface;
use Illuminate\Http\Request;

class PostController extends Controller
{
    protected $postRepo;

    public function __construct(PostRepositoryInterface $postRepo)
    {
        $this->postRepo = $postRepo;
    }

    public function index(Request $request)
    {
        $filters = $request->only(['status', 'author_id']);
        $posts = $this->postRepo->where($filters);

        return response()->json($posts);
    }

    public function show($id)
    {
        $post = $this->postRepo->findById($id);

        if (!$post) {
            return response()->json(['message' => 'Post not found'], 404);
        }

        return response()->json($post);
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'title' => 'required|string|max:255',
            'body' => 'required|string',
            'slug' => 'required|string|unique:posts,slug',
        ]);

        $post = $this->postRepo->create($data);

        return response()->json($post, 201);
    }

    public function update(Request $request, $id)
    {
        $data = $request->validate([
            'title' => 'sometimes|string|max:255',
            'body' => 'sometimes|string',
        ]);

        $updated = $this->postRepo->update($id, $data);

        if (!$updated) {
            return response()->json(['message' => 'Post not found'], 404);
        }

        return response()->json(['message' => 'Post updated']);
    }

    public function destroy($id)
    {
        $deleted = $this->postRepo->delete($id);

        if (!$deleted) {
            return response()->json(['message' => 'Post not found'], 404);
        }

        return response()->json(['message' => 'Post deleted']);
    }
}
?>

5. (Optional) Create a Base Repository for Reusable Logic

If you have many repositories, a base class can encapsulate common tasks like caching or soft‑delete handling.

<?php

namespace App\Repositories\Eloquent;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Cache\Repository as CacheRepository;

abstract class BaseEloquentRepository
{
    protected $model;
    protected $cache;

    public function __construct($model, CacheRepository $cache = null)
    {
        $this->model = $model;
        $this->cache = $cache;
    }

    protected function getCacheKey(string $method, array $params): string
    {
        return sprintf('repo:%s:%s:%s', get_class($this->model), $method, md5(serialize($params)));
    }

    public function findById(int $id): ?object
    {
        if ($this->cache) {
            $key = $this->getCacheKey('findById', [$id]);
            return $this->cache->remember($key, 300, fn() => $this->model->find($id));
        }

        return $this->model->find($id);
    }

    // Other shared methods can go here
}
?>

Your specific repository would then extend BaseEloquentRepository instead of implementing the interface directly, while still satisfying the interface contract.

Real‑World Examples

To see the pattern in action, consider a multi‑tenant SaaS application where each tenant has its own database connection. Rather than scattering setConnection calls throughout services, you can encapsulate tenant resolution inside the repository.

<?php

namespace App\Repositories\Eloquent;

use App\Repositories\Interfaces\UserRepositoryInterface;
use App\Models\User;
use Illuminate\Support\Str;

class TenantAwareUserRepository implements UserRepositoryInterface
{
    protected $model;

    public function __construct(User $model)
    {
        // Determine tenant from subdomain, header, or middleware
        $tenantId = tenant()->id; // Assume a tenant() helper
        $connection = sprintf('tenant_%s', $tenantId);
        $this->model = $model->onConnection($connection);
    }

    // Implement interface methods using $this->model
    // ...
}
?>

Another example is aggregating data from multiple sources. Suppose you need to fetch a user's profile from the main database and their preferences from an external API. A repository can hide this complexity.

<?php

namespace App\Repositories\Eloquent;

use App\Repositories\Interfaces\ProfileRepositoryInterface;
use App\Models\User;
use App\Services\PreferenceApiClient;

class HybridProfileRepository implements ProfileRepositoryInterface
{
    protected $userModel;
    protected $preferenceClient;

    public function __construct(User $userModel, PreferenceApiClient $preferenceClient)
    {
        $this->userModel = $userModel;
        $this->preferenceClient = $preferenceClient;
    }

    public function findById(int $id): array
    {
        $user = $this->userModel->find($id);
        if (!$user) {
            return null;
        }

        $preferences = $this->preferenceClient->getPreferences($user->external_id);

        return [
            'user' => $user->toArray(),
            'preferences' => $preferences,
        ];
    }
}
?>

These examples demonstrate how the repository pattern centralizes data‑access concerns, making your application easier to evolve.

Production Code Examples

Below is a complete, production‑ready repository for managing Order entities, complete with soft‑delete handling, eager loading, and basic filtering.

<?php

namespace App\Repositories\Eloquent;

use App\Repositories\Interfaces\OrderRepositoryInterface;
use App\Models\Order;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

class EloquentOrderRepository implements OrderRepositoryInterface
{
    protected $model;

    public function __construct(Order $model)
    {
        $this->model = $model;
    }

    public function all(): array
    {
        return $this->model->with(['customer', 'items.product'])->get()->toArray();
    }

    public function findById(int $id): ?Order
    {
        return $this->model->with(['customer', 'items.product'])->find($id);
    }

    public function create(array $data): Order
    {
        // Expects keys: customer_id, items (array of product_id, quantity)
        $order = $this->model->create([
            'customer_id' => $data['customer_id'],
            'status' => 'pending',
            'total_amount' => 0,
        ]);

        $total = 0;
        foreach ($data['items'] as $item) {
            $order->items()->create([
                'product_id' => $item['product_id'],
                'quantity' => $item['quantity'],
                'price' => $item['price'],
            ]);
            $total += $item['price'] * $item['quantity'];
        }

        $order->update(['total_amount' => $total]);

        return $order->fresh();
    }

    public function update(int $id, array $data): bool
    {
        $order = $this->model->find($id);
        if (!$order) {
            return false;
        }

        $order->update($data);

        return true;
    }

    public function delete(int $id): bool
    {
        $order = $this->model->withTrashed()->find($id);
        if (!$order) {
            return false;
        }

        if ($order->trashed()) {
            // Already soft deleted
            return true;
        }

        return $order->delete();
    }

    public function restore(int $id): bool
    {
        $order = $this->model->withTrashed()->find($id);
        if (!$order) {
            return false;
        }

        return $order->restore();
    }

    public function paginate(int $perPage = 15): LengthAwarePaginator
    {
        return $this->model->with(['customer'])->paginate($perPage);
    }

    public function filter(array $filters, int $perPage = 15): LengthAwarePaginator
    {
        $query = $this->model->newQuery()->with(['customer']);

        if (isset($filters['status'])) {
            $query->where('status', $filters['status']);
        }

        if (isset($filters['start_date']) && isset($filters['end_date'])) {
            $query->whereBetween('created_at', [
                $filters['start_date'],
                $filters['end_date'],
            ]);
        }

        if (isset($filters['customer_id'])) {
            $query->where('customer_id', $filters['customer_id']);
        }

        return $query->paginate($perPage);
    }
}
?>

Notice how the repository encapsulates eager loading (with), calculation of totals, and soft‑delete/restore logic. Controllers using this repository stay focused on orchestration rather than data‑access intricacies.

Comparison Table

Below is a concise comparison between using the repository pattern and calling Eloquent directly in services.

Aspect Direct Eloquent Repository Pattern
Separation of Concerns Business logic knows Eloquent specifics Business logic depends only on interface
Testability Requires database or extensive mocking of Eloquent Easy to mock repository interface
Flexibility to Change Storage High effort – need to replace Eloquent calls throughout Swap implementation; only binding changes
Boilerplate Less initial code Extra interface and implementation files
Centralized Query Logic Scattered across services Located in repository methods
Learning Curve Familiar to most Laravel developers Requires understanding of interfaces and binding

For small prototypes or simple apps, direct Eloquent may be acceptable. As the project grows, the repository pattern pays dividends in maintainability and testability.

Best Practices

  • Keep Interfaces Focused: Define only the methods your application actually needs. Avoid god‑interfaces with dozens of unrelated methods.
  • Return Eloquent Models or DTOs: If you return Eloquent models, be aware that the caller can still call Eloquent methods on them. Consider returning plain arrays or data transfer objects if you want strict encapsulation.
  • Use Type Hinting for Dependencies: Always type‑hint the repository interface in constructors; let Laravel's container resolve the concrete class.
  • Leverage Laravel's Cache via Decorator: If you need caching, create a decorator class that implements the same interface and wraps the Eloquent repository, rather than adding caching logic directly inside the repository.
  • Handle Exceptions Gracefully: Let the repository throw meaningful exceptions (e.g., ModelNotFoundException) that services can catch and translate into appropriate HTTP responses.
  • Keep Transactions in Services: Database transactions that span multiple repository calls belong in the service layer, not inside individual repository methods, to maintain clear boundaries.
  • Name Conventions: Use *RepositoryInterface for interfaces and Eloquent*Repository for Eloquent‑based implementations to make the purpose obvious.
  • Document Each Method: Add PHPDoc describing parameters, return values, and any side effects (e.g., triggers events).
  • Unit Test the Repository: Use an in‑memory SQLite database or a fake repository to verify that your implementation behaves correctly.

Common Mistakes

  • Leaking Eloquent Specifics: Returning raw Eloquent builders or collections from interface methods forces callers to know about Eloquent, breaking the abstraction.
  • Over‑Loading the Interface: Adding every possible query variation to the interface makes it bloated and defeats the purpose of a clean contract.
  • Skipping the Service Provider Binding: Forgetting to bind the interface results in resolution errors when trying to type‑hint the interface.
  • Putting Business Logic in Repositories: Repositories should only handle data retrieval and persistence; placing complex calculations or workflow steps here mixes concerns.
  • Ignoring Soft Deletes: If your models use soft deletes, repository methods must explicitly handle withTrashed or onlyTrashed as needed.
  • Not Using Dependency Injection for Helpers: Injecting services like cache or event dispatchers directly into repositories via the container keeps them testable.
  • Over‑Caching Without Invalidation: Caching query results without a proper invalidation strategy can lead to stale data.

Performance Tips

  • Eager Load Relationships: Use with inside repository methods to avoid N+1 query problems when returning models with relations.
  • Select Only Needed Columns: When you don't need the full model, use select to limit columns fetched from the database.
  • Chunk Large Datasets: For processing thousands of records, use Eloquent's chunk method inside a repository method to keep memory usage low.
  • Leverage Database Indexes: Ensure columns used in where, orderBy, and join clauses are indexed.
  • Use Caching Judiciously: Cache expensive aggregations or rarely changing lists (e.g., categories, settings) with appropriate TTLs.
  • Avoid Unnecessary Toxic Operations: Don't call get followed by count when count alone suffices.
  • Monitor Query Logs: During development, enable the query log to spot inefficient patterns introduced by repository methods.

Security Considerations

  • Mass Assignment Protection: Always validate and sanitize input data before passing it to repository create or update methods. Use Laravel's request validation or form request objects.
  • Authorization Checks: While repositories focus on data access, ensure that the service or controller layer verifies that the current user is allowed to perform the operation on the requested resource.
  • SQL Injection Safety: Eloquent's query builder uses PDO binding under the hood, so using where with array parameters is safe. Avoid raw queries unless you properly bind parameters.
  • Data Encryption for Sensitive Fields: If you store personally identifiable information, consider using Eloquent mutators or database encryption; repositories should remain agnostic to this.
  • Rate Limiting on Expensive Operations: Repository methods that perform heavy aggregations can be abused; protect them with middleware or gateway‑level rate limiting.

Deployment Notes

  • Run Migrations: Ensure that any new columns or tables referenced by repository methods are migrated before deploying the code.
  • Clear Configuration Cache: If you added a new service provider, run php artisan config:cache after deployment.
  • Verify Container Bindings: Run php artisan tinker and attempt to resolve an interface to confirm the binding works in the production environment.
  • Monitor Repository Calls: Use Laravel Telescope or a similar tool to watch for unexpected repository usage patterns that could indicate missing bindings or over‑fetching.

Debugging Tips

  • Interface Mismatch: If you get a binding resolution error, double‑check that the interface name and the concrete class name match exactly, including namespaces.
  • Unexpected Null Returns: Verify that the repository method is receiving the correct parameters; use log or dd temporarily to inspect inputs.
  • Query Log: Enable DB::enableQueryLog() before calling a repository method and inspect DB::getQueryLog() to see the exact SQL generated.
  • Cache Conflicts: If you introduced a caching decorator, ensure that cache keys are unique enough to avoid collisions.
  • Test with a Fresh Database: Run your feature tests against an empty SQLite database to ensure repositories don't rely on hidden seed data.

FAQ

What is the main advantage of using the repository pattern in Laravel?

The primary advantage is decoupling business logic from the Eloquent ORM, which improves testability, allows swapping data sources, and centralizes data‑access code.

Do I need to create a repository for every model?

Not necessarily. Start with models that have complex queries, multiple relationships, or are likely to change storage mechanisms. Simple CRUD models can remain with direct Eloquent usage until a need arises.

Can I return plain arrays instead of Eloquent models from repository methods?

Yes. Returning arrays or data transfer objects can further encapsulate the data layer, but be aware that you lose Eloquent conveniences like mutators and relationship loading unless you manually map them.

How do I handle transactions that involve multiple repository calls?

Wrap the service method that orchestrates the repository calls in a database transaction using DB::transaction. Keep transaction management out of the repository itself.

Is there a performance penalty for using repositories?

The abstraction adds negligible overhead. Any performance impact comes from how you implement the repository methods (e.g., forgetting to eager load). Properly written repositories perform similarly to direct Eloquent calls.

Should I use Laravel's cache inside the repository?

You can, but a cleaner approach is to create a decorator that implements the same interface and wraps the Eloquent repository, adding caching logic without modifying the core repository.

How do I unit test a repository that depends on Eloquent?

Either use an in‑memory SQLite database for fast integration‑style tests, or create a fake repository implementation that returns predetermined data for pure unit tests of the service layer.

Can repositories work with multiple database connections?

Absolutely. Determine the appropriate connection inside the repository constructor (e.g., based on tenant, user preference, or environment) and set the model's connection accordingly.

Conclusion

Implementing the repository pattern in Laravel is a powerful step toward building applications that are maintainable, testable, and adaptable to changing requirements. By defining clear interfaces, binding them to Eloquent implementations, and relying on dependency injection, you keep your controllers and services focused on business purposes rather than data‑access details. The pattern also prepares your codebase for future evolutions—such as switching to a different data store, integrating micro‑services, or adding caching layers—without massive refactoring.

Start small: pick a single model with non‑trivial queries, create its repository, and experience the benefits firsthand. As you grow comfortable, expand the pattern across your codebase. Remember to follow the best practices outlined above, avoid common pitfalls, and leverage Laravel's powerful service container to keep everything wired together.

Take the next step: refactor one of your existing services to use a repository, write a unit test that mocks the repository interface, and observe how your tests become faster and more reliable. Happy coding!