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

Database schema

Six publish-only, forward-only migrations (no down(), timestamp-injected when published, never auto-loaded). No package table uses soft deletes and no model is swappable — security state is persisted only through these models and the engine.

#TableConnectionForeign keys
1sentinel_keyssentinel.database.connection—
2sentinel_checkpointsthe sealable model’s—
3sentinel_ledgerthe sealable model’scheckpoint_id → sentinel_checkpoints (restrict)
4sentinel_sealsthe sealable model’sledger_entry_id → sentinel_ledger (restrict)
5sentinel_idempotency_keyssentinel.database.connection—
6sentinel_noncessentinel.database.connection—

Seals, ledger and checkpoints live on each sealable model’s connection and share its transaction. Hosts with sealables on several connections publish and run 0002–0004 per connection and list them in ledger.connections.

Upgrading from a pre-release build

Migration 0004_create_sentinel_seals_table was edited in place to add the nullable attributes_mac column (the MAC of the attribute document, for seals with computed fields); there is no new migration. If you ran an earlier pre-release copy, re-publish the migrations and re-run 0004, or add the column to sentinel_seals yourself — string('attributes_mac', 192)->nullable() — before the next sealed write. Installs from the current migrations already have it.

Models

Read them for reporting (Seal::query()->where('key_id', $kid)->count()), never write them: the engine owns every write, and the MACs, chain and envelopes catch edits. Every package timestamp is cast with Casts\UtcDateTime (UTC, microseconds, CarbonImmutable), so the application’s time zone never leaks into stored or MAC’d times.

ModelTableNotes
Keysentinel_keys$hidden = ['envelope']; the envelope is AES-256-GCM ciphertext bound to the row’s plain columns, written and opened by the key store.
Sealsentinel_sealssealable(): MorphTo, sealedBy(): MorphTo, ledgerEntry(): BelongsTo; manifest / field_tags → array; event → SealEvent.
LedgerEntrysentinel_ledgerNo timestamps; append-only (Eloquent updates and deletes throw, quiet ones included); sealable(), actor(), checkpoint().
Checkpointsentinel_checkpointsUPDATED_AT = null; append-only.
IdempotencyKeysentinel_idempotency_keys$hidden = ['response', 'owner_token']; status is processing or completed.
Noncesentinel_noncesUPDATED_AT = null; subject(): MorphTo; kind → NonceKind.

Factories

  • KeyFactory — ->hmac(), ->ed25519(), ->ecdsaP256(), ->verifyOnly(), ->revoked(), ->retired(), ->pending(), ->ring(), all with valid envelopes.
  • SealFactory — ->forSealable(Model $m, string $seal), structurally valid but unsigned: for Malformed and negative tests.
  • LedgerEntryFactory — ->forSealable(Model $m, string $seal), ->tombstone(SealEvent $event = SealEvent::Deleted); plus CheckpointFactory.
  • IdempotencyKeyFactory — ->processing(), ->completed(), ->expired().
  • NonceFactory — ->issued(), ->seen(), ->consumed(), ->expired().

Retention

TableGrows withPruned
sentinel_sealsone row per (model, seal)deleted with hard-deleted models
sentinel_ledgerone row per seal eventnever (evidence)
sentinel_checkpointsone row per checkpoint batchnever
sentinel_keyskeysnever (retire or revoke instead)
sentinel_idempotency_keysidempotent requestssentinel:prune (expired)
sentinel_noncesissued and remembered noncessentinel:prune (expired)

Protecting the tables

Sentinel detects changes to these tables too, but database grants make tampering harder in the first place. The application user needs INSERT and SELECT on sentinel_ledger and sentinel_checkpoints, only UPDATE (checkpoint_id) on the ledger, and the one row-lock privilege each engine asks for, because sentinel:checkpoint locks the checkpoint tail with SELECT … FOR UPDATE. Never DELETE. Keep the anchor store’s credentials away from the database’s.

-- PostgreSQL
GRANT SELECT, INSERT ON sentinel_ledger, sentinel_checkpoints TO app;
GRANT UPDATE (checkpoint_id) ON sentinel_ledger TO app;
GRANT UPDATE (seq) ON sentinel_checkpoints TO app;

-- MySQL
GRANT SELECT, INSERT ON app_db.sentinel_ledger TO 'app'@'%';
GRANT SELECT, INSERT ON app_db.sentinel_checkpoints TO 'app'@'%';
GRANT UPDATE (checkpoint_id) ON app_db.sentinel_ledger TO 'app'@'%';
GRANT LOCK TABLES ON app_db.* TO 'app'@'%';

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.