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

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 intact

The 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

StatusMeaning
IntactThe seal matches the stored values and the ledger.
OutdatedIntact, but sealed under an older definition (counts as intact by default; sentinel:reseal --only-outdated).
UnsealedA lenient seal that was never written.
TamperedValues 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).
MissingA strict seal is absent: seal_deleted (history exists), never_sealed, unsealed.
StaleAn older seal was restored: newer_version, not_in_ledger, ledger_mismatch (entity_recreated on a re-used id).
UnknownKey, RevokedKey, RetiredKeyThe sealing key is unknown/pending/damaged, revoked or retired.
AlgorithmNotAllowed, AlgorithmMismatchThe key’s algorithm is not allowed / differs from the stored one.
MalformedThe seal row is damaged (unknown format, undecodable manifest, invalid MAC encoding).
UnverifiableA 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):

#ConditionStatusReason
1Model not sealable / seal not declaredthrows SealingMisconfiguredException—
2No seal row; ledger head liveMissingseal_deleted
2aNo seal row; the ledger head is a tombstone whose entry MAC does not verifyTamperedledger_entry
3No seal row; strictMissingunsealed / never_sealed
4No seal row; lenientUnsealed—
5Unknown format, undecodable manifest, a manifest column that is neither the seal’s nor in the table, MAC not base64url, version < 1Malformedthe check
6Stored ring not the seal’s ring nor in acceptRingsUnknownKeyring_not_accepted
7Key not found / pending / envelope integrity failureUnknownKeynot_found / pending / integrity
8Key revokedRevokedKey—
9Key retiredRetiredKey—
10Key algorithm not allowed by the seal and ringAlgorithmNotAllowed—
11Stored algorithm ≠ the key’sAlgorithmMismatch—
12A sealed column missing from the read-back / a computed field no longer declaredUnverifiablemissing_attribute / missing_computed
13Current values do not canonicalizeTamperedcanonicalization
14MAC / signature invalid, but the attribute MAC proves only computed values driftedTamperedcomputed
14aMAC / signature invalid otherwiseTamperedmac
15Ledger: an entry newer than the seal row existsStalenewer_version
16Ledger: no entry at the seal row’s versionStalenot_in_ledger
17Ledger: that entry’s seal MAC ≠ the row’sStaleledger_mismatch
18Ledger: that entry’s MAC invalid, or signed by a ring the seal does not acceptTamperedledger_entry
19Stored manifest ≠ current definitionOutdated—
20OtherwiseIntact—

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 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.