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

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) unless scripted; outbound sign() records the request and returns it unsigned.
  • Key imports are validated and parsed as in production; results are synthetic KeyInfos and nothing is stored.
  • Scans honour where and progress, 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.

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:

ControlEffect
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).
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\LedgerFinding;
use RoundlyConsulting\Sentinel\DataTransferObjects\VerifiedSignature;
use RoundlyConsulting\Sentinel\Enums\{Algorithm, LedgerFindingKind, 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->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

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

AreaAssertions
SealsassertSealed, assertNotSealed, assertNothingSealed, assertVerified, assertNotVerified, assertNothingVerified, assertAcknowledged, assertNotAcknowledged, assertNothingAcknowledged, assertUnsealed, assertNothingUnsealed, assertSealingSuspended, assertSealingNotSuspended
Bulk and ledgerassertScanned, assertResealed, assertNothingResealed, assertCheckpointed, assertLedgerVerified
KeysassertKeyGenerated, assertKeyImported, assertKeyRotated, assertKeyRevoked, assertKeyRetired, assertNoKeyChanges
Idempotency and noncesassertIdempotentRun, assertNoIdempotentRuns, assertIdempotencyKeyForgotten, assertNonceIssued, assertNoNoncesIssued, assertNonceConsumed, assertNonceNotConsumed, assertSingleUseUrlIssued, assertPruned
SignaturesassertRequestSigned, assertNothingSigned, assertSignatureVerified
$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):

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