---
title: "User model — Passkeys for Laravel | Roundly"
description: "Add the opaque user-handle column, implement HasPasskeys with the InteractsWithPasskeys trait, and run ceremonies straight off the user."
url: https://roundly-consulting.com/open-source/docs/passkeys-for-laravel/user-model
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)

# User model

Any model that can own passkeys implements the HasPasskeys contract; the InteractsWithPasskeys trait is a ready-made implementation. First add a nullable, unique column for the opaque, non-PII user handle — the passkeyUserHandle() Blueprint macro creates it on any account table, named after passkeys.user.handle\_column:

```php
// database/migrations/xxxx_add_passkey_user_handle_to_users_table.php
Schema::table('users', function (Blueprint $table): void {
    $table->passkeyUserHandle();   // string(passkeys.user.handle_column)->nullable()->unique()
});
```

Then wire the model:

```php
use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Passkeys\Concerns\InteractsWithPasskeys;
use RoundlyConsulting\Passkeys\Contracts\HasPasskeys;

final class User extends Authenticatable implements HasPasskeys
{
    use InteractsWithPasskeys;
}
```

A host with several account models (guards) adds the column to every table whose model owns passkeys.

## Static analysis

The Blueprint macro is declared for Larastan / PHPStan as passkeyUserHandle(): ColumnDefinition. A macro exists only once its service provider has run. Larastan boots an auto-discovering host app and usually finds it, but not when the provider isn’t loaded during analysis — package development under Testbench, dont-discover, a provider registered by hand. There, a migration calling the macro would fail with “Call to an undefined method”. The package ships a PHPStan extension (RoundlyConsulting\\Passkeys\\PHPStan\\PasskeyMacrosExtension) that phpstan/extension-installer loads through extra.phpstan.includes — nothing to configure. Without the installer, include it yourself:

```yaml
includes:
    - vendor/roundly-consulting/passkeys-for-laravel/extension.neon
```

## The contract

These four methods are all the ceremonies need. The trait implements them; implement them yourself when you need bespoke handle storage or name resolution:

```php
interface HasPasskeys
{
    public function passkeyUserHandle(): string;   // opaque, non-PII, stable per user
    public function passkeyUserName(): string;      // authenticator "name" (e.g. email)
    public function passkeyDisplayName(): string;   // authenticator "display name" (e.g. full name)
    public function passkeys(): MorphMany;          // the user's stored credentials
}
```

## Ceremony verbs on the model

The trait also exposes the ceremonies as verbs, so the user model is the subject. They are shorthand for the account handle — each delegates to Passkeys::for($this), so host overrides and Passkeys::fake() see them too:

```php
$options = $user->passkeyRegistrationOptions();                   // Passkeys::for($user)->registrationOptions()
$passkey = $user->registerPasskey($response, 'MacBook Touch ID'); // ->register()
$options = $user->passkeyAuthenticationOptions($overrides);       // ->authenticationOptions()

$user->hasPasskeys();   // ->exists() — at least one active (non-revoked) passkey
$user->passkeyCount();  // ->count()
```

- passkeys() — the MorphMany relationship to the user’s stored credentials.
- passkeyRegistrationOptions(?RegistrationOptionsOverrides) — creation options for this user.
- registerPasskey(RegistrationResponseData, ?string $name) — verify and store a credential for this user.
- passkeyAuthenticationOptions(?AuthenticationOptionsOverrides) — request options bound to this user’s credentials.
- hasPasskeys() / passkeyCount() — whether and how many active (non-revoked) passkeys the account holds.

## The user handle

The handle is generated lazily and persisted on the first registrationOptions() or user-bound authenticationOptions() call — a small write on an otherwise read-shaped call. The write is surgical:

- It updates only the handle column, so an unsaved edit on the model stays unsaved — the model is never re-saved.
- It only fills an empty column, so under concurrent first-time requests the first handle stored wins and every request uses it.
- It never inserts an unsaved model: on a model that doesn’t exist yet the handle is only set, and the model’s own save persists it.

That makes eager generation a one-liner — call passkeyUserHandle() while the user is being created:

```php
// e.g. in a User creating() observer
$user->passkeyUserHandle(); // sets base64url(random_bytes(passkeys.user.handle_bytes))
```

If you mint handles yourself, they must be base64url of random bytes — at least 16, no padding — for example Base64Url::encode(random\_bytes(32)) with RoundlyConsulting\\Crypto\\Codec\\Base64Url. The ceremony decodes the handle strictly, so a string that merely looks like one, such as Str::random(43), is refused with InvalidClientData.

Keep the handle stable. Every passkey stores the handle it was registered under, and every sign-in — usernameless or user-bound — is refused with CredentialNotFound when the owner’s stored handle no longer matches it. Overwriting the column therefore revokes the account’s passkeys in effect, and a new account that reuses a deleted account’s ID never inherits them. For the trait the sign-in reads the column directly, so it never mints a handle; a custom HasPasskeys implementation is asked through passkeyUserHandle().

## Name and display name

The authenticator shows an account name and a display name. They default to the model’s email and name attributes (falling back to the primary key). Repoint them globally with passkeys.user.name\_attribute and display\_name\_attribute, or override the methods for per-model logic:

```php
public function passkeyDisplayName(): string
{
    return trim($this->first_name.' '.$this->last_name) ?: $this->email;
}
```

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