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:
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, no nonce) unless scripted; outbound sign() records the request and returns it unsigned. verifyDeferred() scripts the same signature, or the scripted rejection, into a DeferredSignature, and commitNonce() runs production’s commit over the fake’s in-memory nonce store: once only — a second commit, or a verifyDeferred() of a committed nonce, is replayed —, too_old past the window, no events. Script a signature with a nonce to exercise that; the synthetic one has none, so its commit always passes. Commits are recorded with the ring and key id, never the nonce.
- Sentinel::macs() is not faked — Sentinel holds no key material for it: sign() and verify() run the real actions with every rule and return the real MAC and index. Calls are recorded without the secrets or the verified MAC; a refused call records nothing. rejectMacsWithSecrets() makes every verify() return null.
- 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). |
rejectMacsWithSecrets() | Every Sentinel::macs()->verify() matches no secret and returns null — test your 401 path without forging a MAC. The production rules still apply and still throw. |
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). |
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->rejectMacsWithSecrets(); // every macs()->verify() returns null
$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-six 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, assertSignatureVerifiedDeferred, assertNonceCommitted, assertNonceNotCommitted |
| MACs | assertMacVerified, assertMacNotVerified, assertMacComputed, assertNoMacsComputed |
| MACs with your own secrets | assertMacSigned, assertMacsVerified, assertNoMacsWithSecrets |
$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');
$fake->fakeVerifiedSignature(new VerifiedSignature('sig1', 'http', 'acme-2026-10', Algorithm::Ed25519, time(), null, 'n-1', null, ['@method']));
$this->postJson('/webhooks/partner', ['malformed' => true])->assertStatus(400); // verifyDeferred(), then refused
$fake->assertSignatureVerifiedDeferred();
$fake->assertNonceNotCommitted(); // the sender's retry still verifies
$fake->rejectMacsWithSecrets();
$this->postJson('/webhooks/acme', $payload, ['Webhook-Signature' => 'v1,bogus'])->assertUnauthorized();
$fake->assertMacsVerified(matched: false);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):
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:
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 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.