RefreshTokens::fake() swaps the manager for a recording fake and returns it. Calls still run for real — tokens are issued, redeemed and revoked in your test database, because a stub that answered redeem() differently from the real store would let a broken refresh flow pass — and every call is recorded, whether it came through the facade, an injected RefreshTokensManager, for()->issue(), sessions() / session() or the HasRefreshTokens trait:
use RoundlyConsulting\RefreshTokens\Facades\RefreshTokens;
$fake = RefreshTokens::fake();
// … exercise your login / refresh / logout endpoints …
$fake->assertIssued(for: $user);
$fake->assertIssued($user, fn (IssueContext $context): bool => $context->accessReference === $jti);
$fake->assertRedeemed(by: $user);
$fake->assertRotated(for: $user);
$fake->assertRevoked(for: $user, reason: RevocationReason::CredentialsChanged);
$fake->assertEnriched(for: $user);
$fake->assertPruned();
$fake->assertNothingIssued(); // …and assertNothingRedeemed/Rotated/Revoked/Enriched/Pruned()| Assertion | Matches |
|---|---|
assertIssued(?Model $for = null, ?Closure $where = null) / assertNothingIssued() | issue(), for()->issue() and $owner->issueRefreshToken(); $where receives the IssueContext. |
assertRedeemed(?Model $by = null) / assertNothingRedeemed() | redeem() — a failed redeem is recorded with no owner. |
assertRotated(?Model $for = null) / assertNothingRotated() | rotate() — recorded as a rotation only, not also as a redeem and an issue. |
assertRevoked(?Model $for = null, ?RevocationReason $reason = null) / assertNothingRevoked() | revoke($plain), every sessions()->revoke…(), session()->revoke() and the trait’s revoke verbs. |
assertEnriched(?Model $for = null) / assertNothingEnriched() | session()->enrich(). |
assertPruned() / assertNothingPruned() | prune() and the refresh-tokens:prune command. |
recorded() | Every call as a RecordedOperation (operation, owner, reason, context, count). |
A call that throws is not recorded. The assertions use PHPUnit’s Assert.
Access-token revocation
Bind the shipped FakeAccessTokenRevoker in place of your real revoker and assert exactly which access references the package asked to deny — no hand-rolled spy required:
use RoundlyConsulting\RefreshTokens\Contracts\AccessTokenRevoker;
use RoundlyConsulting\RefreshTokens\Testing\FakeAccessTokenRevoker;
$revoker = new FakeAccessTokenRevoker();
$this->app->instance(AccessTokenRevoker::class, $revoker);
// … exercise reuse detection / session revocation …
$revoker->assertRevoked($jti);
$revoker->assertNotRevoked($otherJti);
$revoker->assertRevokedCount(2);
$revoker->assertNothingRevoked(); // when nothing should have been deniedIts assertions throw RevokerAssertionFailedException rather than a PHPUnit assertion, so it works under any runner.
A full reuse-detection test
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\RefreshTokens\Contracts\AccessTokenRevoker;
use RoundlyConsulting\RefreshTokens\DataTransferObjects\IssueContext;
use RoundlyConsulting\RefreshTokens\DataTransferObjects\RotationContext;
use RoundlyConsulting\RefreshTokens\Events\RefreshTokenReuseDetected;
use RoundlyConsulting\RefreshTokens\Facades\RefreshTokens;
use RoundlyConsulting\RefreshTokens\Testing\FakeAccessTokenRevoker;
it('revokes the whole family when a rotated token is reused', function () {
Event::fake([RefreshTokenReuseDetected::class]);
$revoker = new FakeAccessTokenRevoker();
$this->app->instance(AccessTokenRevoker::class, $revoker);
$user = User::factory()->create();
$a = RefreshTokens::issue($user, new IssueContext(accessReference: 'acc-a'));
RefreshTokens::rotate($a->plainText, new RotationContext(accessReference: 'acc-b'));
expect(RefreshTokens::redeem($a->plainText))->toBeNull(); // replay = theft signal
$revoker->assertRevoked('acc-b');
$revoker->assertRevokedCount(1);
Event::assertDispatchedTimes(RefreshTokenReuseDetected::class, 1);
});Factory
The RefreshToken model ships a factory with expired(), revoked($reason), forFamily($id) and forOwner($owner) states:
use RoundlyConsulting\RefreshTokens\Enums\RevocationReason;
use RoundlyConsulting\RefreshTokens\Models\RefreshToken;
RefreshToken::factory()->forOwner($user)->create(); // a live session row
RefreshToken::factory()->forOwner($user)->expired()->create();
RefreshToken::factory()->forOwner($user)->revoked(RevocationReason::Logout)->create();
RefreshToken::factory()->forOwner($user)->forFamily($familyId)->create();Factory rows hold a random digest, never a real plaintext, so they can’t be redeemed — use them for listing, revoking and pruning tests, and issue() when you need a redeemable token.
The package’s own suite
composer test
composer test-coverageShow 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.