5 min read

When Repository Classes Help in Laravel

What I learned from researching and applying repository classes selectively in Laravel applications, without wrapping Eloquent by default.

  • Architecture
  • Product thinking

I started looking more closely at repository classes while working on Laravel applications. I wanted data-access code to have a clear place, but I also saw how an extra class could add more navigation without making a query easier to understand.

I researched the pattern, compared it with tools Laravel already provides, and applied it selectively in my work. The main lesson was not that every model needs a repository. A repository or query class is useful when it creates a meaningful boundary; otherwise, Eloquent may already be enough.

The examples below are generalized to avoid exposing application details. They document the approach I understood and applied, not the exact source code of a work project.

A Simple Query Is Not Automatically a Problem

Consider a product listing used in one controller:

$products = Product::query()
    ->where('status', 'active')
    ->where('stock', '>', 0)
    ->with('category')
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->paginate(20);

If the query is short, local, and used in one place, moving it into another class may only make the reader open an extra file.

The decision changes when one data-access rule is reused or needs a name. For example, a catalog query can become a small query object:

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

final class ProductCatalog
{
    public function available(int $perPage = 20): LengthAwarePaginator
    {
        return Product::query()
            ->where('status', 'active')
            ->where('stock', '>', 0)
            ->with('category')
            ->orderByDesc('created_at')
            ->orderByDesc('id')
            ->paginate($perPage);
    }
}

The value is not merely a shorter controller. The name available() gives one place to understand and change what the application means by an available product.

The Wrapper I Try to Avoid

A generic repository often repeats methods Eloquent already expresses:

$repository->find($id);
$repository->create($attributes);
$repository->update($id, $attributes);

If those methods accept the same inputs and return the same Eloquent models, the application still depends on Eloquent while carrying an additional layer. I do not add that wrapper only to match a pattern diagram.

I prefer names that describe a real query or business concept:

use App\Models\ProductStock;
use Illuminate\Database\Eloquent\Collection;

final class InventoryQueries
{
    public function belowReorderPoint(int $warehouseId): Collection
    {
        return ProductStock::query()
            ->where('warehouse_id', $warehouseId)
            ->whereColumn('quantity', '<=', 'reorder_point')
            ->with('product')
            ->get();
    }
}

belowReorderPoint() explains why the query exists. A generic method such as where($filters) would move syntax without preserving that meaning.

Choosing the Smallest Boundary

Researching and applying the pattern also helped me separate several tools that are easy to group under the word “repository”. I now choose the smallest one that fits the problem:

  • Eloquent query: when a query is short and local.
  • Model scope: when a reusable condition naturally belongs to one model and should remain composable.
  • Query class: when a completed read has a meaningful name or combines several conditions, relations, aggregates, or performance decisions.
  • Repository: when a group of persistence operations needs a stable boundary, especially when the underlying data source may vary.
  • Service or action: when the operation coordinates business rules, transactions, notifications, or external systems. Data access supports that operation but is not the whole use case.

This distinction keeps me from placing business workflows inside a repository or creating repositories for simple queries that a scope already explains.

Concrete Class or Interface?

Laravel’s service container can resolve a concrete class automatically when its dependencies are also resolvable:

public function __construct(
    private ProductCatalog $catalog,
) {}

An interface needs a binding so the container knows which implementation to inject:

$this->app->bind(
    ProductRepository::class,
    EloquentProductRepository::class,
);

I use an interface when it represents a boundary that benefits from another implementation or a contract independent of Eloquent. When there is one small concrete query class and no need to replace it, injecting the concrete class is usually enough.

Return Types Should Match the Boundary

Returning an Eloquent builder keeps a query composable but also exposes Eloquent to the caller. Returning a collection, paginator, data object, or scalar creates a firmer boundary but gives the caller fewer ways to extend the query.

I make that choice explicitly:

  • model scopes can return a builder for further composition;
  • completed query objects return their final result;
  • a repository should not claim to hide Eloquent while exposing Eloquent-specific behavior from every method.

Test the Query That Actually Runs

Mocking a repository can verify how a caller uses its contract, but it does not prove that an Eloquent query works. For database-backed query objects, I prefer an integration test with the database:

use App\Models\ProductStock;
use App\Queries\InventoryQueries;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class InventoryQueriesTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_returns_stock_below_the_reorder_point(): void
    {
        $below = ProductStock::factory()->create([
            'warehouse_id' => 1,
            'quantity' => 4,
            'reorder_point' => 5,
        ]);

        ProductStock::factory()->create([
            'warehouse_id' => 1,
            'quantity' => 8,
            'reorder_point' => 5,
        ]);

        ProductStock::factory()->create([
            'warehouse_id' => 2,
            'quantity' => 3,
            'reorder_point' => 5,
        ]);

        $result = app(InventoryQueries::class)
            ->belowReorderPoint(warehouseId: 1);

        $this->assertCount(1, $result);
        $this->assertTrue($result->contains($below));
    }
}

The record from warehouse 2 is intentional: it makes the test fail if the warehouse filter is accidentally removed.

Fakes and mocks remain useful for real external boundaries such as a payment gateway or remote API. For a database query, testing against the database gives me more confidence in its filters, relations, and result.

What I Apply Now

I start with Eloquent and keep a query close to where it is used. I extract it when a specific pressure appears: repeated meaning, growing query complexity, performance work, or a persistence boundary that the application should name.

This selective approach is what made the repository pattern useful in my work. The pattern is not the goal. The goal is to make data-access decisions easier to find, understand, test, and change without introducing a layer that only renames Eloquent.

References