Why the Repository Pattern Fights Laravel (And What to Use Instead)

21 min read
A fighter walking towards the ring.

Photo by Attentie Attentie on Unsplash

Search for how to structure a Laravel application and the repository pattern shows up near the top of every list. Wrap Eloquent in an interface, bind the implementation in a service provider, inject the interface everywhere, and your application is now decoupled from the database. It sounds disciplined. It reads well in a pull request description. I have written it, reviewed it, and argued for it.

I don't do it anymore. Not because the repository pattern is a bad pattern, but because it is a poor fit for Eloquent specifically. Every time I have used it in a Laravel project, I have spent more time fighting the framework than I saved. This article is my attempt to explain exactly why, and to show what I reach for instead: query scopes, custom builders, query classes, and action classes.

I should say up front that I have argued this position and lost. On one large codebase I pushed for scopes and custom builders instead of a repository layer, the team weighed it up and went the other way, and the repository approach shipped. That turned out to be more useful than winning would have been, because I then spent a couple of years watching the consequences play out in production code rather than theorizing about them from the outside. Which is a very serene way of describing it, and not at all how I felt at the time. 😄 Most of what follows comes from that, not from a blog post I read.

The order matters. I am going to spend the first half on the why, because the how is easy and largely uninteresting once you agree on the reasoning. If you take one thing away, let it be the reasoning and not the file layout.

What the Repository Pattern Actually Promises

It is worth being precise, because the term gets thrown around loosely. Repository comes from Martin Fowler's Patterns of Enterprise Application Architecture, and was popularized by Eric Evans in Domain-Driven Design. Fowler's definition is a class that mediates between the domain and the data mapping layer, exposing a collection-like interface for accessing domain objects.

Notice the phrase "data mapping layer". Repository is designed as a companion to Data Mapper, a pattern where your domain objects know nothing about persistence and a separate mapper translates them to and from rows. Under Data Mapper, a repository is the natural front door, because your domain objects genuinely cannot query. Something has to.

Eloquent is not Data Mapper. Eloquent is Active Record, the pattern where a single object holds both the row's data and the behavior that persists it. $user->save() is Active Record in one line. That is not an oversight, it is a deliberate design decision, and it is where most of Laravel's productivity comes from.

So when you put a repository in front of Eloquent, you are bolting a Data Mapper accessory onto an Active Record model. That single mismatch is the source of nearly every problem in this article.

The Abstraction Leaks Immediately

Here is the minimal version everyone starts with.

// app/Repositories/UserRepository.php
interface UserRepository
{
public function findById(int $id): ?User;
}
 
// app/Repositories/EloquentUserRepository.php
final readonly class EloquentUserRepository implements UserRepository
{
public function findById(int $id): ?User
{
return User::query()->find($id);
}
}

The stated goal is that nothing outside the implementation knows about Eloquent. Now look at what a caller does with the result.

$user = $this->users->findById($id); // "persistence agnostic"
 
$user->posts() // ...except it isn't
->whereNull('published_at')
->latest()
->get();
 
$user->update(['name' => 'Tharindu']);

That object is an Eloquent model. It carries save(), delete(), update(), lazy-loaded relations, and a full query builder hanging off every relationship. The caller did not have to reach around the repository to get at the database. The repository handed the database over, wrapped in an object.

You have two ways out, and both are bad. You can return data transfer objects instead of models, at which point you have given up relationships, casts, accessors, serialization, route model binding, and factories, and you are hand-rolling a Data Mapper on top of an ORM designed not to be one. Or you accept that models leak, in which case the interface is documentation rather than a boundary.

Then You Rebuild the Query Builder

Assume you take the second option, which is what almost everyone does. The repository still has to answer real questions, so it grows methods.

interface UserRepository
{
public function findById(int $id): ?User;
public function findByEmail(string $email): ?User;
public function findActiveByTeam(int $teamId): Collection;
public function findActiveByTeamPaginated(int $teamId, int $perPage): LengthAwarePaginator;
public function findActiveByTeamWithSubscriptions(int $teamId): Collection;
public function countActiveByTeamSince(int $teamId, CarbonImmutable $since): int;
// ...and so on, forever
}

This is the well-known failure mode. Filters combine, so the method count grows combinatorially rather than linearly. Every screen with a slightly different where needs a new method, a new interface entry, and a new implementation. Six months in, EloquentUserRepository is the five thousand line monster you were trying to avoid by keeping queries out of the model. Same monster, different postcode. You did not remove the problem, you renamed the file.

The clever fix is to make the repository chainable. I have watched a team arrive at this independently, and it is worth showing because the destination is so revealing. Stripped down, it looks like this.

trait BuildsQueries
{
private ?Builder $query = null;
 
public function fields(array $fields): static
{
$this->query()->addSelect(Arr::map($fields, function (string $field): string|Expression {
// Sniff out aggregates and aliases so we can wrap them in DB::raw()
// instead of letting raw SQL leak into the calling code.
return preg_match('/^[a-z_.\s]+$/i', $field) ? $field : DB::raw($field);
}));
 
return $this;
}
 
public function get(): Collection
{
return $this->execute('get');
}
 
public function count(): int
{
return $this->execute('count');
}
 
public function paginate(mixed ...$arguments): Paginator
{
return $this->execute('paginate', $arguments);
}
 
protected function execute(string $method, array $arguments = []): mixed
{
return tap($this->query()->{$method}(...$arguments), fn () => $this->reset());
}
 
private function query(): Builder
{
return $this->query ??= $this->model->newQuery();
}
 
private function reset(): void
{
$this->query = null;
}
}

Each repository then adds its own domain vocabulary, and callers write something like this.

$posts = $this->posts
->fields(['id', 'title', 'COUNT(comments.id) AS comment_count'])
->forUser($userId)
->withCategories($categoryIds)
->paginate(15);

Look at that carefully. Field selection, fluent chaining, deferred execution, get(), count(), paginate(). It is Eloquent's query builder, re-implemented inside your application, by you, with a fraction of the features and none of Laravel's test suite behind it. You have written an ORM. 🎉 Nobody sat down on Monday morning intending to write an ORM, and yet here one is, growing quietly in the corner of the codebase and requiring maintenance forever.

The specific problems are worth naming, because none of them are hypothetical.

  • You re-add Eloquent one method at a time. Eager loading, whereHas, chunkById, cursor, lazy, upsert, withTrashed. None of them exist until somebody needs one badly enough to put it on the interface, write the implementation, and test behavior that Laravel already tested. A mature version of this pattern will have most of them by now. That is not the pattern succeeding. That is the pattern converging on the thing it replaced, one pull request at a time.

  • It is stateful, and that state is a bug waiting to happen. The builder is held on the instance, so every terminal method has to remember to reset it. Repositories are usually resolved once and passed around, so one forgotten reset means the next caller inherits the previous one's constraints. That failure mode does not throw an exception. It quietly returns wrong data, which is the worst kind of bug, because it does not page you at 3am. It waits for a customer to notice a number that looks slightly off and mention it four months later.

  • Raw SQL still leaks. That regular expression exists because callers need to select things like COUNT(comments.id) AS comment_count, and there is no way to express that in the abstract vocabulary. The raw string travels straight through the interface untouched. The leak moved, it did not close.

  • The class names admit the truth. Codebases that go down this road end up with MySqlOrderRepository sitting next to ElasticSearchOrderRepository, and callers choose between them. If the abstraction held, the caller would not need to know.

  • Nobody outside your team can read it. Eloquent's builder is documented, taught in every Laravel course, and has a decade of answers behind it. Your builder has a trait and whatever is in the pull request description.

The Portability Promise Does Not Survive Contact

The reason usually given for accepting all of that is portability. We might move to Postgres. We might move to a document store. When that day comes we swap one implementation for another, and the rest of the application never notices.

Take the Postgres case first, because it is the most concrete and by far the most common. Laravel already abstracts SQL grammar. The query builder compiles to dialect-specific SQL through a grammar class per driver, and swapping the driver in your database config is the migration path. where, join, whereIn, having, ordering, pagination, and locking are all translated for you. That is the abstraction, and you already have it.

So what actually breaks on a Postgres migration? The raw SQL. Group-by semantics, JSON_EXTRACT versus ->>, GROUP_CONCAT versus string_agg, case-insensitive LIKE, INSERT ... ON DUPLICATE KEY. A repository fixes none of that. Raw SQL inside a repository is still raw SQL. You relocated the problem into a file that is harder to review, because the fragments now sit in method bodies instead of at the call sites where a reviewer would trip over them.

The document store case is worse. Moving from MySQL to a document or graph store is not a change of grammar, it is a change of data model: embedding instead of joining, denormalizing instead of normalizing, different consistency guarantees. If your repository still returns Eloquent models, your application still thinks in rows and relationships, and the repository bought you nothing at all.

The sharpest version of the counterargument I have heard came from a colleague who pointed out that the thing actually worth abstracting is the query language, not the model. Hydrating Eloquent models from a different store is fine. It is the grammar that hurts. I think that is exactly right, and it is precisely the thing Laravel already does for you.

One honest question tends to settle it: how many Laravel applications have you seen actually swap their datastore? I have been part of a handful of MySQL to Postgres moves. In every one, the repository layer was irrelevant to the difficulty. The raw queries and a small number of genuinely dialect-specific behaviors were the difficulty.

What You Give Up in the Meantime

Portability is a benefit you might collect later. The costs are ones you pay every day. Eloquent is not just a query builder, it is a lifecycle, and a repository sits directly on top of that lifecycle.

  • Model events and observers. Eloquent fires retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, replicating, trashed, and the force-delete pair. Auditing, cache invalidation, search indexing, and slug generation are usually built on them. They fire on the model, so any path that bypasses the model bypasses them too.

  • Global scopes and soft deletes. Tenancy filters and SoftDeletes quietly constrain every query. Your repository either honors them by using Eloquent (which is the coupling you claimed to remove) or it does not, and now those filters silently stop applying.

  • Eager loading decisions. Only the caller knows which relations a given screen renders. If the repository decides, you get N+1 queries on some screens and over-fetching on others. If the caller decides, you have re-exposed with() through your interface and the boundary is gone.

  • Memory-safe iteration. chunkById(), lazy(), and cursor() are how you process a million rows without exhausting memory. A repository that returns a Collection cannot offer them, and returning a builder instead is returning Eloquent.

  • Everything else built on the model. Route model binding, policies, factories, firstOrCreate, upsert, Scout, model broadcasting, Prunable.

You can re-expose each of these on your interface, one at a time, as people need them. But every method you add is an admission that Eloquent's API is your real contract. At the end of that process you have an interface shaped exactly like Eloquent, plus a binding, plus an extra file per model, plus a service provider that exists purely to introduce the two of them to each other. That is cost with no matching benefit.

There is a specific and rather telling way this ends. Eager loading and soft deletes cannot be expressed in a store-agnostic vocabulary, because they are not concepts a document store or a search index shares. So the interface splits in two: a general one for operations any store could support, and a second one, named after Eloquent, for the operations only Eloquent can. Callers that need eager loading depend on the second. The abstraction has now documented its own leak and given it a class name, which is at least honest, but it is no longer an abstraction over storage. It is an Eloquent interface with a smaller Eloquent interface underneath it.

All Queries, or Only Some?

There is one more structural problem, and it has no good answer. A boundary with holes in it is not a boundary, so by definition every query has to go through a repository. Follow that rule and you end up with methods used exactly once in the entire application, wrapped in an interface and bound in a provider purely so the rule holds. Sooner or later somebody writes getUsersForTheSettingsPageDropdown() and everyone quietly agrees not to mention it. 🙈

Break the rule and it is worse. Now some queries live in repositories and others live in controllers, jobs, and models, and anyone reading unfamiliar code has to check several places to find out where a given query lives. You have taken on the ceremony of a boundary and gained the guarantees of nothing.

In practice, teams pick a third option: declare a migration. New code uses the repository layer, old code gets converted when somebody happens to touch it. That is a sensible-sounding plan, and it is genuinely how this goes. The problem is that converting a working query costs real hours and produces nothing a user can see, so it loses every prioritization conversation it ever enters. Forever. The ticket exists. The ticket has a label. The ticket is going to celebrate a birthday. 🎂 Years later a minority of the codebase has moved, both conventions are still live, and anyone opening an unfamiliar file has to work out which era it belongs to before they can safely change it.

That is the part worth internalizing. The transitional state is not a phase you pass through on the way to consistency. For a codebase of any real size, the transitional state is the permanent state. So when you weigh up adopting a pattern like this, the honest comparison is not "our current mess versus a clean repository layer". It is "our current mess versus our current mess plus a second convention alongside it, indefinitely".

None of the three options is a stable resting point, which is a strong signal that the pattern is not carrying its weight.

Making the Trade-Off Explicit

Programmers know the benefits of everything and the trade-offs of nothing.

That line is Rich Hickey's, and it is the most useful thing anybody has said about architecture decisions. Every choice buys something and pays for it somewhere else. The job is not to find an option with no downsides, it is to know the downsides and pick the set you can live with.

Adopting a repository layer is an architectural decision in the real sense. It changes the structure of the system, it touches nearly every file, and it is expensive to reverse. So it deserves a deliberate trade-off analysis rather than a default.

Aspect

Repository over Eloquent

Scopes, builders, queries, actions

Named, reusable queries

Yes, on a dedicated interface

Yes, on the model, a builder, or a query class

Framework features

Only where you re-expose them by hand

All of them, all of the time

Datastore portability

Partial in theory, rarely realized in practice

Whatever Laravel's driver and grammar layer gives you

Cost of one more query

Interface method, implementation, sometimes a fake

A four line scope, or nothing at all

Onboarding

Learn Eloquent, then learn your team's layer on top

Learn Eloquent

Read down the middle column and ask the only two questions that matter. Will the upsides help you ship a successful application? And can you live with the downsides? For most Laravel projects I have worked on, the answers are no and no.

What I Reach For Instead

The alternative is not "put everything in the controller". It is a small set of framework-native tools, applied in order of cost. Start with the cheapest and move up only when you have an actual problem rather than an anticipated one.

Level 0: Nothing At All

// app/Http/Controllers/PostController.php
public function index(): View
{
return view('posts.index', [
'posts' => Post::query()->latest()->paginate(15),
]);
}

One caller, no rule worth naming, no duplication. Extracting this would add a file and remove nothing. Not every query needs a home, a name, and a plaque. Sometimes a query is just fifteen posts, newest first.

Level 1: Query Scopes

The moment a set of constraints has a name in your team's vocabulary, make it a scope. Since Laravel 12 you mark them with the Scope attribute instead of the old scope method prefix, which lets the method stay protected so it cannot be called directly by accident.

// app/Models/Order.php
use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
 
class Order extends Model
{
/**
* Scope a query to orders that have not been paid for yet.
*/
#[Scope]
protected function pending(Builder $query): void
{
$query->whereIn('status', [
OrderStatus::Pending,
OrderStatus::PaymentFailed,
]);
}
 
/**
* Scope a query to orders that are old enough to need a human.
*/
#[Scope]
protected function stale(Builder $query, int $minutes = 15): void
{
$query->where('created_at', '<=', now()->subMinutes($minutes));
}
}
$orders = Order::pending()->stale()->with('customer')->paginate(50);

Four lines each. No interface, no binding, no service provider. More importantly, a scope is a mutation of the builder rather than a wrapper around it, which means it composes with absolutely everything: whereHas, withTrashed, chunkById, when, ordering, pagination. Nothing is off limits, because you never left Eloquent.

Level 2: A Custom Builder When Scopes Crowd the Model

Scopes live on the model, so twenty of them make the model hard to read. That is a fair complaint, and it has a first-party answer: give the model its own builder class.

// app/Models/Builders/OrderBuilder.php
namespace App\Models\Builders;
 
use App\Enums\OrderStatus;
use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;
 
/**
* @extends Builder<Order>
*/
class OrderBuilder extends Builder
{
public function pending(): self
{
return $this->whereIn('status', [
OrderStatus::Pending,
OrderStatus::PaymentFailed,
]);
}
 
public function stale(int $minutes = 15): self
{
return $this->where('created_at', '<=', now()->subMinutes($minutes));
}
 
public function forMerchant(int $merchantId): self
{
return $this->where('merchant_id', $merchantId);
}
}
// app/Models/Order.php
use App\Models\Builders\OrderBuilder;
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
use Illuminate\Database\Eloquent\Model;
 
#[UseEloquentBuilder(OrderBuilder::class)]
class Order extends Model
{
// The scopes moved out. The model is a model again.
}

Now compare that with the chainable repository from earlier. The method names are almost identical, and that is exactly the point. The difference is one keyword: extends. OrderBuilder is an Eloquent builder, so it already does everything Eloquent does, and your methods are vocabulary layered on top. The repository version had to reimplement the same surface from scratch and would never catch up.

If you want evidence that this is the accepted way to add query vocabulary in Laravel rather than just my preference, look at how Spatie built laravel-query-builder. It is one of the most widely installed packages in the ecosystem, its entire job is to add request-driven filtering and sorting on top of Eloquent, and the first thing the documentation tells you is that its builder extends the Eloquent builder so every existing method and macro still works. A package whose whole purpose is adding query features chose to extend rather than wrap. That is the same decision, made by people with far more users to answer to than either of us.

Be careful not to over-apply this one, though. A custom builder solves a single specific problem well: a model carrying so much query vocabulary that it has become hard to read. It is not a home for every query that needs one. Most of the query logic in a real application is not reusable model vocabulary at all, which brings us to the level that does the most work.

Level 3: Query Classes for Use Case Reads

Scopes and builders express constraints that belong to the model. Some queries are not about the model at all, they are about a use case: this dashboard, this export, this nightly job, with these filters, these eager loads, this ordering, this page size. Those deserve a class of their own. Think of it as the action class idea applied to reads.

There is a nice symmetry here that took me a while to notice. The same Fowler catalog that gives us Repository also contains a pattern called Query Object, defined simply as an object that represents a database query. That is all a query class is. So this is not a case of abandoning the enterprise patterns in favour of framework shortcuts. It is a case of picking the pattern from the same book that actually fits the ORM in front of you. Repository is the one that needs Data Mapper. Query Object does not care what your persistence layer looks like, which is precisely why it survives contact with Active Record.

// app/Queries/OrdersNeedingAttentionQuery.php
namespace App\Queries;
 
use App\Models\Builders\OrderBuilder;
use App\Models\Order;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 
final readonly class OrdersNeedingAttentionQuery
{
public function handle(?int $merchantId = null, int $perPage = 50): LengthAwarePaginator
{
return Order::query()
->pending()
->stale()
->with(['customer', 'payment'])
->when(
$merchantId !== null,
fn (OrderBuilder $query) => $query->forMerchant($merchantId),
)
->latest()
->paginate($perPage);
}
}
// app/Http/Controllers/Admin/OrdersNeedingAttentionController.php
final class OrdersNeedingAttentionController
{
public function __invoke(Request $request, OrdersNeedingAttentionQuery $query): View
{
return view('admin.orders.attention', [
'orders' => $query->handle(
merchantId: $request->integer('merchant_id') ?: null,
),
]);
}
}

One public method, named after the business question rather than the table. It is injectable and trivial to test. Note what it does not do: it does not hide Eloquent, and it does not claim to be the only way to query orders. If a caller needs a different shape, handle() can return the builder instead of the results and let the caller finish it.

The rule of thumb I use: create one when the same combination shows up in more than one place, or when the rule matters enough that I want a test named after it. Not before.

This is the level people are usually reaching for when they say they want a repository, and it is worth being clear about why. Go and read the query code in any mature application and count what you find. A small amount of it is genuinely reusable model vocabulary, the kind that belongs in a scope. The overwhelming majority is one screen, one export, one report, one nightly job, each with its own filters, its own eager loads, its own ordering. Those queries have almost nothing in common with each other except the table they happen to touch.

Which is exactly why a repository per model never fits. It groups queries by the table they read from, when the thing they actually share is the use case they serve. Query classes group them the other way around, and that is the whole difference. A repository ends up as a bag of unrelated methods that happen to mention orders. A query class is one question, answered once, with a name a non-developer would recognize.

Level 4: Action Classes for Writes

Reads are only half of it. The genuine problem behind most repository adoption is that models turn into dumping grounds, and moving queries out does not fix that, because queries were never the biggest part. Orchestration is. That belongs in an action.

// app/Actions/CancelOrder.php
namespace App\Actions;
 
use App\Enums\OrderStatus;
use App\Events\OrderCancelled;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
 
final readonly class CancelOrder
{
public function __construct(private RefundPayment $refundPayment) {}
 
public function handle(Order $order, string $reason): Order
{
return DB::transaction(function () use ($order, $reason): Order {
$this->refundPayment->handle($order->payment);
 
$order->update([
'status' => OrderStatus::Cancelled,
'cancellation_reason' => $reason,
'cancelled_at' => now(),
]);
 
OrderCancelled::dispatch($order);
 
return $order;
});
}
}

The model stays a model. The action owns the sequence, the transaction boundary, and the event. When the business rule changes, there is one obvious file to open.

How They Stack

Order::query() // Eloquent
->pending() // your vocabulary
->forMerchant($id) // your vocabulary
->whereHas('customer') // plain Eloquent, right in the middle
->chunkById(500, fn ($orders) => $this->cancelOrder->handleMany($orders));

Those two highlighted lines are the entire argument. Halfway through a chain of your own vocabulary you can drop straight back into plain Eloquent, because you never wrapped it. A repository's whole job is to make that impossible, and that is what an abstraction boundary is for. When the thing behind the boundary is as capable and as central as Eloquent, the boundary costs more than it protects.

There is a reliable tell for when a repository layer has quietly stopped being a boundary. Sooner or later somebody adds a method that accepts a query builder from outside, lets the repository apply its own constraints to it, and hands the same builder back. It gets added for good reasons, by good engineers, because some piece of real work needed it and every alternative was worse. But once that door exists, the boundary is a convention rather than a guarantee, and conventions are enforced by code review instead of by the type system. If you find yourself designing that method, the design is telling you something. Listen to it earlier than I did.

But What About Testing?

The strongest remaining argument for repositories is testing. With an interface you can bind a fake and unit test your logic without touching a database. It sounds good, and it is mostly an illusion.

Think about where query bugs actually live. A missing whereNull('deleted_at'). A join that duplicates rows. An orWhere that needed grouping. A soft-deleted record sneaking into a total. A missing with() turning one page into three hundred queries. A mocked repository cannot fail on any of those, because the mock is not the query. You end up asserting that your test double behaves like your test double. It does. It always will. The suite is green, the coverage report is magnificent, and the bug is in production. ✅

Laravel's answer is better, and faster than people expect: RefreshDatabase, model factories, and a real database. The test then exercises the real SQL.

// tests/Feature/Queries/OrdersNeedingAttentionQueryTest.php
it('only returns stale pending orders for the given merchant', function (): void {
$merchant = Merchant::factory()->create();
 
$needsAttention = Order::factory()->for($merchant)->create([
'status' => OrderStatus::Pending,
'created_at' => now()->subHour(),
]);
 
Order::factory()->for($merchant)->create([
'status' => OrderStatus::Pending,
'created_at' => now(), // too recent
]);
 
Order::factory()->for($merchant)->create([
'status' => OrderStatus::Paid, // wrong status
'created_at' => now()->subHour(),
]);
 
$orders = new OrdersNeedingAttentionQuery()->handle(merchantId: $merchant->id);
 
expect($orders->pluck('id')->all())->toBe([$needsAttention->id]);
});

That test is possible because the query class is not hidden behind an interface. There is nothing to bind and nothing to fake. It names a business rule, sets up the three cases that matter, and asserts against what the database actually returns. If somebody later drops the stale() constraint, this fails.

Keep pure unit tests for pure logic: value objects, calculations, state machines, policies. Test queries against a database. That split has caught far more real bugs for me than any amount of mocking.

A Note on Domain-Driven Design

If you arrived at repositories through DDD rather than through a Laravel tutorial, there is an obvious objection here: DDD asks for repositories, so this whole article looks like it is arguing against a foundation. It is worth checking what the rule actually says. In DDD a repository exists per aggregate root, not per entity and certainly not per table. If Order is your aggregate root and OrderLine lives inside its boundary, there is no OrderLineRepository. You load and save the whole order. Which means an application with UserRepository, PostRepository and CommentRepository sitting side by side is not doing DDD. It is doing one class per table, in DDD vocabulary.

The other half of the rule matters even more. Writes must go through the aggregate root, because that is where invariants and transactional consistency are enforced. Reads are explicitly allowed to bypass it, on the grounds that a query does not change state. What the literature asks instead is that reads be built for the use case in front of you. In Implementing Domain-Driven Design, Vaughn Vernon calls this a use case optimal query: a finder that composes exactly what one use case needs and returns a purpose-designed object rather than a graph of aggregates. Read that description again and it is a query class. DDD does not object to the read side of what I have been proposing. It lands in the same place and gives it a longer name.

The write side is where the real difference sits, and it deserves precision rather than reassurance. An action class is a use case boundary: cancelling an order is one operation, here it is, named and tested. A DDD repository is a consistency boundary: an order and its lines load and save as a unit, and nothing reaches the internals except through the root. Those are not substitutes. In strict DDD you have both, with the action loading the aggregate from the repository and handing it back afterwards. An action gives you the transaction and the name. What it cannot give you is enforcement, because every Eloquent model can persist itself, so nothing stops the next developer skipping your action entirely. That is precisely why the next section ends with Doctrine rather than with a clever workaround.

Worth naming the pattern you end up with, by the way. Commands through actions and reads through query classes is the cheap and genuinely useful half of CQRS, and I would reach for it on any project. The expensive half, separate read and write stores kept in step by projections, is a much larger bill and a different article.

When a Repository Is the Right Call

None of this makes the pattern wrong. It makes it context dependent, so here is where I would still use it.

  • The store is not Eloquent. Wrapping a third-party HTTP API, an Elasticsearch index, or a document store in a repository-shaped interface is perfectly sensible. There is no Active Record to fight, and you genuinely have more than one implementation, including a fake for tests.

  • The seam is a feature, not a model. This is the one repository advantage I think is real: you do not need one per model. If a subdomain touches six tables and five hundred lines of query code, a single IssueTrackerRepository can be a reasonable home where six model files would scatter it. Query classes give me most of the same benefit with less ceremony, but the point stands.

  • You are strangling legacy code. A repository is a decent place to hide something you intend to delete, precisely because it is a boundary.

  • Persistence ignorance is a hard requirement. If your domain model genuinely must not know about the database, commit to it properly and use a real Data Mapper such as Doctrine with plain PHP objects. Repository over Active Record gives you the ceremony of Data Mapper and the guarantees of neither.

Wrapping Up

Most of the time, absurd architecture comes from good intentions. Somebody finishes a design patterns book, gets enthusiastic, and starts looking for places to apply what they learned rather than asking which problem each pattern was built to solve. We have all had that fortnight. Everything is a Strategy, your CRUD app grows an Abstract Factory, and a perfectly innocent invoice total ends up behind three interfaces. Repository over Eloquent is the most common and most durable example of that in the Laravel world.

The other trap is thinking too far ahead. We picture the version of the application with hundreds of thousands of users and three datastores, and we build for that today. It leads us away from the defaults and into complexity long before we see any return, and often the imagined future never arrives at all.

So my approach is to not overthink architecture. Start with Laravel's conventions, and adopt an extra pattern when it solves a problem the project actually has. Note the word: not overthink, not "do not think". Deviating from the defaults is fine, and sometimes necessary. Rushing into it because a pattern might pay off in a scenario years away is not.

Repositories over Eloquent are, for me, the clearest case of that mistake. They cost velocity and framework features every single day in exchange for a portability benefit that almost never gets collected, and that turns out not to have needed them when it is. Scopes, custom builders, query classes, and actions give me the same organization, the same testability, and the same named vocabulary, while leaving the framework intact underneath.

Do your own trade-off analysis rather than taking mine. Just make sure it is an analysis and not a habit.