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, optimistic locking, and the pessimistic findByIdForUpdate().
#[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.
Two reads insist where their siblings return null: getById($id) throws EmptyResultDataAccessException
(404) for a missing row, and findByIdForUpdate($id) reads under SELECT … FOR UPDATE and throws
TransactionRequiredException outside a transaction. findSlice(Pageable) is the count-free page.
Every method runs through the package's exception translation (see Data & Repositories):
a unique violation from save() is a DuplicateKeyException, a #[Query] against a missing table a
BadSqlGrammarException, a connection that cannot be opened a DataAccessResourceFailureException — each with
the QueryException as previous. firefly.data.exception-translation.enabled=false restores the raw
exceptions.
EloquentRepository reuses Eloquent's native SoftDeletes trait as-is — nothing bespoke:
final class SoftRecord extends Model
{
use SoftDeletes;
protected $table = 'soft_records';
public $timestamps = false;
protected $guarded = [];
}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
{
$query = $this->reading(__FUNCTION__);
return $this->translating(fn (): array => $this->narrow($query->withoutGlobalScope(SoftDeletingScope::class)->get()->all()));
}
// …
public function restore(mixed $id): ?object
{
return $this->translating(function () use ($id): ?object {
$this->query()
->withoutGlobalScope(SoftDeletingScope::class)
->where($this->keyName(), '=', $id)
->update(['deleted_at' => null]);
return $this->findById($id);
});
}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;
protected $table = 'audited_records';
public $timestamps = false;
protected $guarded = [];
}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 $table = 'versioned_records';
public $timestamps = false;
protected $guarded = [];
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 — a kernel OptimisticLockingFailureException (409 OPTIMISTIC_LOCK) — is thrown
instead of silently applying (or silently losing) the write. This is the package's own regression test for it,
which reads the row twice, writes through the first handle and then watches the second one be refused:
$a = $repo->findById($id);
$b = $repo->findById($id);
assert($a instanceof VersionedRecord && $b instanceof VersionedRecord);
$a->name = 'first';
$repo->save($a);
$reloaded = $repo->findById($id);
expect($reloaded?->version)->toBe(1);
$b->name = 'second';
expect(fn () => $repo->save($b))->toThrow(OptimisticLockException::class);
expect($repo->findById($id)?->name)->toBe('first'); // the stale write never landedInsert 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.