This interface appears in many Laravel codebases:
interface Repository
{
public function all(): Collection;
public function find(int $id): ?Model;
public function create(array $attributes): Model;
public function update(int $id, array $attributes): Model;
public function delete(int $id): bool;
}It hides the word Eloquent and preserves everything else about Eloquent’s world: models, attribute arrays, record IDs, and CRUD operations. Callers still need to know which fields are writable, which relationships are loaded, and which operations preserve domain rules.
That is not a useful persistence boundary. It is a smaller, less capable ORM.
A repository should make persistent domain objects feel like a collection owned by the domain. Its methods should reveal which aggregates can be loaded and saved for actual use cases—not expose a database toolkit through a custom interface.
Start from the aggregate lifecycle
An order application may need this boundary:
interface Orders
{
public function nextIdentity(): OrderId;
public function get(OrderId $id): Order;
public function getForUpdate(OrderId $id): Order;
public function save(Order $order): void;
}The plural name is deliberate. Orders represents the conceptual collection
of order aggregates. The application asks it for one aggregate, changes that
aggregate through domain behavior, and saves the result.
final readonly class CancelOrderHandler
{
public function __construct(private Orders $orders) {}
public function handle(CancelOrder $command): void
{
DB::transaction(function () use ($command): void {
$order = $this->orders->getForUpdate($command->orderId);
$order->cancel($command->reason);
$this->orders->save($order);
});
}
}The interface says that cancellation needs an existing order and a locking
load. It does not let the handler bypass cancel() with an attribute array.
The transaction surrounds load, decision, and save because the lock is useful
only while that complete operation remains open. Laravel’s
lockForUpdate() guidance likewise places the lock inside a
transaction.
Fowler’s Repository pattern describes a mediator between
domain and data mapping with a collection-like interface. The collection
metaphor does not mean reproducing every operation supported by SQL. An
in-memory collection of domain objects also would not accept whereRaw() or
mass-assignment arrays.
Generic CRUD erases the important differences
Not every aggregate has the same lifecycle:
- orders may be created, placed, cancelled, and retained permanently;
- invitations may expire and be revoked;
- ledger entries may be append-only;
- inventory reservations may be acquired and released under locks; and
- projections may be rebuilt rather than individually updated.
Forcing them through create, update, and delete makes illegal operations
look available. A LedgerEntryRepository::update() method is misleading even
if its implementation throws.
Purposeful interfaces make absence and failure semantics explicit:
interface Invitations
{
public function add(Invitation $invitation): void;
public function findPending(Token $token): ?Invitation;
public function save(Invitation $invitation): void;
}findPending() returns null because “no usable invitation” is an expected
lookup result. Orders::get() can throw OrderNotFound because the command
requires an existing aggregate. One generic find() cannot communicate both
contracts.
Queries do not have to pass through repositories
The repository protects aggregate persistence. A dashboard needs shaped data, pagination, sorting, joins, and aggregates:
interface FindOrdersForCustomer
{
public function matching(
CustomerId $customerId,
OrderFilters $filters,
Page $page,
): OrderPage;
}Its Eloquent or query-builder implementation can select exactly what the view needs:
return OrderRecord::query()
->where('customer_id', $customerId->value)
->when($filters->status, fn (Builder $query, OrderStatus $status) =>
$query->where('status', $status->value)
)
->latest('placed_at')
->paginate($page->size)
->through(OrderListItem::fromRecord(...));Loading aggregate roots and mapping them into list rows would be slower and less honest. The read use case does not intend to change orders, so it does not need the write model’s collection abstraction.
Laravel’s query builder, Eloquent scopes, and API Resources are already useful read tools. A repository should not ban them from the application merely to claim database independence. Isolate a query when it has a stable use-case contract, repeated business meaning, or a technology boundary that matters.
“We may replace MySQL” is weak evidence
A repository does not make persistence portable by itself. SQL behavior leaks
through transaction isolation, unique constraints, locking, collation,
generated values, and query capabilities. Replacing MySQL with an HTTP service
changes consistency and failure semantics even if both implement find().
The stronger reason for a repository is dependency direction: domain and application code can express aggregate operations without depending on Eloquent records or table structure. That helps when persistence concerns are already distorting the model, whether or not the database vendor ever changes.
Keeping Eloquent directly in a small CRUD application is a credible choice. Eloquent already provides mapping, querying, relationships, and lifecycle hooks. Adding one interface per model creates indirection without a separate domain model or contract to protect.
Introduce a repository when there is an aggregate boundary, meaningful load or save semantics, repeated mapping logic, or a dependency boundary worth testing. Do not add it because an architecture diagram has a box labelled “data access.”
The implementation owns mapping and persistence mechanics
A repository implementation can use Eloquent records while returning domain objects:
final readonly class EloquentOrders implements Orders
{
public function getForUpdate(OrderId $id): Order
{
$record = OrderRecord::query()
->with('lines')
->lockForUpdate()
->find($id->value);
if ($record === null) {
throw OrderNotFound::withId($id);
}
return OrderMapper::toDomain($record);
}
public function save(Order $order): void
{
OrderMapper::persist($order);
}
}The mapping must preserve identity, child removal, optimistic versions,
database constraints, and transaction expectations. Those are the details the
boundary owns. Hiding them behind save() does not make them optional.
Reconstitution is part of that ownership. Loading a stored order should not call the factory used to place a new one: that could apply today’s creation policy to historical state or record creation events again. Give the mapper a path that says what it is doing:
final class OrderMapper
{
public static function toDomain(OrderRecord $record): Order
{
return Order::reconstitute(
id: OrderId::fromString($record->id),
status: OrderStatus::from($record->status),
lines: $record->lines->map(self::lineToDomain(...))->all(),
version: Version::fromInt($record->version),
);
}
}This is not permission to construct an impossible aggregate. Reconstitution can still reject a missing identity, an unknown status, or malformed child state. The distinction is narrower: creation decides whether a new transition is allowed now; reconstitution rebuilds state that was accepted earlier without pretending the transition just happened. Invalid stored state should fail visibly or be migrated, not be silently repaired by the mapper.
If the domain object itself extends Eloquent, the repository can still centralize purpose-specific loading and locking. The mapping disappears, but the aggregate contract remains:
final class EloquentOrders implements Orders
{
public function getForUpdate(OrderId $id): Order
{
$order = Order::query()
->with('lines')
->lockForUpdate()
->find($id->value);
return $order ?? throw OrderNotFound::withId($id);
}
}This is less isolated but may be the right cost for the application. The implementation still translates Eloquent’s missing row into the failure the collection contract promises.
Choose one concurrency contract and make save enforce it
getForUpdate() chooses pessimistic concurrency. The application holds a row
lock from load through save, so the transaction boundary belongs around the
whole use case. Laravel’s DB::transaction() commits
on success, rolls back and rethrows on failure, and can retry deadlocks. A
repository method that starts and commits its own transaction cannot protect
the later domain change made by its caller.
Pessimistic locking is useful when conflicts are likely and the operation is
short. Optimistic concurrency is often a better contract when conflicts are
rare or work cannot hold a database lock. In that design, the aggregate keeps
the version it loaded and save() includes it in the update condition:
public function save(Order $order): void
{
$nextVersion = $order->persistedVersion()->next();
$affected = OrderRecord::query()
->whereKey($order->id()->toString())
->where('version', $order->persistedVersion()->toInt())
->update([
'status' => $order->status()->value,
'version' => $nextVersion->toInt(),
]);
if ($affected !== 1) {
throw ConcurrentOrderChange::for($order->id());
}
$order->markPersisted($nextVersion);
}The affected-row check is the contract. Without it, storing the version is decoration and the last writer still wins. Saving lines and the root would also need one transaction; the shortened example isolates the concurrency decision rather than presenting a complete mapper.
Do not automatically retry this save() with the same mutated object. A
concurrent writer may have changed the facts that made cancel() valid. Load
fresh state and re-run the domain decision when the command is safe to retry,
or report a conflict to the caller.
Test the contract against the real persistence behavior
An in-memory repository is useful for fast application-service tests:
$orders = OrdersInMemory::containing($order);
$handler = new CancelOrderHandler($orders);
$handler->handle(new CancelOrder($order->id, $reason));
expect($orders->get($order->id)->status())
->toBe(OrderStatus::Cancelled);It cannot prove Eloquent mapping, locks, constraints, or transactions. Run the same behavioral contract against every implementation:
function ordersContract(Closure $repository): void
{
it('round-trips an order and its lines', function () use ($repository) {
$orders = $repository();
$order = anOrderWithTwoLines();
$orders->save($order);
expect($orders->get($order->id))->toEqual($order);
});
}Add integration tests for concurrency and database constraints where the contract depends on them. A fake that passes while two real workers lose an update is not substitutable evidence.
For an optimistic repository, the shared suite should include the stale-write contract as well as round-tripping:
it('rejects a stale aggregate', function () use ($repository) {
$orders = $repository();
$first = $orders->get($this->orderId);
$stale = $orders->get($this->orderId);
$first->cancel($this->reason);
$orders->save($first);
$stale->changeDeliveryAddress($this->address);
expect(fn () => $orders->save($stale))
->toThrow(ConcurrentOrderChange::class);
});An in-memory implementation must model versions if it claims to satisfy that contract. If it cannot, give it a narrower testing role instead of calling it an interchangeable implementation.
Mocking Orders is also not mandatory. A small in-memory implementation often
makes state and assertions clearer than a sequence of get and save mock
expectations. The test should describe the use case, not rehearse repository
method calls.
Let the interface expose domain access, not engine power
A repository earns its name when callers can think in aggregates and domain operations while the implementation handles persistence. Keep reads that serve screens in focused query objects. Keep direct Eloquent access when no meaningful boundary exists.
The reversal test is simple: if replacing the repository with direct Eloquent would remove only forwarding methods and no domain contract, mapping, or testing seam, remove it. An abstraction that hides a class name but preserves the entire database API has hidden nothing that matters.