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

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()
AssertionMatches
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 denied

Its 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-coverage

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.