---
title: "Testing — Passkeys for Laravel | Roundly"
description: "Record and assert ceremonies with Passkeys::fake(), or run real ceremonies end to end with the software VirtualAuthenticator."
url: https://roundly-consulting.com/open-source/docs/passkeys-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Passkeys for Laravel](https://roundly-consulting.com/open-source/docs/passkeys-for-laravel.md)

# Testing

Passkeys::fake() swaps the relying party for PasskeysFake — a programmable, no-crypto double bound under the PasskeyService contract — and returns it for assertions. You can assert your enrolment and login controllers without reproducing authenticator crypto. It performs no CBOR/COSE decoding, signature verification or challenge check, and is bound only for the test. The facade, an injected PasskeyService and the model verbs all see it:

- Ceremonies are recorded, not performed: register() persists a factory-built credential and authenticate() returns the programmed outcome. The options are built like the real ones except for a canned ceremony ID, challenge and relying party (localhost / Fake): they honour every override, take their defaults from passkeys.\* (timeout, user verification, resident key, attestation, algorithms) and, for Passkeys::for($user), list the account’s active passkeys in excludeCredentials / allowCredentials. They never require rp.id or origins, and the creation options decode the user handle exactly as the real service does, so a malformed handle is refused there too.
- The side effects stay real: a fake register() stores the credential with last\_used\_at null and fires PasskeyRegistered; a successful authenticate() stamps last\_used\_at (the counter is not moved) and fires PasskeyAuthenticated. Both go through the app’s event dispatcher, so your listeners run unless the test wraps them in Event::fake(). A programmed failure writes and fires nothing.
- Calls through the model verbs (registerPasskey() and the rest) are recorded as well — they route through Passkeys::for($this).
- rename() and revoke() still run the real, ownership-checked actions — writes and PasskeyRenamed / PasskeyRevoked included — and are recorded once they succeed.
- Reads (all(), find(), count(), exists()) go to the database; attestationFormats() returns the real registry’s formats.

```php
use RoundlyConsulting\Passkeys\Facades\Passkeys;

$fake = Passkeys::fake();

// The fake skips the crypto, not your controller's parsing: RegistrationResponseData::fromArray()
// still needs the browser's shape with base64url members — any bytes will do.
$this->postJson('/passkeys', [
    'id' => 'AAAA',
    'rawId' => 'AAAA',
    'type' => 'public-key',
    'response' => ['clientDataJSON' => 'e30', 'attestationObject' => 'oA'],
])->assertCreated();

$fake->assertRegisteredFor($user);

$this->deleteJson("/passkeys/{$passkey->id}")->assertNoContent();
$fake->assertRevoked($passkey);
$fake->assertNothingRenamed();

// programmable outcomes:
Passkeys::fake()->rejectAuthentication();          // authenticate() throws CredentialNotFound
Passkeys::fake()->authenticatesAs($passkey);       // authenticate() returns it — if still stored, unrevoked and owned
Passkeys::fake()->failRegistrationWith($exception);
```

## Programmable outcomes

| Method | Effect |
| --- | --- |
| `acceptRegistration()` | register() persists a factory-built ES256 credential for the user (the default). |
| `failRegistrationWith($e)` | register() throws the given PasskeyException instead of persisting. |
| `acceptAuthentication()` | authenticate() succeeds (the default). |
| `rejectAuthentication()` | authenticate() throws CredentialNotFound. |
| `authenticatesAs($passkey)` | authenticate() returns exactly this credential — refused like the real service once it is revoked, if it was never saved, or when its owner no longer holds it. Seed it with Passkey::factory()->forAuthenticatable($user). |

## What the fake refuses

The fake refuses what the real verifier refuses without crypto, in the same order: the credential must still be stored and not revoked, its owner must still hold it, and it must meet the AuthenticationExpectation. A refusal is recorded as a failed authentication and throws CredentialNotFound. The owner check is the real verifier’s own — the owner must exist, must not be soft-deleted and must still hold the passkey’s user handle — and applies to the authenticatesAs() passkey and the last fake-registered one. A passkey whose owner type names no model is refused the same way.

One passkey is exempt: the one the fake invents when nothing is seeded — no authenticatesAs(), no fake registration. It stands for “any valid credential”, so its placeholder owner is not checked. Seed a sign-in with a passkey its owner holds: Passkey::factory()->forAuthenticatable($user) gives it the user’s handle, while a bare Passkey::factory()->create() is refused.

## Assertions

| Assertion | Passes when |
| --- | --- |
| `assertRegistered()` | At least one registration was recorded. |
| `assertRegisteredFor($user)` | A registration was recorded for $user. |
| `assertNothingRegistered()` | No registration was recorded. |
| `assertRegistrationCount($n)` | Exactly $n registrations were recorded. |
| `assertAuthenticated()` | At least one successful authentication was recorded. |
| `assertAuthenticatedFor($user)` | A successful authentication was recorded for $user. |
| `assertAuthenticationFailed()` | At least one authentication failed. |
| `assertAuthenticationCount($n)` | Exactly $n authentications were recorded. |
| `assertRenamed(?$passkey, ?$name)` | A rename was recorded — of this passkey and/or to this name, when given. |
| `assertNothingRenamed()` | No rename was recorded. |
| `assertRevoked(?$passkey)` | A revocation was recorded — of this passkey, when given. |
| `assertNothingRevoked()` | No revocation was recorded. |

Assertions throw PasskeyAssertionFailed — a package exception, not a PHPUnit assertion — so they work under any runner. The fake honours AuthenticationExpectation the way the real verifier does, and for($user)->authenticate() holds the result to that account — a credential of the wrong owner is recorded as a failure and throws CredentialNotFound:

```php
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Passkeys\Events\PasskeyRegistered;
use RoundlyConsulting\Passkeys\Exceptions\CredentialAlreadyRegistered;
use RoundlyConsulting\Passkeys\Models\Passkey;

// A rejected sign-in
$fake = Passkeys::fake()->rejectAuthentication();
$this->postJson('/login/passkey', $payload)->assertUnauthorized();
$fake->assertAuthenticationFailed();

// A registration failure surfaced to the client
$fake = Passkeys::fake()->failRegistrationWith(CredentialAlreadyRegistered::make());
$this->actingAs($user)->postJson('/passkeys', $payload)->assertStatus(422);
$fake->assertNothingRegistered();

// Sign-in resolves to a specific credential
$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
Passkeys::fake()->authenticatesAs($passkey);
$this->postJson('/login/passkey', $payload)->assertOk();

// A deleted or soft-deleted account can't sign in
$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
$fake = Passkeys::fake()->authenticatesAs($passkey);
$user->delete();
$this->postJson('/login/passkey', $payload)->assertUnauthorized();
$fake->assertAuthenticationFailed();

// Your listener runs on a fake registration
Event::fake([PasskeyRegistered::class]);
Passkeys::fake();
$this->actingAs($user)->postJson('/passkeys', $payload)->assertCreated();
Event::assertDispatched(PasskeyRegistered::class);
```

## Real ceremonies with the virtual authenticator

When you want the real verifier in your suite — challenge, origin, RP ID hash, flags, signature, sign counter, user binding — drive it with VirtualAuthenticator, a software ES256 authenticator that attests with none:

```php
use RoundlyConsulting\Passkeys\DataTransferObjects\AuthenticationOptionsOverrides;
use RoundlyConsulting\Passkeys\Enums\UserVerification;
use RoundlyConsulting\Passkeys\Facades\Passkeys;
use RoundlyConsulting\Passkeys\Testing\VirtualAuthenticator;

$authenticator = VirtualAuthenticator::es256();   // rpId from the options, origin from config
$keys = Passkeys::for($user);

$keys->register($authenticator->register($keys->registrationOptions()));

$passkey = $keys->authenticate($authenticator->assert($keys->authenticationOptions()));

// Prove your step-up really demands user verification:
$options = $keys->authenticationOptions(new AuthenticationOptionsOverrides(userVerification: UserVerification::Required));
$keys->authenticate($authenticator->assert($options, userVerified: false)); // UserVerificationRequired

$authenticator->assert($options, signCount: 3);   // explicit counter, e.g. a cloned key
$authenticator->credentialId();                   // base64url, as stored in credential_id
```

- register($options, residentKey: false) models a non-discoverable credential, with no userHandle on assertions — so, as with a real browser, it can only answer options minted for its user (Passkeys::for($user)->authenticationOptions()); a usernameless ceremony refuses it.
- The counter advances by one per assert() unless signCount is given; userVerified: false sends presence only.
- It answers any options it is given, so a suite can also play the attacker and prove the server refuses.
- Test-only: its key is throwaway material from crypto-for-laravel’s TestKeys, so never use it in production code. It lives in runtime autoload only so other packages’ suites can reach it — the same posture as Passkeys::fake(). On macOS with Homebrew PHP, key generation may need OPENSSL\_CONF pointed at Homebrew’s openssl.cnf.

## Factory

Passkey::factory() builds valid stored credentials for your own tests, using the configured model:

```php
use RoundlyConsulting\Passkeys\Models\Passkey;

$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
$rsa     = Passkey::factory()->rs256()->neverUsed()->forAuthenticatable($user)->create();
```

| State | Effect |
| --- | --- |
| `es256()` | A valid ES256 (P-256) COSE public key. |
| `rs256()` | A valid RS256 (RSA-2048) COSE public key. |
| `withCredentialId($id)` | Keys the credential on an explicit ID and its hash. |
| `forAuthenticatable($model)` | Owns the passkey by a specific model. A HasPasskeys model also gives it its user handle (minted on the model if it has none yet), as a real registration does; any other model keeps the random handle. |
| `neverUsed()` | sign\_count = 0, last\_used\_at = null. |

Without forAuthenticatable(), a factory passkey’s owner is a placeholder (morph type user, a random ID) that no account matches, so a sign-in refuses it — under the fake and the real verifier alike.

## 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/passkeys-for-laravel.md)
