I Rebuilt a Laravel App With the Repository Pattern — Here’s My Honest Verdict

The Repository Pattern is the most debated architectural decision in the Laravel community. Fans swear by it. Taylor Otwell thinks it’s unnecessary. After rebuilding a real app both ways, here’s what I actually believe.


Few architectural debates in the Laravel community generate more heat and less light than the Repository Pattern. On one side: developers who say it’s essential for testability, abstraction, and keeping business logic clean. On the other: Taylor Otwell himself, who has publicly stated that in the context of Laravel, repositories are unnecessary abstraction — that Eloquent is already a Data Mapper and wrapping it in repositories just adds boilerplate for no benefit.

Both sides make coherent arguments. Both sides are partially right. And most blog posts on this topic pick a side and argue it, rather than telling you what actually happens when you build a real application both ways.

This is the honest account of what I found when I did exactly that.


What the Repository Pattern Actually Is

Before the verdict, a clear definition — because a lot of the debate is people arguing past each other about different things.

The Repository Pattern, as described by Martin Fowler, mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects. In plain language: your business logic talks to a repository interface, not directly to a database. The repository hides how data is actually retrieved and stored.

// The interface — defines a contract
interface UserRepositoryInterface
{
    public function find(int $id): ?User;
    public function findByEmail(string $email): ?User;
    public function findActiveWithSubscription(): Collection;
    public function save(User $user): User;
    public function delete(int $id): bool;
}

// The Eloquent implementation — fulfils the contract
class EloquentUserRepository implements UserRepositoryInterface
{
    public function find(int $id): ?User
    {
        return User::find($id);
    }

    public function findByEmail(string $email): ?User
    {
        return User::where('email', $email)->first();
    }

    public function findActiveWithSubscription(): Collection
    {
        return User::where('status', 'active')
            ->whereHas('subscription', fn($q) => $q->where('ends_at', '>', now()))
            ->with('subscription')
            ->get();
    }

    public function save(User $user): User
    {
        $user->save();
        return $user;
    }

    public function delete(int $id): bool
    {
        return User::destroy($id) > 0;
    }
}

The promise: because your business logic depends on the interface, not the implementation, you can swap Elasticsearch for Eloquent, mock the repository in tests, or add a caching layer without touching business logic.


The Case For the Repository Pattern

Let me make the strongest honest version of the pro-repository argument, because it deserves to be made clearly.

1. Testability Without Database

The argument most often made for repositories is testing. When your service classes depend on a UserRepositoryInterface, you can inject a mock or a fake in tests — no database required.

// Service class depends on interface — not Eloquent
class UserService
{
    public function __construct(
        private UserRepositoryInterface $users,
    ) {}

    public function promoteToAdmin(int $userId): User
    {
        $user = $this->users->find($userId);

        if (!$user) {
            throw new UserNotFoundException($userId);
        }

        if ($user->role === 'admin') {
            throw new AlreadyAdminException($userId);
        }

        $user->role = 'admin';
        return $this->users->save($user);
    }
}
// Test — no database, no Eloquent, fast and isolated
class UserServiceTest extends TestCase
{
    public function test_promotes_user_to_admin(): void
    {
        $user = new User(['id' => 1, 'role' => 'user']);

        $repository = $this->createMock(UserRepositoryInterface::class);
        $repository->method('find')->with(1)->willReturn($user);
        $repository->method('save')->willReturnArgument(0);

        $service = new UserService($repository);
        $result  = $service->promoteToAdmin(1);

        $this->assertEquals('admin', $result->role);
    }

    public function test_throws_when_user_not_found(): void
    {
        $repository = $this->createMock(UserRepositoryInterface::class);
        $repository->method('find')->willReturn(null);

        $service = new UserService($repository);

        $this->expectException(UserNotFoundException::class);
        $service->promoteToAdmin(99);
    }
}

These tests run in milliseconds. No database setup. No factories. No transactions to roll back. When the test suite has hundreds of service tests, this matters.

2. Separation of Concerns

Repositories give queries a home that isn’t a controller or a model. Without them, complex Eloquent queries tend to end up either in controllers (bad) or in model scopes (reasonable, but creates a different coupling). With repositories, the data access layer has a clear, findable location.

// All user-related queries in one place
class EloquentUserRepository implements UserRepositoryInterface
{
    public function findChurnRisk(): Collection
    {
        return User::where('last_login_at', '<', now()->subDays(30))
            ->whereHas('subscription', fn($q) =>
                $q->where('ends_at', '<', now()->addDays(7))
            )
            ->with(['subscription', 'activityLog' => fn($q) =>
                $q->latest()->limit(5)
            ])
            ->get();
    }

    public function findHighValueInactive(): Collection
    {
        return User::whereDoesntHave('orders', fn($q) =>
                $q->where('created_at', '>', now()->subDays(90))
            )
            ->where('lifetime_value', '>', 500)
            ->orderByDesc('lifetime_value')
            ->limit(100)
            ->get();
    }
}

3. Caching Is Genuinely Cleaner

The repository is the natural place to add a caching layer — and it stays completely transparent to the callers.

class CachedUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private EloquentUserRepository $inner,
        private Cache                  $cache,
    ) {}

    public function find(int $id): ?User
    {
        return $this->cache->remember(
            "user:{$id}",
            now()->addMinutes(15),
            fn () => $this->inner->find($id)
        );
    }

    public function save(User $user): User
    {
        $result = $this->inner->save($user);
        $this->cache->forget("user:{$user->id}");
        return $result;
    }
}

Swap EloquentUserRepository for CachedUserRepository in the service provider — every caller gets caching without knowing it changed.


The Case Against the Repository Pattern

Now the strongest honest version of the anti-repository argument — and it’s also compelling.

1. Eloquent Is Already a Repository

Laravel’s Eloquent ORM is based on the Active Record pattern, but it also acts as a Data Mapper. The User::where()->get() call is already abstracted from SQL. The User model is already a repository-like interface to your users table. Wrapping it in another abstraction layer adds indirection without adding meaningful isolation.

// Eloquent already abstracts the database
// This is already "repository-like"
User::where('status', 'active')->with('subscription')->get()

// Wrapping it adds a method call, an interface, a service provider binding,
// and a new class — for code that's already readable
$this->users->findActiveWithSubscription()

2. The Swappable Database Fallacy

The most cited reason for repositories — “so you can swap Eloquent for another ORM later” — almost never happens in practice. The overhead of designing every data access layer against the possibility of swapping your database driver is real, upfront cost in exchange for a benefit that, empirically, almost no one ever realises.

// The interface was written to support this swap:
interface UserRepositoryInterface
{
    public function find(int $id): ?User;
}

// But your User model is an Eloquent model.
// Your controllers cast responses as Eloquent collections.
// Your tests use Eloquent factories.
// Your events fire Eloquent models.
// Swapping to Doctrine would require rewriting the entire application anyway.
// The repository abstraction protected nothing.

3. Fighting the Framework

Laravel is designed around Eloquent. Its conventions, its testing utilities (RefreshDatabase, model factories, assertDatabaseHas), and its community patterns all assume you’re working with Eloquent directly. Introducing a repository layer fights those conventions constantly.

// Laravel's built-in testing tools are designed for Eloquent
public function test_user_is_created_on_registration(): void
{
    $this->post('/register', [
        'name'     => 'Taylor',
        'email'    => 'taylor@example.com',
        'password' => 'password',
    ]);

    // This is clean, readable, and works perfectly
    $this->assertDatabaseHas('users', ['email' => 'taylor@example.com']);
}

// With repositories, you're mocking instead — which means you're not actually
// testing that the user was written to the database

4. Repositories Become God Objects

In practice, repositories rarely stay clean. Business logic creeps in. Repositories grow to 500+ lines. The UserRepository ends up with methods like findUsersForChurnCampaignWithSubscriptionAndLastFiveOrdersAndActivityLog() — methods so specific they’re only called from one place, eliminating any benefit from the abstraction.

// What repositories look like six months into a real project
class EloquentUserRepository
{
    public function find(int $id): ?User { ... }
    public function findByEmail(string $email): ?User { ... }
    public function findActiveSubscribers(): Collection { ... }
    public function findChurnRisk(): Collection { ... }
    public function findHighValueInactive(): Collection { ... }
    public function findForAdminReport(array $filters): LengthAwarePaginator { ... }
    public function findForExport(Carbon $from, Carbon $to): LazyCollection { ... }
    public function findWithUnreadNotifications(): Collection { ... }
    public function countByStatus(): array { ... }
    public function updateLastLoginTimestamp(int $id): void { ... }
    // ... 15 more methods, each called from exactly one place
}

The repository has become a dumping ground. It’s no more organised than the models and controllers were before — just further away.


What I Actually Found When I Rebuilt the App

Here’s the concrete finding from rebuilding the same application both ways.

The app: A SaaS product with users, subscriptions, orders, and a reporting module. Roughly 25 service classes, 8 domain models, 150 feature tests.

Test Speed

With repositories and mocks, service-level unit tests ran significantly faster. A suite of 80 service tests that touched no database completed in about 0.4 seconds.

With Eloquent directly and RefreshDatabase, those same test scenarios ran in about 6 seconds.

Verdict: Repository wins on test speed — but the difference matters less than it sounds. 6 seconds for 80 service tests is still fast. Where it compounds is in CI with 500+ tests. Then the gap becomes real.

Development Speed

Adding a new feature without repositories: write the controller, write the Eloquent query inline or in a scope, write a feature test using RefreshDatabase. The process is fluid and fast. Laravel’s documentation, community examples, and Stack Overflow all assume this pattern.

Adding a new feature with repositories: write the interface method, write the implementation, register the binding, write the service, inject the repository, write the test with a mock. The overhead per feature is small but real — roughly 20–30% more code to write.

Verdict: Direct Eloquent wins on development speed, especially for smaller teams and early-stage products.

Codebase Navigability

Without repositories, tracing where a query originates requires searching for User::where across controllers, services, and model scopes. There’s no single place queries live.

With repositories, every query for a given model lives in one file. UserRepository.php is the complete answer to “where do we query users?”

Verdict: Repositories win on navigability, but only when the team enforces discipline about not putting queries elsewhere. If anyone breaks the pattern and puts a User::where directly in a controller, the navigability benefit evaporates.

Refactoring Safety

When I needed to add a global scope to filter soft-deleted records, the impact without repositories was widespread — every Eloquent call needed checking. With repositories, one method change in one file was sufficient.

Verdict: Repositories win on refactoring safety, significantly so.


The Middle Ground That Nobody Talks About

Here’s where I landed after running both experiments: neither pure approach is the right answer for most applications. The actual best practice is more nuanced.

Use Query Objects Instead of Repositories for Complex Queries

Instead of a repository that owns every query for a model, create focused query objects for complex, reused queries. Simple queries stay inline with Eloquent.

// Simple queries — inline Eloquent is fine
$user = User::find($id);
$users = User::where('status', 'active')->get();

// Complex, reused queries — a focused class
class ChurnRiskQuery
{
    public function get(): Collection
    {
        return User::where('last_login_at', '<', now()->subDays(30))
            ->whereHas('subscription', fn($q) =>
                $q->where('ends_at', '<', now()->addDays(7))
            )
            ->with(['subscription', 'activityLog'])
            ->get();
    }
}

// Service — only depends on this one query object
class ChurnCampaignService
{
    public function __construct(
        private ChurnRiskQuery $query
    ) {}

    public function getTargetUsers(): Collection
    {
        return $this->query->get();
    }
}

Use Action Classes Instead of Service Classes That Need Mocking

Action classes are small, single-purpose classes that encapsulate one piece of business logic. They’re trivially testable with RefreshDatabase because they do one thing — no complex mocking setup required.

// app/Actions/PromoteUserToAdmin.php
class PromoteUserToAdmin
{
    public function execute(User $user): User
    {
        if ($user->role === 'admin') {
            throw new AlreadyAdminException($user->id);
        }

        $user->update(['role' => 'admin']);

        event(new UserPromotedToAdmin($user));

        return $user->fresh();
    }
}
// Test — simple, uses RefreshDatabase, no mocking needed
class PromoteUserToAdminTest extends TestCase
{
    use RefreshDatabase;

    public function test_promotes_user_to_admin(): void
    {
        $user   = User::factory()->create(['role' => 'user']);
        $action = new PromoteUserToAdmin();
        $result = $action->execute($user);

        $this->assertEquals('admin', $result->role);
        $this->assertDatabaseHas('users', ['id' => $user->id, 'role' => 'admin']);
    }

    public function test_throws_if_already_admin(): void
    {
        $user = User::factory()->create(['role' => 'admin']);

        $this->expectException(AlreadyAdminException::class);
        (new PromoteUserToAdmin())->execute($user);
    }
}

When Repositories Are Actually Worth It

After all of this, here are the situations where I would recommend the full repository pattern without hesitation:

Large teams with strict architectural boundaries. When 10+ developers work on the same codebase and the consistency of having one clear place for queries outweighs the overhead, repositories pay for themselves.

Applications with genuinely complex, multi-source data. If your application reads from Eloquent, Elasticsearch, Redis, and an external API — and your service layer needs to be agnostic about which source is used — repositories are the right abstraction.

Microservices with shared domain logic. If multiple services share the same domain logic but different persistence mechanisms, an interface that can be fulfilled by different implementations is genuinely valuable.

Applications that require audit trails or event sourcing. When every persistence operation needs to pass through a layer that records events, the repository is the natural place for that cross-cutting concern.


The Decision Framework

Do you have more than one data source for the same entity?
├── Yes → Use repositories (abstraction is genuinely needed)
└── No
    │
    Is your team larger than 8–10 developers?
    ├── Yes → Consider repositories for navigability and consistency
    └── No
        │
        Do you have complex, reused queries that need a home?
        ├── Yes → Use Query Objects for complex queries, Eloquent directly for simple ones
        └── No
            └── Use Eloquent directly + Action classes for business logic
                → Simplest approach, works well for most Laravel applications

My Honest Verdict

Taylor Otwell is right that repositories are unnecessary for most Laravel applications. Eloquent is a powerful, expressive ORM that already abstracts the database cleanly. Adding a repository layer to a standard Laravel application with a single database adds boilerplate and overhead without proportional benefit.

But the developers who swear by repositories are not wrong either — they’re often working on the types of applications where repositories genuinely help. Large codebases. Multiple data sources. Teams that need strict architectural boundaries.

The mistake is treating this as a universal principle rather than a context-sensitive decision. The repository pattern is a tool. Like all tools, it’s valuable in the right situation and overhead in the wrong one.

For most Laravel applications in 2026 — single database, small-to-medium team, standard SaaS or web application scope — the better path is:

  • Eloquent directly for simple queries
  • Query objects for complex, reused queries that need a home
  • Action classes for business logic that needs to be tested in isolation
  • Model observers for cross-cutting concerns on model changes
  • Service classes only when orchestrating multiple actions or external services

This combination gives you most of the benefits of the repository pattern — clear query organisation, testable business logic, separation of concerns — without the boilerplate overhead of full repository abstraction.

If your application grows to the point where this isn’t enough, the repository pattern will be waiting. And by then, you’ll have enough context to implement it where it actually adds value, rather than everywhere because an article told you to.


A Note on the “Just Use Eloquent” Purist Position

One position I won’t take: that Eloquent queries should live directly in controllers and that’s fine. It isn’t. Controllers that contain complex Eloquent queries are harder to test, harder to reuse, and harder to reason about.

The question isn’t whether to organise your data access code. It’s where to put it and how much abstraction to add. Query objects, model scopes, and action classes are all legitimate answers. Full repository abstraction is a legitimate answer in the right context. Eloquent queries in controllers is not an answer.

The organisation matters. The specific form of organisation depends on your application.

Leave a Reply

Your email address will not be published. Required fields are marked *