Back to Blog

N+1 Queries: How the Repository Pattern Solves Them

- 5 min read

Every Laravel developer has faced N+1 queries. You add with('relation') everywhere, use Laravel Debugbar religiously, and still find yourself playing whack-a-mole with performance issues. There's a better way.

The Problem with Implicit Loading

Eloquent's lazy loading is convenient. Too convenient. When you write:

$orders = Order::all();
foreach ($orders as $order) {
    echo $order->customer->name; // N+1!
}

You get 1 query for orders, plus N queries for customers. The fix seems obvious:

$orders = Order::with('customer')->get();

Problem solved? Not really. You've just moved the problem. Now your controller "knows" about database optimization. And when requirements change, you forget to update the eager loading. Or you over-fetch data you don't need.

The Repository Solution

With the Repository pattern, data fetching becomes explicit. You define exactly what you need, where you need it:

interface OrderRepositoryInterface
{
    public function findAllWithCustomers(): Collection;
    public function findById(OrderId $id): Order;
    public function findByIdWithItems(OrderId $id): Order;
}

Each method has a clear purpose. No ambiguity about what data will be loaded:

final class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function findAllWithCustomers(): Collection
    {
        return OrderModel::with('customer')
            ->get()
            ->map(fn($model) => $this->toDomain($model));
    }

    public function findByIdWithItems(OrderId $id): Order
    {
        $model = OrderModel::with('items')
            ->findOrFail($id->value());

        return $this->toDomain($model);
    }
}

Why This Works Better

  1. Explicit contracts - The interface documents exactly what data loading options exist
  2. Single responsibility - Optimization logic lives in the repository, not scattered across controllers
  3. Easy to test - Mock the repository interface, not Eloquent internals
  4. AI-friendly - Clear method names help AI assistants understand intent

The CQRS Optimization

For read-heavy operations, combine this with dedicated Query objects:

final class GetOrdersWithCustomerQuery
{
    public function __construct(
        public readonly ?CustomerId $customerId = null,
        public readonly ?DateRange $dateRange = null,
    ) {}
}

final class GetOrdersWithCustomerHandler
{
    public function handle(GetOrdersWithCustomerQuery $query): Collection
    {
        return DB::table('orders')
            ->join('customers', 'orders.customer_id', '=', 'customers.id')
            ->when($query->customerId, fn($q) =>
                $q->where('customer_id', $query->customerId->value())
            )
            ->select([
                'orders.id',
                'orders.total',
                'customers.name as customer_name',
            ])
            ->get();
    }
}

Now your reads bypass Eloquent entirely. No N+1 possible. No over-fetching. Just the exact data you need.

Key Takeaway

N+1 queries aren't a technical problem - they're a design problem. Eloquent's implicit loading encourages implicit decisions about data fetching. The Repository pattern makes those decisions explicit, centralized, and testable.

Want to learn more?

This pattern is covered in detail in Chapter 12 (Repositories) and Chapter 14 (Queries and Read Models) of Pragmatic DDD with Laravel.

Get the book