Installation
Require the package and run the guided installer. The service provider is auto-discovered; the package registers no global facade alias, so always import RoundlyConsulting\Sentinel\Facades\Sentinel:
composer require roundly-consulting/sentinel-for-laravel
php artisan sentinel:installsentinel:install publishes the configuration and the migrations, shows the morph key types to settle before migrating, generates the default ring’s key when it has none (printed as environment lines — .env is never written) and prints the next steps. It never edits host files beyond the publish; running it again is harmless, and --force overwrites published files.
The same steps by hand
Publish and run the migrations — six tables, all prefixed sentinel_. They are publish-only and forward-only, never auto-loaded:
php artisan vendor:publish --tag="sentinel-migrations"
php artisan migrateOptionally publish the configuration file and the translations (English and Slovak):
php artisan vendor:publish --tag="sentinel-config"
php artisan vendor:publish --tag="sentinel-translations"| Tag | Publishes |
|---|---|
sentinel-migrations | Six publish-only, forward-only migrations (no down()), timestamp-injected. |
sentinel-config | config/sentinel.php |
sentinel-translations | lang/vendor/sentinel/{en,sk} |
Choose the key types, then migrate
The morph columns follow sentinel.key_type (sealed models) and sentinel.actor_key_type (actors, key owners, nonce subjects) — bigint by default, or uuid / ulid. Set them before migrating. A fleet mixing integer and UUID keys on MySQL or SQLite picks uuid: the column is a char(36) there, which holds integer keys as strings too. On PostgreSQL the uuid column is native and refuses integer keys, so a mixed fleet publishes the migrations and changes those id columns to string(36) first. Both settings are read strictly: anything but bigint, uuid or ulid (any case) makes the migration throw — a typo never silently builds bigint columns.
sentinel_keys, sentinel_idempotency_keys and sentinel_nonces use sentinel.database.connection; sentinel_checkpoints, sentinel_ledger and sentinel_seals live on each sealable model’s connection and share its transaction. With sealables on several connections, run migrations 0002–0004 on each and list the connections in sentinel.ledger.connections.
Create the first key
The default ring uses the config driver: the command prints three environment lines and never writes .env itself. Add them to your secret store and treat them as secrets:
php artisan sentinel:key:generate
# SENTINEL_KEY_ID=default-20261002-k3f9qa
# SENTINEL_ALGORITHM=hmac-sha256
# SENTINEL_KEY="base64:…"Use --algorithm=ed25519 (or ecdsa-p256-sha256, …) for asymmetric seals; the public half is printed as SENTINEL_PUBLIC_KEY for verify-only nodes. Keys for HTTP partners live in the http ring (database driver) — generate one, or import the partner’s own:
php artisan sentinel:key:generate --ring=http --database
php artisan sentinel:key:import acme-2026-10 --ring=http --algorithm=ed25519 --file=acme.pem --owner-type=partner --owner-id=7Seal a model and adopt existing rows
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Sentinel\Concerns\HasSeals;
use RoundlyConsulting\Sentinel\Contracts\Sealable;
use RoundlyConsulting\Sentinel\Definition\SealBuilder;
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');
}
}Every Eloquent write now seals the row. Seals are strict by default — a row without a seal is a finding — so baseline the rows that exist today, once, with a reason that ends up in the ledger. Rows that already have ledger history but lost their seal are reported, never baselined: a deleted seal is evidence.
php artisan sentinel:seal-missing "App\Models\Invoice" --reason="Initial baseline"Quick start
Verify wherever it matters — in code, as route middleware or in the scheduled scan:
use RoundlyConsulting\Sentinel\Facades\Sentinel;
$invoice->isIntact(); // true when every seal verifies
Sentinel::for($invoice)->verifyOrFail(); // throws TamperedModelException otherwise
Route::get('/invoices/{invoice}', ShowInvoice::class)->middleware('sentinel.verified');When someone changes a sealed value outside the application, verification names the changed column and the model is refused for further writes until someone acknowledges the change with a reason:
// Someone runs UPDATE invoices SET amount = 0 WHERE id = 42 in a SQL console:
Sentinel::for($invoice)->verify();
// VerificationResult { status: Tampered, reason: 'mac', changedAttributes: ['a:amount'], … }
$invoice->update(['note' => 'x']); // TamperedModelException: refused until acknowledged
Sentinel::for($invoice)->by($admin)->because('INC-88: refund fixed by the DBA')->acknowledge();Check the installation
There is nothing to schedule: Sentinel registers its upkeep on the Laravel scheduler itself (run php artisan schedule:run every minute, as for any scheduled task). Then run the health check — ten checks, each ok, warning or failure:
php artisan sentinel:check # exit 1 on a failure
php artisan sentinel:check --strict # warnings fail too (CI)Recommended production settings
- Keep the default ring on the config driver — keys stay out of the database.
- Set SENTINEL_CONTEXT to a value unique to the application when several apps share keys.
- Configure an anchor outside the application database: SENTINEL_ANCHORS=cache with SENTINEL_ANCHOR_CACHE_STORE pointing at a separate Redis, or filesystem on object storage with object lock.
- Verify-only servers: SENTINEL_AUTO_SEAL=false and only public key material.
Naming
laravel/sentinel (installed with Horizon, Pulse and Telescope) ships Laravel\Sentinel\Sentinel and Laravel\Sentinel\SentinelManager, and cartalyst/sentinel registers a global Sentinel alias. That is why this package registers no alias: import RoundlyConsulting\Sentinel\Facades\Sentinel and RoundlyConsulting\Sentinel\SentinelManager explicitly — an IDE may auto-import the wrong one. php artisan about shows the manager’s full class name.
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.