
September 08, 2026
10 min read
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.
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 | Symptom | Better alternative |
|---|---|---|
| Eloquent passthrough | Interface mirrors model 1:1 | Eloquent scopes, model methods, or actions |
| God repository | One class, 20+ unrelated methods | Query objects per screen or report |
| Leaky builder | query() exposed to callers | Named methods returning DTOs or collections |
| Mock-heavy tests | Every controller stubs repository | Feature tests + action unit tests |
| Base CRUD repo | Generic all() used everywhere | Purpose-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.
- Inventory public methods. Group them by controller, job, or report. Unused methods get deleted first.
- Extract query objects. Move complex reads into dedicated classes under
App\Queries. Each class exposes one entry point, such asexecute(). - Move writes to actions. Create-booking, cancel-booking, and refund-booking become invokable actions with typed inputs.
- Shrink the interface. If only two callers remain, inline the repository or replace it with a narrow port for external storage.
- 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.
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.
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
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.

