Verifying
Every entry point — the facade, the handle, the model trait, the middleware, the validation rule, the collection macro, verify-on-retrieve and the scans — reaches the same verifier and the same verdict:
use RoundlyConsulting\Sentinel\Enums\VerificationStatus;
use RoundlyConsulting\Sentinel\Facades\Sentinel;
$result = Sentinel::for($invoice)->verify(); // the default seal
$result = Sentinel::for($invoice, 'identity')->verify(); // a named seal
$result = Sentinel::verify($invoice, 'financial'); // the same, flat
$result->status; // VerificationStatus::Intact, ::Tampered, …
$result->reason; // 'mac', 'seal_deleted', 'newer_version', … (null when intact)
$result->changedAttributes; // ['a:amount'] — names only, never values
$result->changedColumns(); // ['amount'] (and changedComputed() for c:* fields)
$result->onlyComputedChanged(); // only computed (c:*) values drifted — an explicit seal() re-seals that
$result->toArray(); // status, reason, ids, key, changed names — never values
$result->isIntact();
$invoice->isIntact(); // every seal
$invoice->verifySeal('identity');
Sentinel::verifyAll($invoice)->allIntact(); // VerificationReport over every seal
Sentinel::verifyMany($invoices, 'financial')->failures();
Sentinel::for($invoice)->verifyOrFail(); // TamperedModelException when not intactThe action form takes a VerifyRequest; checkLedger: false skips the ledger comparison for that one call:
use RoundlyConsulting\Sentinel\Actions\Seals\VerifyModelAction;
use RoundlyConsulting\Sentinel\DataTransferObjects\VerifyRequest;
$result = app(VerifyModelAction::class)->execute(new VerifyRequest($invoice, 'financial', checkLedger: false));The result
VerificationResult carries status, reason, the sealable type, id and seal, version and ledgerVersion, ring, keyId, algorithm, sealedAt, changedAttributes, context (a VerificationContext) and outdatedIsIntact. isIntact() is true for Intact, Unsealed and — with verification.outdated_is_intact, the default — Outdated; failed() is its negation, and toArray() never contains values. verifyAll() and verifyMany() return a VerificationReport with results, allIntact(), failures(), count(), counts() and throwIfTampered().
Statuses
| Status | Meaning |
|---|---|
Intact | The seal matches the stored values and the ledger. |
Outdated | Intact, but sealed under an older definition (counts as intact by default; sentinel:reseal --only-outdated). |
Unsealed | A lenient seal that was never written. |
Tampered | Values changed outside the application (mac), only computed values drifted (computed — writes and seal() re-seal it), values no longer canonicalize (canonicalization), or a ledger entry was forged (ledger_entry). |
Missing | A strict seal is absent: seal_deleted (history exists), never_sealed, unsealed. |
Stale | An older seal was restored: newer_version, not_in_ledger, ledger_mismatch (entity_recreated on a re-used id). |
UnknownKey, RevokedKey, RetiredKey | The sealing key is unknown/pending/damaged, revoked or retired. |
AlgorithmNotAllowed, AlgorithmMismatch | The key’s algorithm is not allowed / differs from the stored one. |
Malformed | The seal row is damaged (unknown format, undecodable manifest, invalid MAC encoding). |
Unverifiable | A sealed column is missing from the row (missing_attribute), the stored seal covers a computed field the definition no longer declares (missing_computed), or a scanned row’s verification threw (error). |
Every failure fires TamperDetected (sync) and logs a warning to verification.log_channel — names only, never values. Verification is read-only: it takes no locks except inside a write’s pre-write check.
Status precedence
Evaluated in order; the first match wins. Statuses 6–11 are decided before any MAC is computed, and rows 15–18 run when verification.check_ledger is on (one indexed query):
| # | Condition | Status | Reason |
|---|---|---|---|
| 1 | Model not sealable / seal not declared | throws SealingMisconfiguredException | — |
| 2 | No seal row; ledger head live | Missing | seal_deleted |
| 2a | No seal row; the ledger head is a tombstone whose entry MAC does not verify | Tampered | ledger_entry |
| 3 | No seal row; strict | Missing | unsealed / never_sealed |
| 4 | No seal row; lenient | Unsealed | — |
| 5 | Unknown format, undecodable manifest, a manifest column that is neither the seal’s nor in the table, MAC not base64url, version < 1 | Malformed | the check |
| 6 | Stored ring not the seal’s ring nor in acceptRings | UnknownKey | ring_not_accepted |
| 7 | Key not found / pending / envelope integrity failure | UnknownKey | not_found / pending / integrity |
| 8 | Key revoked | RevokedKey | — |
| 9 | Key retired | RetiredKey | — |
| 10 | Key algorithm not allowed by the seal and ring | AlgorithmNotAllowed | — |
| 11 | Stored algorithm ≠ the key’s | AlgorithmMismatch | — |
| 12 | A sealed column missing from the read-back / a computed field no longer declared | Unverifiable | missing_attribute / missing_computed |
| 13 | Current values do not canonicalize | Tampered | canonicalization |
| 14 | MAC / signature invalid, but the attribute MAC proves only computed values drifted | Tampered | computed |
| 14a | MAC / signature invalid otherwise | Tampered | mac |
| 15 | Ledger: an entry newer than the seal row exists | Stale | newer_version |
| 16 | Ledger: no entry at the seal row’s version | Stale | not_in_ledger |
| 17 | Ledger: that entry’s seal MAC ≠ the row’s | Stale | ledger_mismatch |
| 18 | Ledger: that entry’s MAC invalid, or signed by a ring the seal does not accept | Tampered | ledger_entry |
| 19 | Stored manifest ≠ current definition | Outdated | — |
| 20 | Otherwise | Intact | — |
Changed attributes
With an HMAC key and field tags on (the default), each seal stores keyed 128-bit tags per field. On a failed MAC the changed attributes are the fields whose recomputed tag differs, plus fields present on one side only. Tags are diagnostics: tampering with them only misleads the list — the seal still fails. Asymmetric seals store no tags, so changedAttributes is null.
Verify-on-retrieve
A seal declared with ->verifyOnRetrieve(Reaction::Throw) verifies every retrieved model: Throw reports the finding (TamperDetected and a log line) and throws TamperedModelException; Report reports and lets the model load. Without an explicit reaction, verification.retrieve_reaction applies. The ledger check is off on retrieve unless verification.retrieve_checks_ledger is on, and a partial select() that leaves out the seal’s own columns is skipped. Sentinel::withoutVerification() suspends it, and Sentinel::model(Invoice::class)->find($id) loads a tampered row on purpose (see Acknowledging changes).
Scans
Sentinel::model($class)->scan() verifies every row in chunks, soft-deleted rows included. A where closure (or a builder of that model) narrows the scan of one model without loading it into memory — it filters, never orders — and a progress callback receives the rows processed after every chunk:
$report = Sentinel::model(Invoice::class)->scan('financial', chunk: 500);
$report->scanned; // rows × seals verified
$report->counts; // list<StatusCount>
$report->findings; // up to maxFindings VerificationResult
$report->truncated;
$report->hasFindings(); // any failure
$report->hasFindings(VerificationStatus::Tampered, VerificationStatus::Missing); // only these
$report->count(VerificationStatus::Outdated);
$report = Sentinel::model(Invoice::class)->scan(where: fn ($query) => $query->where('tenant_id', 7));
$report = Sentinel::model(Invoice::class)->scan('financial', where: Invoice::query()->whereYear('created_at', 2026), limit: 10_000);Sentinel::scan(new ScanOptions(models: […], seal: null, chunk: 500, checkLedger: true, limit: null, maxFindings: 1000, checkSchema: false, where: null, progress: null)) is the general form, and sentinel:verify wraps it.
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 cryptoBy 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.