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 method | Effect |
|---|---|
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 constructor | Tag |
|---|---|
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 model | Tag |
|---|---|
int, integer | int |
timestamp | auto — the stored value is sealed as it is; declare datetime() for the normalised form |
bool, boolean | bool |
decimal:N | dec:N |
float, double, real | compile error unless declared float('col', scale) |
string, string-backed enum | str |
int-backed enum | int |
date, immutable_date | date |
datetime, immutable_datetime, datetime:<fmt>, immutable_datetime:<fmt>, custom_datetime | dt |
array, json, object, collection, AsArrayObject, AsCollection | json |
AsStringable | str |
encrypted* | str (the ciphertext as stored) unless declared plaintext() |
custom CastsAttributes / no cast | auto (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 sealThe 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 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.