Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Repository Pattern Anti-Patterns to Avoid

By Kokil Thapa | Last reviewed: September 2026

Repository Pattern Anti-Patterns to Avoid show up on almost every mid-size Laravel codebase I audit. Teams add interfaces, bind them in the service container, and call it clean architecture. Six months later, controllers still depend on concrete query shapes, repositories grow to 800 lines, and nobody can explain what problem the layer solves. If you already read our Laravel repository pattern guide with real code, this article covers the mistakes that turn that pattern into dead weight.

What is the repository pattern, and when should you skip it?

The repository pattern hides data access behind an interface. Callers ask for aggregates; the implementation decides whether that data lives in MySQL, Redis, or an external API. That separation helps when storage might change or when domain logic should not know about SQL.

In Laravel 13 with PHP 8.3+, Eloquent is already an active-record mapper with relationships, scopes, and casting. A repository on top of Eloquent is not automatically wrong. It is wrong when it duplicates Eloquent without adding a boundary you actually need.

Skip repositories when:

  • Your app is a CRUD admin panel with one MySQL database and no planned storage swap.
  • Complex reads are better handled with Eloquent query patterns for large datasets and local scopes.
  • Business rules belong in actions, services, or domain objects—not in 40 nearly identical repository methods.
  • Your team cannot articulate what the interface protects them from.

Keep repositories when you have multiple persistence backends, heavy test doubles for non-database collaborators, or a bounded context where the domain must stay ignorant of Eloquent entirely. On legal-tech portals and booking systems I have maintained, repositories earned their place only where document storage, audit logs, and relational data crossed boundaries.

Repository or Eloquent?Will storage change?NoYesUse Eloquentscopes + actionsRepositorywith clear interfaceDomain needs isolation?Actions sufficeRepository fits
Decision tree: Repository Pattern Anti-Patterns to Avoid often start by adding a layer you never needed.

What are the most common repository pattern anti-patterns in Laravel?

Most failures are structural, not syntactic. The code compiles, tests pass, and the layer still makes every feature slower to ship. These are the anti-patterns I see repeatedly on production Laravel applications and during enterprise application development reviews.

1. The Eloquent passthrough wrapper

This repository adds an interface and a class that only forwards calls to the model:

interface BookingRepositoryInterface
{
    public function find(int $id): ?Booking;
    public function create(array $data): Booking;
}

class EloquentBookingRepository implements BookingRepositoryInterface
{
    public function find(int $id): ?Booking
    {
        return Booking::find($id);
    }

    public function create(array $data): Booking
    {
        return Booking::create($data);
    }
}

You now maintain two files for zero abstraction. Controllers still depend on Eloquent-shaped arrays. Tests still hit the database unless you mock the interface—which brings us to the next anti-pattern.

2. God repositories

One class owns every query for an aggregate: filtering, sorting, exports, dashboard stats, soft-delete restores, and admin overrides. The file becomes a junk drawer. Business rules leak in because the repository is the only place developers know to put SQL.

Split by use case instead. Read models, write actions, and query objects each own one job. On a booking platform like Adventure Third Pole Trek, availability search and payment capture should not share a 600-line repository.

3. Leaky query builders

Methods that return Builder or accept raw column names defeat the purpose of the boundary:

public function query(): Builder
{
    return Booking::query();
}

public function whereStatus(string $status): self
{
    $this->query->where('status', $status);
    return $this;
}

Callers now write Eloquent-flavoured code through your repository. You cannot swap persistence without breaking every consumer. The interface lied about encapsulation.

4. Mock hell in tests

Teams mock BookingRepositoryInterface in every controller test. Each test stubs twelve methods. Refactoring the repository breaks dozens of unrelated tests. Prefer testing actions and integration tests for real persistence paths. Use testing and optimization budget on behaviour, not interface theatre.

5. Generic CRUD base repositories

An abstract BaseRepository with all(), paginate(), and deleteById() encourages lazy data access. Domain-specific queries get squeezed into generic method names. Inheritance hides coupling across unrelated models.

Anti-Pattern StackCleaner StackControllerGod RepositoryLeaky BuilderEloquent ModelControllerAction / ServiceQuery ObjectEloquent ModelHidden SQL everywhereExplicit use casesRepository Pattern Anti-Patterns to Avoid: thin wrappers add layers, not clarity
God repositories and leaky builders versus action-driven Laravel layers with focused query objects.
Anti-patternSymptomBetter alternative
Eloquent passthroughInterface mirrors model 1:1Eloquent scopes, model methods, or actions
God repositoryOne class, 20+ unrelated methodsQuery objects per screen or report
Leaky builderquery() exposed to callersNamed methods returning DTOs or collections
Mock-heavy testsEvery controller stubs repositoryFeature tests + action unit tests
Base CRUD repoGeneric all() used everywherePurpose-built read/write classes

How do you refactor a bloated repository into maintainable code?

Refactoring does not mean deleting every repository on day one. It means moving code to the layer that can explain it. I use this sequence on client codebases where custom software development teams inherited a repository layer they did not design.

  1. Inventory public methods. Group them by controller, job, or report. Unused methods get deleted first.
  2. Extract query objects. Move complex reads into dedicated classes under App\Queries. Each class exposes one entry point, such as execute().
  3. Move writes to actions. Create-booking, cancel-booking, and refund-booking become invokable actions with typed inputs.
  4. Shrink the interface. If only two callers remain, inline the repository or replace it with a narrow port for external storage.
  5. Run integration tests. Confirm behaviour before renaming bindings in the service container.

Example query object replacing a repository search method:

final class AvailableTreksQuery
{
    public function __construct(
        private readonly int $seasonId,
        private readonly int $limit = 20,
    ) {}

    public function execute(): Collection
    {
        return Trek::query()
            ->where('season_id', $this->seasonId)
            ->where('status', 'open')
            ->with(['guide:id,name', 'pricing'])
            ->orderByDesc('starts_at')
            ->limit($this->limit)
            ->get();
    }
}

The controller resolves the query object or receives it via method injection. No fluent repository state, no hidden mutable builder. Paste the result into a JSON formatter when debugging API responses during the refactor.

Refactor PathGod Repo800 linesInventorygroup methodsExtractqueriesActionstyped writesNarrow Interface RemainsOnly for true storage boundariesQuery Objectsread screensDomain Actionsbusiness rulesRepository Pattern Anti-Patterns to Avoid: delete layers that no longer earn their keep
Step-by-step refactor from monolithic repository classes to query objects and domain actions.

When does Eloquent replace the need for a repository?

Eloquent in Laravel 13 is not a dumb ORM. Models support casts, scopes, observers, and Attribute objects. For many apps, that is enough structure without a repository folder.

Use Eloquent directly when reads are model-centric and writes are short. Add local scopes for repeated filters. Push validation into Form Request validation patterns. Put multi-step workflows in actions.

Repositories still help when:

  • You persist the same domain entity to MySQL and a search index.
  • You integrate a legacy stored-procedure database alongside Eloquent.
  • You share persistence logic between Laravel and a Lumen microservice.
  • You implement CQRS in Laravel with separate read and write stores.

The official Laravel Eloquent documentation documents relationships, eager loading, and scopes—the tools that remove most passthrough repositories. Martin Fowler's repository pattern catalog entry stresses mediating between domain and mapping layers. If your domain is the Eloquent model itself, mediation adds little.

On document-heavy portals such as Mijar Law Associates, I kept repositories only around file and audit storage. Everything else used Eloquent and actions. That split matched real boundaries instead of architectural fashion.

How do you test data access without repository mock hell?

Testing strategy follows boundary honesty. If the repository is a thin wrapper, mocking it tests nothing about SQL correctness. If it encapsulates a real port, test the adapter against a database.

Prefer feature and integration tests for queries

Use an in-memory SQLite or a dedicated MySQL test schema. Seed factories, hit the endpoint or action, assert JSON or database state. This catches bad joins that mocks never see.

Unit-test actions with faked ports

When a repository wraps external storage, mock the interface at the action boundary—not in every controller test:

it('stores signed contract PDF', function () {
    Storage::fake('s3');

    $action = app(StoreContractAction::class);
    $action->execute($dto);

    Storage::disk('s3')->assertExists('contracts/2026/agreement.pdf');
});

Watch N+1 and memory during tests

Repository layers sometimes hide eager-load omissions. Enable query detection in tests or use Debugbar locally. Pair this with guidance on PHP memory limits and leak patterns when large result sets appear.

For cache-backed reads, document invalidation beside the query object. See Redis caching patterns for web apps for TTL and tag strategies that belong outside repositories.

Testing FocusMock-HeavyStub 12 repo methodsper controller testBrittle, low SQL confidenceIntegration-FirstSeed DB, run actionAssert output + rowsCatches real query bugsMock only true external portsS3 / API adaptersPayment gateways
Repository Pattern Anti-Patterns to Avoid include mock-heavy controller tests—integration tests catch SQL mistakes mocks miss.

Key Takeaways

  • Do not add a repository unless storage or domain isolation truly requires one—Eloquent scopes and actions cover most Laravel apps.
  • Treat passthrough wrappers, god repositories, and leaky builders as Repository Pattern Anti-Patterns to Avoid on sight.
  • Refactor by extracting query objects and invokable actions; delete methods that only forward to the model.
  • Mock external ports at action boundaries; use database integration tests for SQL correctness.
  • Align layer choices with real boundaries—documents, search indexes, and legacy DBs—not textbook diagrams.
  • Revisit bindings after Laravel upgrades; Laravel 12 is supported to February 2027, and Laravel 13 needs PHP 8.3+.

People Also Ask

Is the repository pattern dead in Laravel?

No, but it is overused. Laravel teams often adopt it because a tutorial said so, not because they swap databases weekly. For standard MySQL CRUD, Eloquent with actions is simpler and easier to hire for. Repositories remain valid for genuine persistence boundaries.

Should repositories return models or DTOs?

Returning Eloquent models from repositories leaks active-record behaviour to callers. For read-heavy screens, map to arrays or readonly DTOs inside query objects. For write paths, return identifiers or lightweight value objects unless the caller truly needs the persisted model.

How big should a repository be?

If you cannot describe the class in one sentence, it is too big. A healthy repository—or query object—owns one use case: "find overdue invoices for dunning" not "everything about invoices." Split when method count crosses roughly eight unrelated entry points.

Does Symfony need repositories more than Laravel?

Symfony projects often use Doctrine entities without active-record helpers, so repositories feel natural. Laravel's Eloquent already centralises data access. Compare approaches in Symfony real-world patterns when you maintain both stacks.

Ship architecture that matches the problem

Repository Pattern Anti-Patterns to Avoid are expensive because they look professional while slowing every feature. Start with the simplest layer that keeps business rules testable. Add repositories only where storage or domain isolation demands them. If your codebase already carries god repositories and mock-heavy tests, refactor incrementally—query objects and actions beat a big-bang rewrite.

Need a second pair of eyes on a Laravel 13 codebase, API layer, or legacy refactor? Review our portfolio of production applications, read advanced Eloquent techniques, or explore API development services and ongoing support. When you are ready to untangle persistence layers on a live project, contact us for a practical architecture review.

Frequently Asked Questions

The most common ones are Eloquent passthrough wrappers that mirror the model one-to-one, god repositories that accumulate unrelated queries and business rules, leaky query builders that expose Builder to callers, mock-heavy controller tests that stub a dozen methods, and generic CRUD base repositories with lazy all() and paginate() usage. They compile and pass tests but add files without real abstraction, slow feature delivery, and hide SQL problems behind interface theatre.

Skip it when your app is a CRUD admin panel on one MySQL database with no planned storage swap, complex reads are better served by Eloquent scopes and query patterns, business rules belong in actions or services rather than repository methods, and your team cannot explain what the interface protects them from. In those cases Eloquent with local scopes, Form Requests, and invokable actions usually gives clearer structure than an extra folder of passthrough classes.

This is a repository interface and class that only forwards find, create, update, and delete to the Eloquent model without changing the data shape callers receive. You maintain two files for zero abstraction gain. Controllers still depend on Eloquent-shaped arrays and relationships. Tests still hit the database unless you mock the interface, which often leads to mock-heavy test suites that break on every repository refactor without proving SQL correctness.

A god repository is one class that owns every query for an aggregate: filtering, sorting, exports, dashboard stats, soft-delete restores, and admin overrides. The file becomes a junk drawer where business rules leak in because developers treat it as the only place for SQL. On a booking platform, availability search and payment capture should not share a 600-line repository. Split by use case with read models, write actions, and focused query objects instead.

Leaky builders appear when repository methods return Eloquent Builder instances or accept raw column names, for example a query() method that hands callers the model query builder. Consumers then write Eloquent-flavoured code through your repository boundary. You cannot swap persistence or change query internals without breaking every caller. The interface promised encapsulation but actually exposed the ORM. Named methods returning DTOs, arrays, or collections keep the boundary honest.

Start by inventorying public methods and grouping them by controller, job, or report; delete unused ones first. Extract complex reads into query objects under App\Queries with a single execute entry point. Move writes to invokable actions such as create-booking or cancel-booking with typed inputs. Shrink the interface and inline or replace narrow ports where only one or two callers remain. Run integration tests before changing service container bindings so behaviour stays stable during the refactor.

Eloquent in Laravel 13 already supports casts, scopes, observers, and Attribute objects. Use it directly when reads are model-centric, writes are short, repeated filters live in local scopes, validation sits in Form Requests, and multi-step workflows run through actions. Repositories still earn their place when you persist to MySQL and a search index, integrate legacy stored procedures, share persistence between Laravel and Lumen, or implement CQRS with separate read and write stores.

No, but it is overused. Teams often add it because a tutorial recommended it, not because they swap databases weekly.

Returning Eloquent models from repositories leaks active-record behaviour to callers who may trigger lazy loads or persistence side effects unexpectedly. For read-heavy screens, map results to arrays or readonly DTOs inside dedicated query objects so the consumer receives only the fields that screen needs. For write paths, return identifiers or lightweight value objects unless the caller genuinely requires the persisted model instance for a tightly scoped follow-up operation.

If you cannot describe the class in one sentence, it is too big. Split when unrelated method count crosses roughly eight entry points.

Prefer feature and integration tests against an in-memory SQLite or dedicated MySQL test schema. Seed factories, hit the endpoint or action, and assert JSON or database state so bad joins surface. Unit-test actions with faked ports only when the repository wraps external storage such as S3, mocking at the action boundary rather than in every controller test. Enable query detection or use Debugbar locally to catch N+1 problems that repository layers sometimes hide from mocked tests.

Mock hell happens when teams mock BookingRepositoryInterface in every controller test and stub a dozen methods per case. Refactoring the repository then breaks dozens of unrelated tests because assertions target stubbed method calls, not actual SQL behaviour. The tests pass while join errors and missing eager loads slip into production. Replace this with integration tests for persistence paths and unit tests on actions where external ports are genuinely faked at a single boundary.

An abstract BaseRepository with all(), paginate(), and deleteById() encourages lazy, undifferentiated data access across unrelated models. Domain-specific queries get forced into generic method names that hide intent. Inheritance couples unrelated aggregates to the same base behaviour. Developers reach for all() instead of writing a purpose-built query object for the screen or report they are shipping. God repositories and leaky builders often grow out of these generic starting points.

Keep repositories when you have multiple persistence backends, need heavy test doubles for non-database collaborators, or run a bounded context where domain logic must stay ignorant of Eloquent entirely. On legal-tech portals and booking systems I have maintained, repositories justified themselves where document storage, audit logs, and relational data crossed real boundaries. The pattern works when storage might change or when domain logic should not know about SQL, not as default scaffolding for standard MySQL CRUD.

Symfony projects often use Doctrine entities without active-record helpers, so repository classes feel like a natural fit for data access. Laravel Eloquent already centralises relationships, eager loading, scopes, and casting in the model layer, which removes most passthrough mediation. Ship architecture that matches the problem on each stack rather than copying Symfony layering onto Laravel by default. Compare both approaches when you maintain applications on both frameworks and align layers with actual persistence boundaries.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: