Testing
Jwt::fake() swaps the manager for a recording fake over an in-memory RSA key pair — no key files needed, and an empty jwt.issuer, jwt.audience or service secret is filled with test values. It moves the package’s denylist onto a private in-memory cache store, so no Redis (or whatever jwt.denylist.store names) is needed; a Denylist binding of your own is left alone. Tokens are still really signed and verified, and every mint, service-token issue and deny is recorded — through the facade, an injected JwtManager, guard(), services() or denylist():
use App\Models\User;
use RoundlyConsulting\Jwt\Facades\Jwt;
use RoundlyConsulting\Jwt\Jose\Claims;
use RoundlyConsulting\Jwt\UserTokens\AccessTokenRequest;
it('mints, calls billing and logs out', function (): void {
$fake = Jwt::fake(); // in-memory RSA keys — no key files
$issued = Jwt::mintAccessToken(AccessTokenRequest::for('42'));
Jwt::services()->issue('billing', ['job' => 'sync']);
Jwt::logout($issued);
$fake->assertMinted(); // any user token
$fake->assertMinted(fn (Claims $claims): bool => $claims->string('sub') === '42');
$fake->assertServiceTokenIssued('billing');
$fake->assertServiceTokenIssued('billing', fn (Claims $claims): bool => $claims->get('job') === 'sync');
$fake->assertDenied($issued->jti);
$fake->assertDenied(); // any jti
expect($fake->minted())->toHaveCount(1); // list<RecordedToken> — ->token, ->claims
});
it('serves the profile to an authenticated user', function (): void {
$fake = Jwt::fake();
$user = User::factory()->create();
// Authenticate a jwt guard for the rest of the test — no hand-built token:
$fake->actingAs(['sub' => (string) $user->id, 'permissions' => ['posts.edit']], 'api');
$this->getJson('/me')->assertOk();
$fake->assertNothingMinted(); // actingAs() is not recorded as a mint
$fake->assertNothingIssuedToServices();
$fake->assertNothingDenied();
});Assertions
| Assert | Negative | Passes when |
|---|---|---|
assertMinted(?Closure $where = null) | assertNothingMinted() | A user token was minted — matching the Claims callback when given — through the facade, an injected JwtManager or guard(). |
assertServiceTokenIssued(?string $audience = null, ?Closure $where = null) | assertNothingIssuedToServices() | A service token was issued — for that audience and matching the callback, when given — including request() and authenticate(). |
assertDenied(?string $jti = null) | assertNothingDenied() | A jti (or that jti) was denylisted — denylist()->deny() / denyToken(), logout() or denyClaims(). |
$fake->minted(), serviceTokens() and denied() return what was recorded — RecordedToken objects with token and claims, and the denied jtis.
Authenticating a guard
actingAs($claims, $guard) mints a real token for the guard’s audience and scope — not recorded as a mint — and sends it as the bearer of every later request that has no Authorization header of its own. It returns the IssuedToken, e.g. for $this->withToken(). With an Eloquent provider, sub must be a real user’s key; a guard with token_version needs a matching tv.
Laravel’s actingAs()
Laravel’s own actingAs() works on both guards too, with no token at all — the guard keeps the user you set until Auth::guard('api')->forgetUser():
$this->actingAs($user, 'api')->getJson('/me')->assertOk(); // Eloquent or TokenUser
$this->actingAs($serviceIdentity, 'service')->postJson('/internal/sync')->assertOk();Jwt::guard('api')->claims() returns a set TokenUser’s claims (and Jwt::services()->claims() a set ServiceIdentity’s); any other user set this way has none. Call Jwt::fake() before Laravel’s actingAs() — the fake rebuilds the guards, dropping a user set earlier — and a later $fake->actingAs() replaces it.
Testing against a real keypair
To exercise your own key configuration, test with real tokens instead. Generate a throwaway keypair for the suite once — relative paths resolve from the application root:
JWT_PRIVATE_KEY_PATH=tests/keys/jwt-private.pem \
JWT_PUBLIC_KEY_PATH=tests/keys/jwt-public.pem \
php artisan jwt:generate-keysUser tokens and the jwt guard
Configure the pins and keys per test, keep the denylist in memory with the array cache store, and assert through the HTTP layer:
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use RoundlyConsulting\Jwt\Events\UserTokenIssued;
use RoundlyConsulting\Jwt\Facades\Jwt;
use RoundlyConsulting\Jwt\Jose\Exceptions\TokenExpired;
use RoundlyConsulting\Jwt\UserTokens\AccessTokenRequest;
beforeEach(function (): void {
config([
'jwt.issuer' => 'jwt-issuer',
'jwt.audience' => 'web',
'jwt.private_key_path' => base_path('tests/keys/jwt-private.pem'),
'jwt.public_key_path' => base_path('tests/keys/jwt-public.pem'),
'jwt.denylist.store' => 'array',
'auth.guards.api' => ['driver' => 'jwt'],
]);
Route::middleware('auth:api')->get('/me', fn () => ['id' => auth()->id()]);
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('authenticates a bearer token and rejects it after logout', function (): void {
$issued = Jwt::mintAccessToken(AccessTokenRequest::for('user-1'));
$this->withToken($issued->token)->getJson('/me')
->assertOk()
->assertJson(['id' => 'user-1']);
Jwt::logout($issued);
$this->withToken($issued->token)->getJson('/me')->assertUnauthorized();
});
it('rejects an expired token', function (): void {
$issued = Jwt::mintAccessToken(AccessTokenRequest::for('user-1')->ttl(60));
CarbonImmutable::setTestNow(CarbonImmutable::now()->addMinutes(5));
expect(fn () => Jwt::verify($issued->token))->toThrow(TokenExpired::class);
});
it('dispatches UserTokenIssued', function (): void {
Event::fake();
$issued = Jwt::mintAccessToken(AccessTokenRequest::for('user-1'));
Event::assertDispatched(UserTokenIssued::class, fn (UserTokenIssued $e): bool => $e->jti === $issued->jti);
});Time checks read CarbonImmutable::now(), so CarbonImmutable::setTestNow() moves a token past its expiry — reset it after each test.
Service tokens
Set a 32-byte-plus secret, this app’s service name and the service-jwt guard, then assert inbound calls and outbound headers with Http::fake():
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use RoundlyConsulting\Jwt\Facades\Jwt;
beforeEach(function (): void {
config([
'jwt.service.name' => 'notifications',
'jwt.service.secret' => 'a-very-secret-service-key-0123456789',
'jwt.service.issuer' => 'billing',
'auth.guards.service' => ['driver' => 'service-jwt'],
]);
});
it('accepts a signed internal call', function (): void {
Route::middleware('auth:service')->get('/internal', fn () => ['iss' => auth()->id()]);
$token = Jwt::services()->issue('notifications')->token;
$this->withToken($token)->getJson('/internal')
->assertOk()
->assertJson(['iss' => 'billing']);
});
it('signs outbound internal calls', function (): void {
Http::fake();
Jwt::services()->request('notifications')->post('https://notifications.internal/send', ['id' => 1]);
Http::assertSent(fn ($request) => $request->hasHeader('Authorization'));
});The package’s own suite runs with composer test:
composer testShow 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.