Index

A Repository Is a Domain Collection, Not an ORM Wrapper

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.