Commands and health check
Fifteen commands cover installation, verification, upkeep and keys. Models are given as a class name or a morph alias, and the commands call the injected SentinelManager, so they also run against Sentinel::fake() in your tests.
| Command | Purpose |
|---|---|
sentinel:install {--force} | Publish, generate the default key when missing (with the ring’s algorithm), print the next steps. |
sentinel:check {--json} {--strict} | The installation health check; exit 1 on a failure (--strict: on a warning too). |
sentinel:verify {model?*} {--seal=} {--chunk=500} {--limit=} {--ledger} {--anchor=} {--check-schema} {--json} {--fail-on=*} {--max-findings=1000} {--allow-empty} {--isolated} | Scan sealed rows (and the ledger); exit 1 on findings, 2 when there is nothing to scan — for cron and CI. |
sentinel:checkpoint {--connection=*} {--batch=} {--isolated} | Fold pending ledger entries into checkpoints and publish them to the anchors. |
sentinel:reseal {model} {--seal=} {--from-key=} {--only-outdated} {--upgrade-format} {--chunk=500} {--dry-run} {--acknowledge=} | Re-seal after a rotation or definition change; never launders without --acknowledge. |
sentinel:seal-missing {model} {--seal=} {--reason=} {--chunk=500} | Baseline rows that were never sealed. |
sentinel:inspect {model} {id} {--seal=} {--show-values} {--check-schema} | One row: seal row, verdict, manifest, history, changed fields (values only with --show-values). |
sentinel:prune {--idempotency} {--nonces} {--dry-run} | Delete expired idempotency keys and nonces. |
sentinel:key:generate {--ring=} {--algorithm=hmac-sha256} {--kid=} {--database} {--activate-at=} {--owner-type=} {--owner-id=} {--label=} | Generate a key (environment lines, or a database row). |
sentinel:key:import {kid} {--ring=} {--algorithm=} {--signing} {--file=} {--activate-at=} {--owner-type=} {--owner-id=} {--label=} | Import a partner’s (or your own) key; material from --file (PEM or base64:…) or a hidden prompt, never an argument. |
sentinel:key:rotate {--ring=} {--algorithm=} {--activate-at=} | Rotate a ring’s signing key (a custom driver’s key is refused). |
sentinel:key:revoke {kid} {--ring=} {--reason=} | Revoke a key. |
sentinel:key:retire {kid} {--ring=} {--force} | Retire a key (refused while seals still use it). |
sentinel:key:list {--ring=} | The key inventory — labels included, quoted and escaped — and the seals each key made (on every ledger connection); never material. |
sentinel:key:reseal {--ring=} {--dry-run} | Re-seal database keys in the 1.2 envelope so their labels are bound — the upgrade step from 1.1; prints each label before writing. |
php artisan sentinel:verify # every sealable model
php artisan sentinel:verify "App\Models\Invoice" --seal=financial --ledger
php artisan sentinel:verify --fail-on=tampered,missing --fail-on=backlog
php artisan sentinel:verify --ledger --anchor='<json>' # compare a payload from a log anchor
php artisan sentinel:verify --check-schema --limit=10000 # CI against a staging snapshot
php artisan sentinel:reseal "App\Models\Invoice" --from-key=default-20250901-a1b2c3 --dry-run
php artisan sentinel:seal-missing "App\Models\Invoice" --reason="Initial baseline 2026-10"
php artisan sentinel:inspect "App\Models\Invoice" 42 --seal=financial
php artisan sentinel:prune --dry-run
php artisan sentinel:key:list --ring=httpOptions worth knowing
- --fail-on takes VerificationStatus values (tampered, missing, … — also outdated and unsealed, which never fail otherwise) and LedgerFindingKind values, repeated or comma-separated. Naming statuses replaces the default — only those fail; naming only ledger kinds keeps every failing status. Ledger violations always fail; backlog and anchor_unreachable fail only when named. An unknown name exits 2.
- --anchor='<json>' (a payload copied out of a write-only log anchor) is read only together with --ledger; an undecodable payload exits 2.
- --isolated[=EXIT] (sentinel:verify, sentinel:checkpoint) skips the run — exit 0, or the given code — while another instance holds the lock.
- Ranges: --chunk 1–100 000, --limit ≥ 1 (rows in total, across models), --max-findings 0–1 000 000, --batch 1–100 000; reasons 1–sealing.reason_max_length characters, a revoke reason at most 1 000.
- --json: sentinel:verify prints models, unresolved_types, scanned, counts, findings, truncated and a ledger object; sentinel:check prints failed and the list of checks, where failed honours --strict.
- sentinel:inspect keeps values redacted unless --show-values (they may contain personal data); sentinel:key:import reads material from --file or a hidden prompt, never from an argument that would land in shell history. A seal row with a corrupt sealed_at is still shown by sentinel:inspect (sealed at —, status malformed (sealed_at), exit 1) instead of aborting it.
- sentinel:key:reseal walks every ring that stores keys in the database (--ring for one), row by row under the row’s lock, and prints ring:kid → "label" before writing — binding, would bind (--dry-run, which writes nothing) or already bound — with control and invisible characters, < and > escaped like sentinel:key:list’s. It is idempotent, and on success without --dry-run it reminds you to set SENTINEL_REQUIRE_BOUND_LABEL=true.
Exit codes
0 means success, 1 failure and 2 invalid input:
| Command | 0 | 1 | 2 |
|---|---|---|---|
sentinel:install | installed — a key generated, already present or left for after migrating (a second run is harmless) | generating the default ring’s key failed (e.g. its algorithm is not in the ring’s allow-list) | — |
sentinel:check | no failure (with --strict: no warning either) | a failure (with --strict: or a warning) | — |
sentinel:verify | nothing failing found (or nothing to scan, with --allow-empty) | a failing status (see --fail-on) or a ledger violation | invalid input or configuration, or nothing to scan |
sentinel:checkpoint | every connection checkpointed (one held by another run is skipped with a warning) | a connection’s checkpoint failed | an invalid --batch or configuration |
sentinel:reseal | nothing skipped, nothing failed | rows that are not intact were skipped (listed), or a row failed | unknown model or seal, invalid option or configuration, an acknowledgement refused by the policy |
sentinel:seal-missing | every candidate baselined | rows with history but no seal (reported, never baselined), or a row failed | no --reason, unknown model or seal, invalid option |
sentinel:inspect | every inspected seal is intact | a seal is not intact, or the row does not exist | unknown model or seal, configuration error |
sentinel:prune | deleted (or counted, with --dry-run) | a store error | — |
sentinel:key:generate | generated | unknown --algorithm, owner not found, refused (taken kid, algorithm not allowed, invalid kid or label) | — |
sentinel:key:import | imported | refused (taken kid, read-only ring, invalid material, private material without --signing) | invalid input (no --algorithm, owner not found, unreadable, empty or > 64 KB file, no terminal and no --file) |
sentinel:key:rotate | rotated | unknown --algorithm, refused (algorithm not allowed, a key from a custom driver) | — |
sentinel:key:revoke | revoked — for a config key, the SENTINEL_REVOKED_KEYS line printed | no --reason, unknown key, refused | — |
sentinel:key:retire | retired — for a config key, the instruction printed | unknown key, still used by seals without --force, refused | — |
sentinel:key:list | listed | invalid ring or configuration | — |
sentinel:key:reseal | every key re-sealed or already bound (with --dry-run: would be) | a key fails its integrity check, a legacy key is refused under require_bound_label or holds a label that is not valid UTF-8 (each listed, none re-sealed); an unknown ring or one without a database store | — |
Scheduling
The provider registers the upkeep on the scheduler while schedule.enabled is on (the default), each task withoutOverlapping()->onOneServer(). The full scan defaults to daily — hourly full-table scans are a silent load on large tables — while the checkpoint stays every minute, because its interval is the window in which a rollback goes unseen:
| Task | Command | Default | Env |
|---|---|---|---|
checkpoint | sentinel:checkpoint (only while ledger.enabled) | everyMinute | SENTINEL_SCHEDULE_CHECKPOINT |
verify | sentinel:verify --allow-empty (+ --ledger while the ledger is on) | daily | SENTINEL_SCHEDULE_VERIFY |
prune | sentinel:prune | daily | SENTINEL_SCHEDULE_PRUNE |
To wire them yourself, set SENTINEL_SCHEDULE=false and add them to routes/console.php. Don’t keep hand-written lines next to the automatic schedule — withoutOverlapping() prevents concurrent runs, not double scheduling.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('sentinel:checkpoint')->everyMinute()->withoutOverlapping();
Schedule::command('sentinel:verify --ledger')->hourly();
Schedule::command('sentinel:verify --json')->dailyAt('03:00')->emailOutputOnFailure('[email protected]');
Schedule::command('sentinel:prune')->daily();Sentinel::sealables() lists what sentinel:verify scans when it is given no model: sentinel.models first, then every class with seal rows or ledger entries. With nothing to scan it exits 2, so a misconfiguration never reports green. In CI, sentinel:verify --check-schema --limit=10000 against a staging snapshot fails the pipeline on findings, and sentinel:check --strict fails it on any weakened guarantee.
A host without Sentinel’s tables
A host that gets Sentinel only as another package’s dependency never publishes its config or migrations — yet the schedule is on by default. So each scheduled task skips while none of the tables the configuration needs exists (the list the tables check reads):
- No tables at all: checkpoint, verify and prune stay registered (schedule:list shows them) but never start — schedule:run dispatches ScheduledTaskSkipped for each. Nothing fails, so such a host needs no SENTINEL_SCHEDULE=false.
- Any one table present: the host uses Sentinel’s storage and every task runs as before — a half-migrated install fails as loudly as it always did.
- prune skips only while it would prune a Sentinel table: with a cache store, or a store the host bound, it runs whatever tables exist.
- Cannot tell (a connection that fails, a setting that cannot be read, a store that cannot be built): the task runs and reports the failure itself.
- The look-up happens only for a task that is due, once per schedule:run: one hasTable per needed table until the first is found. Migrating turns the tasks on at the next schedule:run.
The skip is silent on purpose, so the health check is not: while a task skips, the schedule check fails and names it, and php artisan about shows auto (checkpoint, verify, prune skipped: no tables). Commands you run yourself and lines you schedule by hand are never skipped.
The health check
sentinel:check (or Sentinel::check()) reports every misconfiguration that silently weakens the guarantees, in one place. Messages name settings, rings, tables and classes — never key material, key ids or labels:
$report = Sentinel::check(); // HealthReport
$report->failed(); // any failure (warnings do not fail it)
$report->failures(); // list<HealthCheck>: name, status, message
$report->warnings();| Check | Fails when | Warns when |
|---|---|---|
configuration | Any setting or signature profile is invalid. | — |
signing_keys | The default ring, the ledger ring or a ring a sealable model uses cannot sign (while sealing.auto is on). | The same on a verify-only node. |
app_key | Database keys or idempotency.encrypt need APP_KEY and it is empty. | — |
tables | A sentinel_* table is missing on its connection. | — |
models | A sealable model does not compile, or its table lacks a sealed column. | There are no sealable models, stored types no longer resolve, or a model lives on a connection ledger.connections does not list. |
anchors | A configured anchor is unreachable, or the cache anchor lives in process memory or in the database it protects. | None is configured, or the cache anchor uses the default store. |
checkpoints | — | Ledger entries older than ledger.backlog_warning_seconds are not checkpointed. |
schedule | Scheduling is on, but the tasks skip every run because none of Sentinel’s tables exists (names the tasks). | Scheduling is off and no sentinel:checkpoint is scheduled. |
retired_keys | — | Seals still use a revoked, retired or unknown key (counts per ring). |
stores | The idempotency or nonce store cannot be built. | — |
key_envelopes | require_bound_label is on while database keys still use the pre-1.2 envelope — they fail their integrity check. | Database keys still use the pre-1.2 envelope, whose label is not bound (counts per ring): run sentinel:key:reseal, check the labels, then set SENTINEL_REQUIRE_BOUND_LABEL=true. |
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.