Database schema
One publish-only migration creates the table (refresh_tokens by default). There are no foreign keys:
| Columns | Type | Notes |
|---|---|---|
id | bigint | Row id — changes on every rotation; address sessions by family_id instead. |
owner_type, owner_id | string + bigint/uuid/ulid | Polymorphic owner with a composite index; owner_id follows key_type. |
token_hash | string(128), unique | At-rest digest; fits sha512 hex. Hidden from serialization. |
family_id | uuid, indexed | The stable session id, canonicalised to lowercase. |
access_reference | string(64), nullable, indexed | Opaque link to the access token. Hidden from serialization. |
user_agent, browser, browser_version, os, os_version, device_type, is_bot | nullable | Device data — host-supplied, carried forward on rotation. |
country, city, country_code, ip_address | nullable | Geo data — host-supplied, carried forward on rotation. |
family_started_at, absolute_expires_at, meta | nullable; meta is jsonb | Session columns every row of a family inherits. |
revoked_reason, expires_at, revoked_at | lifecycle | expires_at and revoked_at are indexed. |
created_at, updated_at, deleted_at | timestamps | Standard timestamps plus soft deletes. |
Adopting an existing tokens table
RefreshTokenBlueprint reproduces the package shape from your own migration: columns() emits the full table, and addSessionColumns() adds only the three session columns — all nullable, so they fit a populated table:
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use RoundlyConsulting\PackageToolkit\Enums\KeyType;
use RoundlyConsulting\RefreshTokens\Support\RefreshTokenBlueprint;
// Reproduce the package table in your own migration:
Schema::create('refresh_tokens', function (Blueprint $table): void {
RefreshTokenBlueprint::columns($table, KeyType::Ulid);
});
// Or add only the session columns to an existing tokens table:
Schema::table('refresh_tokens', function (Blueprint $table): void {
RefreshTokenBlueprint::addSessionColumns($table); // family_started_at, absolute_expires_at, meta
});Backfill owner_type, family_started_at and absolute_expires_at in the same migration. Use the family’s first row as its start:
use App\Models\User;
use Illuminate\Support\Facades\DB;
DB::table('refresh_tokens')->update(['owner_type' => (new User)->getMorphClass()]);
// family_started_at = the family's FIRST row, not each row's own created_at.
DB::table('refresh_tokens')
->select('family_id', DB::raw('min(created_at) as started'))
->groupBy('family_id')
->orderBy('family_id')
->each(function (object $family): void {
$started = \Carbon\CarbonImmutable::parse($family->started);
DB::table('refresh_tokens')->where('family_id', $family->family_id)->update([
'family_started_at' => $started,
'absolute_expires_at' => $started->addSeconds(7_776_000), // omit if absolute_ttl = 0
]);
});Without the backfill, legacy rows still work: sessionStartedAt() falls back to created_at, and a family whose rows have no absolute_expires_at is uncapped until it is re-rooted.
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 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.