EloquentRepository is the Eloquent-backed base every concrete repository extends: it implements the full
CrudRepository/PagingAndSortingRepository
contract over Model::query(), so an application repository is usually just a $model assignment plus its
own derived-query/#[Query] methods. This page covers the Eloquent-specific building blocks layered on top:
soft-delete helpers, auditing, and optimistic locking.
/** @extends EloquentRepository<Record> */
#[Repository]
class RecordRepository extends EloquentRepository
{
protected string $model = Record::class;
}Set protected string $model = X::class; and the repository inherits save/saveAll/findById/findAll/
findAllById/existsById/count/delete/deleteById/deleteAll, plus paging and sorting, all driven from
($this->model)::query(). Page/Pageable/Sort are mapped to the Eloquent builder at the edge —
findPaged() runs a count() query for the total, then skip($pageable->offset())->take($pageable->size)
for the slice; applySort() folds each Sort order into an ->orderBy($property, $direction) call. Every
list-returning method narrows its raw Eloquent results back to TModel via an internal instanceof $this->model check, so callers never see a bare Model — only the concrete entity type the repository was
declared over. This class is reflection-free (everything goes through the Eloquent Builder, no runtime
class introspection); see Data & Repositories for the derived-query and #[Query] dispatch it also
hosts.
EloquentRepository reuses Eloquent's native SoftDeletes trait as-is — nothing bespoke:
final class SoftRecord extends Model
{
use SoftDeletes;
}Once a model uses SoftDeletes, deleting through the repository (delete()/deleteById()) soft-deletes
the row, and every ordinary read (findAll(), findById(), derived queries, …) transparently excludes
trashed rows via Eloquent's own global scope — the repository adds no extra filtering of its own. Two helpers
round out the lifecycle:
public function findAllIncludingDeleted(): array; // removes the SoftDeletingScope for this one call
public function restore(mixed $id): ?object; // nulls deleted_at, then re-reads (now visible again)findAllIncludingDeleted() drops SoftDeletingScope for that query only (later calls still exclude trashed
rows as normal); restore() writes deleted_at = null for the given id and returns the freshly re-read
entity, or null if no such row exists. Both are meaningful only on a model that actually uses SoftDeletes
— on a plain model the scope is simply absent, so calling them is a harmless no-op.
Auditable is an opt-in trait for created_by/updated_by stamping:
final class AuditedRecord extends Model
{
use Auditable;
}use Auditable registers an AuditObserver (deferred to Eloquent's whenBooted() hook, since
Model::observe() can't run while the model is still mid-boot) that stamps created_by and updated_by on
insert, and updated_by again on every update. Stamping is driven by an AuditorAware port:
interface AuditorAware
{
public function currentAuditor(): int|string|null;
}!!! note "Known-latent: auditing is a no-op before M11"
AuditObserver resolves AuditorAware from the container at write time; while nothing is bound (which is
the case until M11's security-context principal lands), it simply returns early and leaves created_by/
updated_by untouched. Binding an AuditorAware implementation is what turns stamping on — no code change
to Auditable-using models is needed once that lands.
HasOptimisticLock guards concurrent writes to the same row with a version integer column:
final class VersionedRecord extends Model
{
use HasOptimisticLock;
protected $attributes = ['version' => 0];
}The trait overrides Eloquent's internal performUpdate(): every update bumps version by one and adds
WHERE version = <the version the row was loaded with> to the UPDATE. If that WHERE matches zero rows —
because someone else's write already moved the version — the row changed underneath the caller, and
OptimisticLockException is thrown instead of silently applying (or silently losing) the write:
$a = $repo->findById($id);
$b = $repo->findById($id); // same row, same starting version
$a->name = 'first';
$repo->save($a); // succeeds; version bumps 0 -> 1
$b->name = 'second';
$repo->save($b); // throws OptimisticLockException: version moved to 1 underneath $bInsert is untouched — a new row simply starts at its default version; only updates are guarded. The column
name defaults to version (optimisticLockColumn(), overridable per model).
EloquentRepository::save() does two things when it persists an entity: it calls $entity->save() if the
entity is an Eloquent Model, and — separately — if the entity also implements RecordsDomainEvents (either
by extending AggregateRoot or, for a Model, by use HasDomainEvents) and a transaction is currently
active on that entity's own connection, it registers the entity with the AggregateTracker unit-of-work
registry. That registration is what lets the framework drain and publish the entity's raised events after the
surrounding transaction commits — see Domain (DDD) for the event
model itself and Transactions for the commit/rollback machinery that drives the drain.