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

One publish-only migration creates the table (refresh_tokens by default). There are no foreign keys:

ColumnsTypeNotes
idbigintRow id — changes on every rotation; address sessions by family_id instead.
owner_type, owner_idstring + bigint/uuid/ulidPolymorphic owner with a composite index; owner_id follows key_type.
token_hashstring(128), uniqueAt-rest digest; fits sha512 hex. Hidden from serialization.
family_iduuid, indexedThe stable session id, canonicalised to lowercase.
access_referencestring(64), nullable, indexedOpaque link to the access token. Hidden from serialization.
user_agent, browser, browser_version, os, os_version, device_type, is_botnullableDevice data — host-supplied, carried forward on rotation.
country, city, country_code, ip_addressnullableGeo data — host-supplied, carried forward on rotation.
family_started_at, absolute_expires_at, metanullable; meta is jsonbSession columns every row of a family inherits.
revoked_reason, expires_at, revoked_atlifecycleexpires_at and revoked_at are indexed.
created_at, updated_at, deleted_attimestampsStandard 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 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.