The Repository pattern has created more Laravel boilerplate than useful isolation. A UserRepositoryInterface with find, all, create, and update looks architectural. Usually it is simply a second, less capable spelling of Eloquent. The controller still knows it needs users, the application still stores them in MySQL, and every query now needs a method in two places.
That is not an argument against repositories. It is an argument for using them only at a real boundary. Eloquent is an excellent Active Record implementation: it already owns persistence, relationships, casts, pagination, factories, and query composition. Replacing it with generic CRUD does not make an application independent of persistence. It makes its persistence API harder to use.
This article gives a decision rule, then builds a boundary that earns its maintenance cost: exchange-rate lookup backed by a provider, a cache, and a local alternative. The same shape works for catalogues, read models assembled from multiple systems, and vendor SDKs that domain code should not know about.
Begin with the query Eloquent already answers
An ordinary application query does not need a repository merely because it appears outside a model. Keep it close to the use case and name the intent. This invoice screen needs ten recent overdue invoices for an account:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Invoice;
use Inertia\Inertia;
use Inertia\Response;
final class AccountInvoicesController
{
public function __invoke(Account $account): Response
{
$invoices = Invoice::query()
->whereBelongsTo($account)
->where('status', 'overdue')
->latest('due_date')
->limit(10)
->get(['id', 'number', 'due_date', 'total_cents']);
return Inertia::render('accounts/invoices', [
'account' => $account->only(['id', 'name']),
'invoices' => $invoices,
]);
}
}
There is no leak to repair. The use case is explicitly a database query over Invoice, and Eloquent is part of this application model. A local scope improves reuse without inventing another data-access layer:
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class Invoice extends Model
{
public function scopeOverdueFor(Builder $query, Account $account): void
{
$query
->whereBelongsTo($account)
->where('status', 'overdue')
->orderByDesc('due_date');
}
}
The controller can now use Invoice::query()->overdueFor($account). That is enough when the consumer deliberately uses Eloquent. Do not add InvoiceRepository::overdueFor() merely to make a mock possible. Laravel factories and a test database can exercise the real query, often more usefully than proving a mock received a method name.
The repository smell: repeating Eloquent
This familiar interface has almost no independent meaning:
interface UserRepositoryInterface
{
public function find(int $id): ?User;
public function all(): Collection;
public function create(array $attributes): User;
public function update(User $user, array $attributes): User;
}
It creates questions Eloquent already settled. Does all() paginate? Can callers eager-load relations? What happens with soft-deleted users, transactions, locks, chunking, firstOrCreate, or a new condition? Either the interface grows until it mirrors Builder, or callers demand an escape hatch. Both show that the boundary is false.
The most damaging escape hatch returns a builder:
public function query(): Builder;
Once callers chain where, with, and paginate, they know Eloquent's API, database semantics, and table-oriented vocabulary. Changing implementation would break all callers. A builder is fine in a deliberately Eloquent-specific query service, but it is not an abstraction over persistence. Call that service OverdueInvoiceQuery and keep its purpose clear.
A boundary that is real: exchange rates
An order-pricing action wants a rate from EUR to PLN; it should not know whether it came from a paid provider, a local table, or a retained snapshot. Its application vocabulary is narrow and stable:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
interface ExchangeRateProvider
{
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate;
}
The value object keeps provider JSON and floating-point decisions outside the rest of the application:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
final readonly class ExchangeRate
{
public function __construct(
public string $base,
public string $quote,
public string $rate,
public DateTimeImmutable $publishedAt,
) {
}
public function convertCents(int $amountCents): int
{
return (int) round($amountCents * (float) $this->rate);
}
}
The contract has no find, save, model, builder, or generic array. It promises the business operation the caller needs. A provider implementation maps HTTP at one point:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Rates;
use App\Billing\ExchangeRate;
use App\Billing\ExchangeRateProvider;
use DateTimeImmutable;
use Illuminate\Http\Client\Factory as Http;
use RuntimeException;
final readonly class FrankfurterExchangeRateProvider implements ExchangeRateProvider
{
public function __construct(private Http $http)
{
}
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate
{
$response = $this->http->baseUrl('https://api.frankfurter.dev/v1')
->get($at->format('Y-m-d'), ['base' => $base, 'symbols' => $quote])
->throw();
$rate = $response->json("rates.{$quote}");
if (! is_numeric($rate)) {
throw new RuntimeException("No {$base}/{$quote} rate is available.");
}
return new ExchangeRate(
base: $base,
quote: $quote,
rate: (string) $rate,
publishedAt: new DateTimeImmutable($response->json('date')),
);
}
}
The SDK or HTTP client is now infrastructure. If the provider changes its response schema, one adapter changes. If compliance requires a different provider, order pricing stays unchanged.
Compose behaviour around the boundary
Caching belongs around this contract, not inside a controller or duplicated in every provider. A decorator adds it without expanding the interface:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
use Illuminate\Contracts\Cache\Repository as Cache;
final readonly class CachedExchangeRateProvider implements ExchangeRateProvider
{
public function __construct(
private ExchangeRateProvider $provider,
private Cache $cache,
) {
}
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate
{
$key = sprintf('exchange-rate:%s:%s:%s', $base, $quote, $at->format('Y-m-d'));
return $this->cache->remember($key, now()->addHour(), fn (): ExchangeRate =>
$this->provider->rateFor($base, $quote, $at),
);
}
}
Bind the application contract in one provider. This is the sole place that chooses infrastructure:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Billing\CachedExchangeRateProvider;
use App\Billing\ExchangeRateProvider;
use App\Infrastructure\Rates\FrankfurterExchangeRateProvider;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Http\Client\Factory as Http;
use Illuminate\Support\ServiceProvider;
final class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ExchangeRateProvider::class, function ($app): ExchangeRateProvider {
return new CachedExchangeRateProvider(
provider: new FrankfurterExchangeRateProvider($app->make(Http::class)),
cache: $app->make(Cache::class),
);
});
}
}
The use case depends only on that contract and keeps rounding policy visible:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
final readonly class ConvertOrderTotal
{
public function __construct(private ExchangeRateProvider $rates)
{
}
public function handle(int $totalCents, string $from, string $to, DateTimeImmutable $at): int
{
return $this->rates->rateFor($from, $to, $at)->convertCents($totalCents);
}
}
Test the contract at the right level
The use case is fast to test with a small fake. It is not a mock of HTTP, cache, or Eloquent; it implements the application contract:
<?php
declare(strict_types=1);
use App\Billing\ConvertOrderTotal;
use App\Billing\ExchangeRate;
use App\Billing\ExchangeRateProvider;
use DateTimeImmutable;
it('converts an order total using the selected rate', function (): void {
$rates = new class implements ExchangeRateProvider {
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate
{
return new ExchangeRate($base, $quote, '4.5000', $at);
}
};
$total = (new ConvertOrderTotal($rates))->handle(
totalCents: 10_00,
from: 'EUR',
to: 'PLN',
at: new DateTimeImmutable('2026-01-15'),
);
expect($total)->toBe(45_00);
});
Test the provider adapter at its boundary with Http::fake() and the cache decorator with Laravel's cache store. Those tests protect mapping and expiry rules. They do not pretend a generic repository made external data disappear.
Where a repository is worth it
A repository or provider pays for itself when it gives the application one coherent source of a capability:
- A read model combines MySQL orders, a search index, and an entitlement API into
CustomerDashboard. Return a purpose-built DTO, not three builders. - A catalogue gateway hides a vendor SDK whose objects, pagination tokens, and exceptions should not reach checkout code.
- A port lets a package or domain layer run without Eloquent. Use domain value objects and specific operations such as
reserve(Sku $sku, Quantity $quantity). - A cacheable reference lookup centralizes keys, invalidation, and fallback. Callers ask for a tax rule or rate, not a cache key.
There is a credible second implementation or a volatile detail whose change would otherwise reach many use cases. That is the test—not the number of model calls in a controller.
Pitfalls, alternatives, and when not to use one
Do not make a repository a dumping ground. A ProductRepository that fetches products, writes products, calculates stock, calls a supplier, and invalidates cache has several reasons to change. Split capabilities into InventoryGateway, ProductCatalogue, and an action that owns the workflow.
Do not use a repository to hide transaction ownership. A service calling save() three times needs an explicit transaction at the use-case boundary; hiding each save behind a repository method does not make the workflow atomic. Nor should every Eloquent relationship travel through a repository: hiding eager-loading requirements often produces N+1 queries.
Use a simpler alternative when it fits: an Eloquent scope for a reusable model constraint; a query object for a complex query that should retain Eloquent semantics; an action/service for orchestration, validation, transactions, and side effects; or an HTTP-client wrapper/SDK adapter for one external provider.
Do not add a repository because “clean architecture” requires it, because unit tests must mock the database, or because every model supposedly needs an interface. Those rules make abstraction before evidence. Start with direct Eloquent, watch for repeated infrastructure knowledge, and introduce a narrow contract only when a concrete boundary appears.
The honest decision test
Before adding an interface, ask: does the caller need a business capability rather than database operations? Could a second implementation plausibly exist? Would changing the store or provider otherwise touch many use cases? Can the contract avoid Eloquent models, builders, provider DTOs, and generic arrays?
If most answers are yes, a repository-shaped port will pay for itself. If not, write the clear Eloquent query. The goal is not to abstract every dependency. It is to make the genuinely volatile ones boring to change.