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

Every extension point is a contract in RoundlyConsulting\Sentinel\Contracts, resolved through the container — so Sentinel::fake() and host overrides keep working. Register drivers in a service provider’s boot():

use Illuminate\Contracts\Container\Container;
use RoundlyConsulting\Sentinel\Contracts\Anchor;
use RoundlyConsulting\Sentinel\Contracts\KeyStore;

// A key-store driver (e.g. a KMS): name it in keys.rings.<ring>.driver.
Sentinel::extend('vault', fn (Container $app, string $ring, array $config): KeyStore => new VaultKeyStore($ring, $config));

// An anchor: name it in SENTINEL_ANCHORS.
Sentinel::extendAnchor('s3-lock', fn (Container $app, array $config): Anchor => new ObjectLockAnchor($config));
ContractMethodsDefault / wiring
Contracts\KeyStoresigningKey(): SealingKey, find(string $keyId): ?SealingKey, all(): list<KeyInfo>, supportsWrites(): boolconfig, database, chain; yours via Sentinel::extend()
Contracts\Anchorname(): string, publish(AnchorPayload $payload): void, latest(string $connection): ?AnchorPayloadcache, filesystem, log; yours via Sentinel::extendAnchor() (latest() null = write-only)
Contracts\IdempotencyStorebegin(), complete(), release(), forget(), prune()from idempotency.store
Contracts\NonceStoreissue(), consume(), remember(), prune()from nonces.store
Contracts\IdempotencyScopeResolverresolve(Request $request): stringIdempotency\RequestScope (user, else signature key, else IP)
Contracts\AcknowledgementPolicyauthorize(AcknowledgeRequest $request): ?stringThe Gate check of acknowledgement.ability (null = allowed, else a denial code)
Contracts\SealDefinitiondefine(SealDefinitionBuilder $seal): void->using(MyDefinition::class)
Contracts\Sealablestatic defineSeals(SealBuilder $seals): voidyour models, with HasSeals

Key stores (KMS, Vault, HSM)

// config/sentinel.php
'keys' => ['rings' => ['financial' => ['driver' => 'vault', 'algorithms' => ['ed25519']]]],

interface KeyStore
{
    public function signingKey(): SealingKey;          // NoSigningKeyException when none
    public function find(string $keyId): ?SealingKey;  // any status; null when unknown
    /** @return list<KeyInfo> */
    public function all(): array;
    public function supportsWrites(): bool;            // false: generate/rotate print env lines
}

Build keys with the public extension surface. KeyMaterial validates material exactly as the built-in drivers do and keeps it out of dumps and serialization (fromEncoded(), generate(), canSign(), encodedPrivate(), encodedPublic()); return SealingKey instances with their effective status. The manager applies the revocation list on top of every driver, and keys are cached per request or job only.

use RoundlyConsulting\Sentinel\Enums\Algorithm;
use RoundlyConsulting\Sentinel\Enums\KeyStatus;
use RoundlyConsulting\Sentinel\Keys\KeyMaterial;
use RoundlyConsulting\Sentinel\Keys\SealingKey;

$material = KeyMaterial::fromEncoded(Algorithm::Ed25519, $secretFromVault, $publicFromVault);   // base64:… values

return new SealingKey($ring, 'vault-2026-10', $material, KeyStatus::Active, driver: 'vault');

Sentinel writes keys (generate, rotate, import, revoke, retire) only to a ring’s built-in database store — a ring on a custom driver alone is read-only to it; chain the custom driver with database to import partner keys at runtime.

Anchors

use RoundlyConsulting\Sentinel\Contracts\Anchor;
use RoundlyConsulting\Sentinel\DataTransferObjects\AnchorPayload;

final class ObjectLockAnchor implements Anchor
{
    public function name(): string { return 's3-lock'; }

    public function publish(AnchorPayload $payload): void { /* write it immutably */ }

    public function latest(string $connection): ?AnchorPayload { /* null = write-only */ }
}

Name it in SENTINEL_ANCHORS=s3-lock; its options live under ledger.anchor_drivers.s3-lock. Publishing runs after commit; a throwable becomes AnchorPublishFailed and a retry on the next run — except InvalidSentinelConfigurationException, which is thrown.

Idempotency and nonce stores

use RoundlyConsulting\Sentinel\Contracts\IdempotencyStore;
use RoundlyConsulting\Sentinel\Contracts\NonceStore;

$this->app->bind(IdempotencyStore::class, DynamoIdempotencyStore::class);
$this->app->bind(NonceStore::class, DynamoNonceStore::class);

An idempotency store must make begin() atomic per key — of concurrent calls exactly one gets Proceed — and follow the decision table: another fingerprint → Reused, a valid lease → InProgress with retryAfter, an expired lease → take over, completed → Replay or Unavailable. complete() succeeds only while the request still owns the key; consume() and remember() succeed for exactly one caller; edited or unreadable records fail closed. The fake’s InMemoryIdempotencyStore and InMemoryNonceStore are compact reference implementations.

Idempotency scope

The default Idempotency\RequestScope returns user:<guard>:<id>, else sig:<ring>:<kid>, else ip:<client ip>, and the key is further bound to the route. Rebind the contract to change the first part:

use Illuminate\Http\Request;
use RoundlyConsulting\Sentinel\Contracts\IdempotencyScopeResolver;

final class TenantScope implements IdempotencyScopeResolver
{
    public function resolve(Request $request): string
    {
        return 'tenant:'.$request->user()?->tenant_id.':user:'.$request->user()?->getKey();
    }
}

$this->app->bind(IdempotencyScopeResolver::class, TenantScope::class);

Acknowledgement policy

use RoundlyConsulting\Sentinel\Contracts\AcknowledgementPolicy;
use RoundlyConsulting\Sentinel\DataTransferObjects\AcknowledgeRequest;

final class TwoPersonRule implements AcknowledgementPolicy
{
    public function authorize(AcknowledgeRequest $request): ?string   // null = allowed, else a denial code
    {
        return Approval::grantedFor($request->model, $request->seal, $request->actor) ? null : 'approval_required';
    }
}

$this->app->bind(AcknowledgementPolicy::class, TwoPersonRule::class);

A denial throws AcknowledgementDeniedException::unauthorized($code) — through the facade, the handle, bulk acknowledgements and sentinel:reseal --acknowledge alike.

Default bindings

Contract / serviceDefaultLifetime
SentinelManageritselfsingleton
Contracts\AcknowledgementPolicySupport\GateAcknowledgementPolicybindIf — your binding wins
Contracts\IdempotencyScopeResolverIdempotency\RequestScopebindIf
Contracts\IdempotencyStorefrom idempotency.store (database / cache)resolved per use; a host binding replaces it
Contracts\NonceStorefrom nonces.store (database / cache)resolved per use; a host binding replaces it
key-store and anchor factories, compiled definitions—singletons (code only, no key material)
loaded keys, suspension flags, engine services—scoped: one request or job (Octane-safe)

The manager resolves its action from the container on every call, so a container binding of an action class takes effect everywhere.

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.