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

Declaring seals

A sealable model implements Sealable, uses HasSeals and declares one or more named seals in a static defineSeals(). The first seal is the default one: Sentinel::for($model) without a name targets it, while isIntact() and verifyAll() cover every seal.

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Sentinel\Concerns\HasSeals;
use RoundlyConsulting\Sentinel\Contracts\Sealable;
use RoundlyConsulting\Sentinel\Definition\SealBuilder;
use RoundlyConsulting\Sentinel\Enums\Algorithm;
use RoundlyConsulting\Sentinel\Enums\Reaction;

final class Invoice extends Model implements Sealable
{
    use HasSeals;

    public static function defineSeals(SealBuilder $seals): void
    {
        $seals->seal('financial')
            ->attributes('customer_id', 'currency', 'amount', 'status', 'paid', 'due_on')
            ->decimal('amount', 2)
            ->computed('lines', static fn (Invoice $invoice): array => $invoice->lines()
                ->orderBy('id')->get(['sku', 'quantity'])->toArray())
            ->algorithms(Algorithm::HmacSha256, Algorithm::Ed25519)
            ->scope(static fn (Invoice $invoice): string => (string) $invoice->tenant_id)
            ->verifyOnRetrieve(Reaction::Throw);

        $seals->seal('identity')->using(PartyIdentitySeal::class)->lenient();
    }
}

Use several seals when parts of a row have different owners or lifecycles (financial values vs. a party’s identity), different keys or different strictness. Each is stored as one sentinel_seals row per (model, seal) and versioned separately. Seal names follow ^[a-z][a-z0-9_.-]{0,63}$ and are unique per model.

Builder methods

Builder methodEffect
attributes(...$columns)Columns covered, typed from the model’s casts. Never *; the primary key is always bound.
string(), integer(), boolean(), decimal($column, $scale), float($column, $scale), datetime(), date(), json(), binary(), plaintext()Add columns with an explicit type. Floats must be declared with a scale; plaintext() seals the decrypted value of an encrypted cast.
computed($name, $resolver, ?SealType $as)A value computed from the model (a static closure), e.g. related rows.
ring($ring), acceptRings(...$rings), algorithms(...$algorithms)Key ring, extra rings accepted during a ring migration, algorithm allow-list.
strict() / lenient()A missing seal is a finding (default) / is Unsealed.
auto() / manual()Seal on Eloquent writes (default) / only explicitly.
verifyOnRetrieve(?Reaction $reaction)Verify every retrieved model: Throw (refuse to load it) or Report; both fire TamperDetected and log. Null = verification.retrieve_reaction.
fieldTags(bool $enabled = true)Store keyed per-field tags so failures name the changed attributes (HMAC keys).
scope($resolver)A tenant (or other) scope bound into the MAC — a seal copied across scopes never verifies.
onTamperedWrite(TamperedWritePolicy $policy)Per-seal override of sealing.on_tampered_write.
using(SealDefinition::class)Apply a reusable definition first; inline calls win.

Fields are an explicit allow-list, sorted by name in the canonical document: a column becomes a:<column>, a computed value c:<name>. Typed methods take several columns (->string('number', 'iban')), except decimal() and float(), which take one column and its scale; a typed call after attributes() overrides the inferred type of that column.

Reusable definitions

use RoundlyConsulting\Sentinel\Contracts\SealDefinition;
use RoundlyConsulting\Sentinel\Definition\SealDefinitionBuilder;

final class PartyIdentitySeal implements SealDefinition
{
    public function define(SealDefinitionBuilder $seal): void
    {
        $seal->attributes('number', 'meta')->plaintext('secret');
    }
}

The class is resolved from the container once, at compile time. Inline calls after using() add fields, override field types and override options.

Field types

SealType is the declared type of a field — the $as of computed() and the tag stored in the manifest. Its instances expose kind, scale, tag() and equals(), and SealType::tryFromTag('dec:2') parses a tag back:

Named constructorTag
SealType::string()str
SealType::integer()int
SealType::boolean()bool
SealType::decimal(int $scale)dec:N (0–30)
SealType::float(int $scale)flt:N
SealType::datetime()dt
SealType::date()date
SealType::json()json
SealType::binary()bin
SealType::plaintext()plain
SealType::auto()auto
use RoundlyConsulting\Sentinel\Definition\SealType;

$seals->seal('financial')
    ->decimal('amount', 2)
    ->computed('total', static fn (Invoice $invoice): string => $invoice->totalAsString(), SealType::decimal(2));

Type inference from casts

attributes() types each column from the model’s casts:

Cast on the modelTag
int, integerint
timestampauto — the stored value is sealed as it is; declare datetime() for the normalised form
bool, booleanbool
decimal:Ndec:N
float, double, realcompile error unless declared float('col', scale)
string, string-backed enumstr
int-backed enumint
date, immutable_datedate
datetime, immutable_datetime, datetime:<fmt>, immutable_datetime:<fmt>, custom_datetimedt
array, json, object, collection, AsArrayObject, AsCollectionjson
AsStringablestr
encrypted*str (the ciphertext as stored) unless declared plaintext()
custom CastsAttributes / no castauto (typed at runtime from the raw value; a float is refused)

Values are always read from the database in raw driver form — under a row lock when sealing — and normalized per tag, so a row sealed on SQLite verifies on PostgreSQL or MySQL for every declared or cast type. An auto field (no cast, no declared type) is typed by the PHP value each driver returns, which differs for booleans, uncast decimals and JSON text: declare the type when seals must move between engines.

Computed values

A computed resolver is a static closure receiving the model. A string or Stringable becomes str, an int int, a bool bool, a backed enum its value, a DateTimeInterface dt (UTC), an array or JsonSerializable json. A float needs $as; a model instance is refused — return a key or an array. Resolvers run on a model hydrated from the locked database row, never on the caller’s in-memory instance.

Computed values that read related rows go stale when those rows change without a save of the owner. Re-seal the owner — Sentinel::seal($invoice) re-seals computed-only drift and records it — or touch it from the child’s saved hook ($line->invoice->touch()).

Compilation and errors

A definition is compiled once per process, when the model class boots — so an invalid one fails on the first new — and validated as a whole. defineSeals() must not read request or authentication state, and every closure must be static. Every problem is listed in one InvalidSealDefinitionException (problems()):

  • No seals; a duplicate or invalid seal name; a seal without fields; a duplicate field; an invalid column or computed name; the primary key listed.
  • An undeclared float; a decimal scale outside 0–30.
  • An unknown ring; a ring HTTP message signatures use, as the seal’s ring or in acceptRings; algorithms() not within the ring’s allow-list; acceptRings containing the seal’s own ring.
  • A model not using HasSeals; a using() class that is not a SealDefinition; a non-static closure.

An unknown column surfaces as the database’s QueryException on the first seal or verify (there is no schema query per call) — check up front with sentinel:verify --check-schema or sentinel:inspect --check-schema. Reading a model costs nothing extra unless one of its seals declares verifyOnRetrieve().

Inspecting a definition

$definition = Sentinel::model(Invoice::class)->definition('financial');   // CompiledSeal

$definition->name;          // 'financial'
$definition->ring;
$definition->manifest();    // [['a:amount', 'dec:2'], ['a:currency', 'str'], …, ['c:lines', 'auto']]
Sentinel::model(Invoice::class)->seals();                // ['financial', 'identity']
Sentinel::for($invoice)->definition();                   // the handle's seal

The manifest (field names and declared tags) is stored with each seal. When the definition changes but the data is intact, verification reports Outdated (intact by default), and sentinel:reseal --only-outdated moves rows to the new manifest.

Reserved names

HasSeals adds seal, verifySeal, verifySealOrFail, isIntact, acknowledgeTampering, persistSealed, sentinelSeals (a MorphMany relation) and the scopes whereSealed, whereNotSealed and withSeals to your model, and overrides save(), delete(), the protected incrementOrDecrement() and, on Laravel 13+, incrementOrDecrementEach().

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.