---
title: "Extending — Sentinel for Laravel | Roundly"
description: "Plug in a KMS key store or a custom anchor, and bind your own idempotency and nonce stores, scope resolver or acknowledgement policy."
url: https://roundly-consulting.com/open-source/docs/sentinel-for-laravel/extending
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Sentinel for Laravel](https://roundly-consulting.com/open-source/docs/sentinel-for-laravel.md)

# Extending

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():

```php
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));
```

| Contract | Methods | Default / wiring |
| --- | --- | --- |
| `Contracts\KeyStore` | `signingKey(): SealingKey, find(string $keyId): ?SealingKey, all(): list<KeyInfo>, supportsWrites(): bool` | config, database, chain; yours via Sentinel::extend() |
| `Contracts\Anchor` | `name(): string, publish(AnchorPayload $payload): void, latest(string $connection): ?AnchorPayload` | cache, filesystem, log; yours via Sentinel::extendAnchor() (a write-only one returns null from latest() and implements the Contracts\\WriteOnlyAnchor marker) |
| `Contracts\IdempotencyStore` | `begin(), complete(), release(), forget(), prune()` | from idempotency.store |
| `Contracts\NonceStore` | `issue(), consume(), remember(), prune()` | from nonces.store |
| `Contracts\IdempotencyScopeResolver` | `resolve(Request $request): string` | Idempotency\\RequestScope (user, else signature key, else IP) |
| `Contracts\AcknowledgementPolicy` | `authorize(AcknowledgeRequest $request): ?string` | The Gate check of acknowledgement.ability (null = allowed, else a denial code) |
| `Contracts\SealDefinition` | `define(SealDefinitionBuilder $seal): void` | \->using(MyDefinition::class) |
| `Contracts\Sealable` | `static defineSeals(SealBuilder $seals): void` | your models, with HasSeals |

## Key stores (KMS, Vault, HSM)

```php
// 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 — in a chain ring a custom store’s revoked signing key is skipped and the next store signs, as with the built-in drivers — and keys are cached per request or job only.

```php
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. While a ring signs with a custom driver’s key (alone, or first in a chain), rotateKey() and sentinel:key:rotate refuse with KeyDriverException: rotate that key in its own store — Sentinel never prints a custom store’s material.

## Anchors

```php
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 { /* what the anchor really holds */ }
}
```

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. Throw when your store did not keep the payload (the built-in drivers throw AnchorPublishException::notWritten() when a disk or cache returns false). Before publishing, Sentinel reads latest() and refuses to replace a payload the database no longer agrees with, so latest() must return what the anchor really holds.

A write-only anchor (a log shipped elsewhere, a notary you cannot query) returns null from latest() and implements the Contracts\\WriteOnlyAnchor marker — otherwise a run with nothing pending reads the null as “holds nothing” and re-sends the newest checkpoint every minute:

```php
use RoundlyConsulting\Sentinel\Contracts\WriteOnlyAnchor;

final class NotaryAnchor implements WriteOnlyAnchor
{
    public function name(): string { return 'notary'; }

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

    public function latest(string $connection): ?AnchorPayload { return null; }
}
```

## Idempotency and nonce stores

```php
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:

```php
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

```php
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 / service | Default | Lifetime |
| --- | --- | --- |
| `SentinelManager` | itself | singleton |
| `Contracts\AcknowledgementPolicy` | `Support\GateAcknowledgementPolicy` | bindIf — your binding wins |
| `Contracts\IdempotencyScopeResolver` | `Idempotency\RequestScope` | bindIf |
| `Contracts\IdempotencyStore` | from idempotency.store (database / cache) | resolved per use; a host binding replaces it |
| `Contracts\NonceStore` | from 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/sentinel-for-laravel.md)
