---
title: "Testing — Sentinel for Laravel | Roundly"
description: "Swap in Sentinel::fake() to script statuses and assert every call, or run real seals in your suite with WithSentinelKeys."
url: https://roundly-consulting.com/open-source/docs/sentinel-for-laravel/testing
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)

# Testing

Sentinel::fake() swaps the manager — in the facade and in the container, so an injected SentinelManager is faked too — for a recording fake that needs no keys and writes no seals or ledger entries:

```php
use RoundlyConsulting\Sentinel\Enums\VerificationStatus;
use RoundlyConsulting\Sentinel\Facades\Sentinel;

$fake = Sentinel::fake();

$fake->fakeStatus($invoice, VerificationStatus::Tampered, 'financial', changed: ['a:amount']);
$this->get("/invoices/{$invoice->id}")->assertStatus(409);

Sentinel::assertVerified($invoice);
Sentinel::assertNothingAcknowledged();

$fake->fakeLedgerFindings(new LedgerFinding(LedgerFindingKind::ChainBroken, 3, 17, Invoice::class, 1, 'financial', 'previous digest', 'mysql'));
$this->artisan('sentinel:verify --ledger --allow-empty')->assertExitCode(1);
```

## Production semantics it keeps

- Definitions are compiled by the real registry: an unknown seal, a model that is not sealable or an invalid definition throws exactly as in production.
- The tampered-write policy applies to scripted statuses: with refuse, an Eloquent update of a model faked as tampered throws TamperedModelException.
- Reasons, actors, the acknowledgement policy and sealing.allow\_suspension are enforced with the same exceptions.
- Idempotency (the job middleware too) and nonces run the real state machine over in-memory stores: replay, 409, 422, single consumption.
- sentinel.signed and verify() return a synthetic VerifiedSignature (key id fake) unless scripted; outbound sign() records the request and returns it unsigned.
- Keys: rings, algorithms, kids, labels and reasons are validated, and nothing reaches the real store. Imports are parsed as in production (a taken kid included). Generated, imported and rotated database keys are then returned by findKey(), listKeys() and currentKey() with their effective status and owner; rotateKey() starts from the key signing now, demotes the faked keys that could still sign and refuses a custom driver’s key; revokeKey() and retireKey() return the dates production returns.
- MACs: verifyMac() keeps production’s checks of the ring, the kid, the key’s status and algorithm (faked and real keys, revocation list included) and the MAC’s encoding and length — only the MAC itself is not computed, so a well-formed MAC for a key that verifies is accepted. mac() checks the ring and the signing key and returns a placeholder IssuedMac, well-formed and stable but never the real MAC. Refusals are recorded and thrown as in production.
- Writes and models: verifying an unsaved model throws SealingFailedException, changing a sealed row’s primary key throws SealingMisconfiguredException, an increment() re-seals a seal covering updated\_at, and unseal() returns whether there was a seal to remove.
- Scans honour where and progress, set truncated past maxFindings and, with checkSchema, refuse a sealed column the table lacks; reseal() applies onlyOutdated, upgradeFormat and fromKeyId; and fakeLedgerFindings() drives verifyLedger() with the real LedgerReport semantics, so sentinel:verify --ledger exits 1 on a scripted violation.
- It records every call, including those through HasSeals, the handles and the sub-accessors — and fires no events.

Declared approximations: the fake holds no keys for seals, so reseal() counts every intact or outdated row as re-sealed (it cannot tell a row already on the current key), and sealMissing() adopts every row it has no history for.

## Controls

Default verification is Intact. Every control returns the fake, so they chain, and the controls and assertions are also callable statically on the facade:

| Control | Effect |
| --- | --- |
| `fakeStatus(Model $model, VerificationStatus $status, ?string $seal = null, ?array $changed = null, ?string $reason = null)` | Every verification of the model (one seal, or all) returns this status until changed — or until the fake re-seals or acknowledges that seal. A Missing defaults to seal\_deleted (which seal() refuses); script never\_sealed for a row never sealed. |
| `fakeStatusOnce(...)` | The next verification only. |
| `fakeVerifiedSignature(?VerifiedSignature $signature = null)` | What signature verification returns. |
| `rejectSignatures(SignatureRejection $reason)` | Every signature verification is rejected (401). |
| `fakeVerifiedMac(?KeyInfo $key = null)` | Every MAC verification on a MAC ring succeeds and returns this key (null = a synthetic hmac-sha256 key of the asked ring and kid, driver fake); the kid, the key and the encoding go unchecked. Lifts rejectMacs(). |
| `rejectMacs(MacRejection $reason)` | Every MAC verification on a MAC ring is refused with this reason (MacVerificationException). |
| `fakeLedgerFindings(LedgerFinding ...$findings)` | Every ledger verification reports these findings until called again — with none for a clean ledger. |
| `recorded(?string $method = null)` | list<RecordedCall> (method, arguments, result). |

```php
use RoundlyConsulting\Sentinel\DataTransferObjects\KeyInfo;
use RoundlyConsulting\Sentinel\DataTransferObjects\LedgerFinding;
use RoundlyConsulting\Sentinel\DataTransferObjects\VerifiedSignature;
use RoundlyConsulting\Sentinel\Enums\{Algorithm, KeyStatus, LedgerFindingKind, MacRejection, SignatureRejection};

$fake->fakeStatusOnce($invoice, VerificationStatus::Stale, 'financial');          // the next verification only
$fake->fakeVerifiedSignature(new VerifiedSignature('sig1', 'http', 'acme-2026-10', Algorithm::Ed25519, time(), null, 'n-1', null, ['@method'], 'partner', 7));
$fake->rejectSignatures(SignatureRejection::Replayed);                             // sentinel.signed answers 401
$fake->fakeVerifiedMac(new KeyInfo('logs', 'billing-1', Algorithm::HmacSha256, KeyStatus::VerifyOnly, 'database', false, label: 'billing'));
$fake->rejectMacs(MacRejection::Mismatch);                                         // every verifyMac() throws
$fake->fakeLedgerFindings(new LedgerFinding(LedgerFindingKind::AnchorAhead, 12, null, null, null, null, 'anchor seq 14 > 12', 'mysql'));
$fake->fakeLedgerFindings();                                                       // a clean ledger again

foreach ($fake->recorded('acknowledge') as $call) {
    $call->method;      // 'acknowledge'
    $call->arguments;   // the request DTO, or the list of scalar arguments
    $call->result;      // what the fake returned
}
```

## Assertions

Forty assertions, each passing and failing like PHPUnit’s — a failing one throws ExpectationFailedException:

| Area | Assertions |
| --- | --- |
| Seals | `assertSealed, assertNotSealed, assertNothingSealed, assertVerified, assertNotVerified, assertNothingVerified, assertAcknowledged, assertNotAcknowledged, assertNothingAcknowledged, assertUnsealed, assertNothingUnsealed, assertSealingSuspended, assertSealingNotSuspended` |
| Bulk and ledger | `assertScanned, assertResealed, assertNothingResealed, assertCheckpointed, assertLedgerVerified` |
| Keys | `assertKeyGenerated, assertKeyImported, assertKeyRotated, assertKeyRevoked, assertKeyRetired, assertNoKeyChanges` |
| Idempotency and nonces | `assertIdempotentRun, assertNoIdempotentRuns, assertIdempotencyKeyForgotten, assertNonceIssued, assertNoNoncesIssued, assertNonceConsumed, assertNonceNotConsumed, assertSingleUseUrlIssued, assertPruned` |
| Signatures | `assertRequestSigned, assertNothingSigned, assertSignatureVerified` |
| MACs | `assertMacVerified, assertMacNotVerified, assertMacComputed, assertNoMacsComputed` |

```php
$fake = Sentinel::fake();

$invoice->update(['amount' => '12.00']);                           // through the model trait
$fake->assertSealed($invoice, 'financial', fn (SealResult $seal) => $seal->event === SealEvent::Resealed);

Sentinel::idempotency()->run('charge:42', 'billing', fn () => ['ok' => true]);
Sentinel::idempotency()->run('charge:42', 'billing', fn () => ['ok' => true]);
$fake->assertIdempotentRun('charge:42', replayed: true);

Http::fake();
Http::withSignature('acme-2026-10')->post('https://partner.example/events', []);
$fake->assertRequestSigned('acme-2026-10');
```

## Real seals in your suite

A sealable model’s factory needs a signing key, and your test environment usually has none — the first create() throws NoSigningKeyException, whose message points here. Add WithSentinelKeys to your test case: before each test, every config ring without a key gets a fresh throwaway HMAC-SHA-256 key (kid test-<ring>, process memory only — nothing is written to .env or the database):

```php
use RoundlyConsulting\Sentinel\Testing\WithSentinelKeys;

uses(TestCase::class, RefreshDatabase::class, WithSentinelKeys::class)->in('Feature');
```

SentinelTestKeys::install(app(), Algorithm::Ed25519, rings: \['http'\]) does the same on demand — another algorithm, or a database ring (chained behind a config key). A ring that already has a configured key is never touched.

## In-memory stores

The fake keeps idempotency keys and nonces in its own InMemoryIdempotencyStore / InMemoryNonceStore — one per fake, so nothing leaks between tests. Bind one on its own to run the real manager without the tables:

```php
use RoundlyConsulting\Sentinel\Contracts\IdempotencyStore;
use RoundlyConsulting\Sentinel\Testing\InMemoryIdempotencyStore;

$this->app->instance(IdempotencyStore::class, new InMemoryIdempotencyStore);
```

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