NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Sentinel for Laravel

Sealing and writes

Creating a model seals it; an update re-seals the seals whose columns changed (seals with computed values re-seal on every update). Every Eloquent write path is covered — save, update, create, firstOrCreate, updateOrCreate, touch, push, increment and decrement (also incrementEach / decrementEach on Laravel 13+), restore, delete, forceDelete and the quiet variants. Automatic sealing needs sealing.auto on, an auto() seal and no active suspension.

TriggerWhat happensLedger event
Eloquent create (create, save of a new model, firstOrCreate, updateOrCreate)Every auto() seal is written.sealed
Eloquent update (save, update, touch, push, increment / decrement, restore, quiet variants)Seals whose columns changed are re-sealed; seals with computed values on every update.resealed
Sentinel::seal($model), Sentinel::for($model)->seal(), $model->seal()The seal is written (refused on a tampered model).sealed / resealed
acknowledge()Re-seals a model that is not intact.acknowledged
sentinel:reseal, Sentinel::model(…)->reseal()Re-seals intact rows with the current key / definition.rotated
sentinel:seal-missing, sealMissing()Seals rows that never had a seal.baseline
Hard delete / forceDeleteSeal rows deleted, tombstone appended.deleted
unseal($reason)Seal row deleted, tombstone appended.unsealed

On Laravel 12 a model has no incrementEach(): the call falls through to the query builder and updates every row of the table, unsealed — use increment() per column there.

Explicit seals

use RoundlyConsulting\Sentinel\Actions\Seals\SealModelAction;
use RoundlyConsulting\Sentinel\DataTransferObjects\SealRequest;

$result = Sentinel::for($invoice)->by($admin)->because('Lines recomputed')->seal();
$result = Sentinel::seal($invoice, 'financial', reason: 'Lines recomputed', actor: $admin);
$result = $invoice->seal('financial');
$result = app(SealModelAction::class)->execute(new SealRequest($invoice, 'financial', 'Lines recomputed', $admin));

$result->version;     // the new version
$result->event;       // SealEvent::Sealed (first seal) or SealEvent::Resealed
$result->keyId;       // the ring's current signing key

An explicit seal is how manual() seals are written and how computed-only drift is re-sealed. It seals models that are intact, outdated, never sealed, unsealed (lenient seals) or whose only changes are computed fields; anything else throws TamperedModelException. An explicit seal never launders — a tampered model must be acknowledged. An unsaved model throws SealingFailedException::notPersisted.

Atomicity

HasSeals routes every write through the manager, which runs the host’s write and the sealing in one transaction on the model’s connection (a savepoint inside an outer transaction). The host’s write is never retried. Any sealing failure — no signing key, a value that does not canonicalize, a lost version race — throws and rolls the write back.

Sealing locks the model row and reads the sealed columns back as stored, so database defaults, triggers and engine rewrites are sealed exactly as the database holds them. It then locks (or creates) the seal row, computes the next version, builds the canonical document, MACs or signs it, appends the ledger entry and updates the seal row by version compare-and-swap; ModelSealed fires after commit. The lock order is always model row → seal row → ledger insert, so there are no lock cycles.

A model listener that saves the same row again from inside the write — a created listener numbering the invoice with saveQuietly(), say — joins the outer write: the outer write holds the row lock and re-seals every auto seal once at its end, over the row as stored.

Writes to a model that is not intact

Before an update or increment the row is locked and verified, ledger check included. A model changed outside the application is handled by sealing.on_tampered_write or the seal’s onTamperedWrite():

PolicyEffect
refuseDefault. TamperedModelException; nothing written. Acknowledge first.
resealWritten and re-sealed; the ledger entry records previous_status; TamperDetected fires.
skipWritten, the seal left as it was (still not intact).

Benign drift is always re-sealed and audited: a changed definition (Outdated) and computed fields whose source rows changed (Tampered with reason computed — proven by the seal’s attribute MAC, never by the field tags). A create whose id was used before by a row deleted out of band follows the same policy with Stale(entity_recreated).

Deletes

  • A hard delete (including forceDelete) is never refused. The seal rows are deleted and a deleted tombstone is appended per seal — manual() seals included — recording the status the model had.
  • A soft delete keeps the seal and writes no tombstone; a restore goes through save().
  • Deleting an unsaved model does nothing and returns null, as in Eloquent.
  • With sealing.auto off or inside withoutSealing() no tombstone is written, so sentinel:verify --ledger reports the delete as EntityDeleted, like an out-of-band delete.

Suspension

For seeders and imports, suspend sealing for a callback. The suspension is scoped to the request or job (Octane-safe), nesting-safe, restored even when the callback throws and audited by a SealingSuspended event. It is an explicit opt-in: sealing.allow_suspension ships off, and until you set SENTINEL_ALLOW_SUSPENSION=true (a blank SENTINEL_ALLOW_SUSPENSION= counts as not set, so it stays off) withoutSealing() throws SealingSuspensionNotAllowedException and the callback never runs. Writes inside are unsealed or stale; strict seals report them until sealMissing() or an acknowledgement.

// .env (only where seeders or imports run): SENTINEL_ALLOW_SUSPENSION=true
Sentinel::withoutSealing(fn () => Invoice::query()->create($row), reason: 'Legacy import');

Overriding save() or delete()

Every save and delete of a sealable model must run inside the sealed write path, persistSealed(). In the class that uses HasSeals an override shadows the trait’s method, so wrap the parent call:

public function save(array $options = []): bool
{
    $this->number = trim((string) $this->number);

    return $this->persistSealed(fn (): bool => parent::save($options)) === true;
}

public function delete(): ?bool
{
    return $this->persistSealed(fn (): ?bool => parent::delete(), PersistOperation::Delete);
}

A subclass of a sealable model simply calls parent::save() — the parent’s method is the sealed one. A save or delete that bypasses the sealed path is refused before anything is written (SealingMisconfiguredException), quiet writes and withoutEvents() included.

Verify-only nodes and bypasses

Servers holding only public keys set SENTINEL_AUTO_SEAL=false: saves run without sealing, verification works as usual and an explicit seal() throws NoSigningKeyException. Query-builder writes (Invoice::query()->update(), DB::table(), upsert(), insert()) and raw SQL never seal — they are detected afterwards as Tampered or Missing. For deliberate mass updates use updateAndReseal() (see Bulk operations).

Performance

From the package’s non-gating performance smoke — 1 000 rows created through Eloquent (each sealed in its transaction), then verified with the chunked scan, on in-memory SQLite with PHP 8.4 on an Apple M3 Pro:

AlgorithmSeal (create + seal)Verify (scan)
hmac-sha2561.06 ms/row0.27 ms/row
hmac-sha3841.01 ms/row0.25 ms/row
hmac-sha5121.03 ms/row0.25 ms/row
ed255191.04 ms/row0.30 ms/row
ecdsa-p256-sha2561.10 ms/row0.45 ms/row
ecdsa-p384-sha3842.01 ms/row1.09 ms/row

A sealed update adds one locked read and one MAC before the write. Reading a sealable model costs nothing extra — 5 000 rows in 31 ms, the same as plain Eloquent — unless one of its seals verifies on retrieve; only then does HasSeals register a retrieved listener. Mark a seal manual() to opt out of automatic sealing where that matters.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.