Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 038 / 100

Repository pattern in Laravel: do you need it explained

Often no. Sometimes yes.

Expona Admin6 min read

Repository pattern in Laravel: do you need it explained

You've probably heard that you should use the repository pattern in Laravel. Maybe you've even implemented it in a past project and felt like you were writing boilerplate for its own sake. The truth is nuanced: Eloquent already gives you a lot of what repositories promise, but there are real scenarios where a dedicated repository layer makes your codebase clearer and more flexible.

Let's cut through the conventional wisdom and look at when repositories actually solve problems versus when they just add friction.

Eloquent is already a data mapper-like abstraction

Before we talk about repositories, it's worth recognizing what Eloquent already does. Your Eloquent models are not just database records—they implement a form of the data mapper pattern themselves. When you query a model, you're already working through an abstraction layer that shields you from raw SQL and database implementation details.

Consider this common scenario in an e-commerce system:

php
// In a controller or service
$products = Product::where('status', 'active')
    ->where('stock', '>', 0)
    ->with('category', 'reviews')
    ->paginate(15);

You're not touching raw SQL. You're not hardcoding table names. Eloquent handles the mapping between your PHP code and the database. If you swap PostgreSQL for MySQL tomorrow, nothing breaks. That's the abstraction Eloquent provides, and it's substantial.

Many teams add a repository layer on top of this without asking why. They write:

php
// ProductRepository.php
public function getActiveInStock()
{
    return Product::where('status', 'active')
        ->where('stock', '>', 0)
        ->with('category', 'reviews')
        ->paginate(15);
}

Then use it from a controller:

php
$products = $this->productRepository->getActiveInStock();

You've added a layer, but you haven't solved a real problem yet. The query logic is still tied to a database implementation. You haven't made the code more testable or flexible. You've just moved complexity sideways.

Repositories help with complex queries

Where repositories start to earn their weight is in handling genuinely complex queries that appear in multiple places. Imagine you're building a trading platform. You need to fetch open positions for a user, but the logic is intricate: you must join across multiple tables, apply time-based filters, exclude certain position types, and always sort by profit margin in a specific way.

Without a repository, this logic lives in a controller method, a service method, or scattered across both. A few months later, another developer needs the same list with slightly different conditions. They copy the query, modify it slightly, and now you have two subtly different versions of the same idea. Bugs creep in, and consistency breaks.

A repository consolidates that logic:

php
// PositionRepository.php
namespace App\Repositories;

use App\Models\Position;
use Illuminate\Pagination\Paginator;

class PositionRepository
{
    public function getOpenPositionsForUser(
        int $userId,
        array $excludeTypes = [],
        int $perPage = 20
    ): Paginator {
        return Position::where('user_id', $userId)
            ->where('status', 'open')
            ->when(
                ! empty($excludeTypes),
                fn ($q) => $q->whereNotIn('type', $excludeTypes)
            )
            ->join('instruments', 'positions.instrument_id', '=', 'instruments.id')
            ->join('margins', 'positions.margin_id', '=', 'margins.id')
            ->where('positions.opened_at', '>=', now()->subDays(90))
            ->select('positions.*', 'instruments.symbol')
            ->orderByRaw('(positions.current_value - positions.entry_value) / positions.margin_requirement DESC')
            ->paginate($perPage);
    }
}

Now every part of your codebase that needs open positions calls the same method. When you discover a bug or need to refine the logic, you fix it once. This is where repositories genuinely reduce maintenance burden.

Useful when swapping data sources

Here's a scenario that advocates for repositories cite often: you want to swap your data source. Maybe you're moving from a relational database to an event store, or you want to add an in-memory cache layer for frequently accessed entities.

If repositories are designed properly—with an interface—you can swap implementations without touching your business logic:

php
// App\Contracts\OrderRepository.php
interface OrderRepository
{
    public function findById(int $id): ?Order;
    public function findRecent(int $limit): array;
    public function save(Order $order): void;
}

Then you have multiple implementations:

php
// Database implementation
class EloquentOrderRepository implements OrderRepository
{
    public function findById(int $id): ?Order
    {
        return Order::find($id);
    }
    
    public function findRecent(int $limit): array
    {
        return Order::latest()->limit($limit)->get()->all();
    }
    
    public function save(Order $order): void
    {
        $order->save();
    }
}

// Cache layer implementation
class CachedOrderRepository implements OrderRepository
{
    public function __construct(
        private OrderRepository $repository,
        private Cache $cache
    ) {}
    
    public function findById(int $id): ?Order
    {
        return $this->cache->remember(
            "order:{$id}",
            3600,
            fn () => $this->repository->findById($id)
        );
    }
    
    // ... other methods delegate to $this->repository
}

In a service or controller, you depend on the interface, not the concrete implementation:

php
class OrderCheckoutService
{
    public function __construct(private OrderRepository $orders) {}
    
    public function process(int $orderId): void
    {
        $order = $this->orders->findById($orderId);
        // ... business logic
    }
}

Now you can swap the underlying repository by changing a service provider binding. In practice, this flexibility is valuable if you're genuinely experimenting with different architectures or if your data storage strategy changes. But for most applications, a single Eloquent-based repository is sufficient.

Do not wrap every model by default

This is the critical principle many teams miss. Not every model needs a repository. Adding a repository for every entity in your application creates boilerplate without corresponding benefit.

Ask yourself: Is this query complex? Does it appear in multiple places? Will I ever need to swap the data source? If you answer "no" to all three, skip the repository.

For straightforward CRUD operations on a model that's queried in only one or two places, Eloquent queries in your service layer or controller are fine:

php
// No need for a repository here
class UserProfileService
{
    public function updateBio(int $userId, string $bio): User
    {
        $user = User::findOrFail($userId);
        $user->bio = $bio;
        $user->save();
        return $user;
    }
}

This is clear, direct, and has zero overhead. Compare that to the cost of building a UserRepository with all its boilerplate just to wrap this operation.

A pragmatic approach: build repositories for entities with complex query logic or multiple access patterns. For everything else, use Eloquent directly in your services and controllers.

Common mistakes

Creating a repository just to hide Eloquent. Some teams treat repositories as a way to make testing easier by hiding the database. But you can mock Eloquent directly, and modern Laravel testing tools make this straightforward. Don't add a repository layer purely for this reason.

Repositories that are just query builders. A repository that just returns a query builder (not a resolved collection or paginator) defeats the purpose. If your method returns Builder, you haven't abstracted anything—you've just moved the query into a different file.

Mixing query logic and business logic in a repository. Repositories should return data. If your repository method includes business logic—calculating discounts, sending notifications, validating state transitions—that logic belongs in a service, not the repository.

Ignoring N+1 query problems. Repositories are a good place to define eager-load strategies. If your repository method returns models with relationships, load them upfront with with(). Pushing this responsibility to callers is a recipe for performance problems.

Key takeaways

  • Eloquent already provides data abstraction; a repository layer on top must justify itself.
  • Use repositories when you have genuinely complex queries that appear in multiple places.
  • Implement interfaces so you can swap implementations and test easily.
  • Not every model needs a repository—apply them selectively.
  • Keep repositories focused on data access; push business logic to services.
  • Define eager-load strategies in repositories to prevent N+1 queries.
  • A repository that returns query builders hasn't abstracted anything worthwhile.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles