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

The host mints its access token first, then issues the refresh token linked to it. The plaintext is returned once — only its digest is stored:

use RoundlyConsulting\RefreshTokens\Facades\RefreshTokens;
use RoundlyConsulting\RefreshTokens\DataTransferObjects\IssueContext;

$new = RefreshTokens::issue($user, new IssueContext(
    ipAddress: $request->ip(),
    userAgent: $request->userAgent(),
    accessReference: $access->jti, // opaque link to the host's access token
));

$plainText = $new->plainText;      // return to the client ONCE
$row = $new->token;                // the stored RefreshToken row (hash only)

The fluent builder

RefreshTokens::for($owner) returns a PendingIssue builder for the common controller case:

$new = RefreshTokens::for($user)
    ->fromRequest($request)        // fills ip + user agent
    ->linkedTo($access->jti)
    ->issue();
  • fromRequest($request) — fills the IP and user agent; or set them individually with withIp() and withUserAgent().
  • linkedTo($accessReference) — the opaque access-token reference.
  • inFamily($familyId) — inherit an existing family; startingFamily($uuid) — root a new one under your UUID. Use one or the other.
  • ttl($seconds) / absoluteTtl($seconds) — per-session lifetimes.
  • meta(array) — session metadata.
  • issue() — terminal; returns NewRefreshToken.

Per-session options

All optional — root the family under your own session id, give this login its own lifetimes, and attach metadata:

$sid = (string) Str::uuid();       // e.g. already minted into the access token as `sid`

$new = RefreshTokens::for($client)
    ->fromRequest($request)
    ->startingFamily($sid)         // root the family under YOUR uuid (IssueContext::$newFamilyId)
    ->ttl(3600)                    // sliding lifetime for this session
    ->absoluteTtl(86_400)          // hard cap for this session; 0 = uncapped
    ->meta(['guard' => 'clients', 'amr' => ['pwd', 'otp'], 'auth_time' => time()])
    ->issue();

newFamilyId must be a well-formed UUID (checked before any query) that no family already uses. Family ids are canonicalised to lowercase, so they match case-insensitively on every driver.

IssueContext

The same options as a DTO — every field is optional:

$new = RefreshTokens::issue($owner, new IssueContext(
    ipAddress: $request->ip(),
    userAgent: $request->userAgent(),
    accessReference: $accessTokenId,   // opaque handle to the paired access token
    familyId: null,                    // inherit an existing family (rotation replacement)
    newFamilyId: null,                 // OR root a new family under a caller-chosen UUID
    ttl: null,                         // sliding lifetime override (>= 1 s)
    absoluteTtl: null,                 // absolute cap override, root only (>= 0; 0 = uncapped)
    meta: ['guard' => 'users', 'amr' => ['pwd'], 'auth_time' => time()],
));
FieldTypeMeaning
ipAddress?stringClient IP. When inheriting a family and left null, it is copied from the family’s newest row.
userAgent?stringRaw user-agent string; same inheritance fallback as ipAddress.
accessReference?stringOpaque link to the host’s access token (e.g. a JWT jti), passed to the revoker on revoke.
familyId?stringInherit an existing family of the same owner — a rotation replacement.
newFamilyId?stringRoot a new family under your own UUID. Cannot be combined with familyId.
ttl?intSliding lifetime override in seconds (≥ 1).
absoluteTtl?intAbsolute cap override in seconds (≥ 0; 0 = uncapped). Honoured only when rooting a family.
meta?arraySession metadata stored as JSON and merged on inherit. Never put secrets in it.

Session meta

meta is stored as JSON and inherited across every rotation (new keys are merged over the old). It is readable by anyone who can read the table and is serialized with the model — never put secrets in it. On PostgreSQL (jsonb) object key order is not preserved.

Validation

Inputs are validated before any query:

InputThrows
familyId and newFamilyId togetherInvalidTokenFamilyException::ambiguous
newFamilyId is not a UUIDInvalidTokenFamilyException::malformed
newFamilyId already used by any familyInvalidTokenFamilyException::alreadyExists
familyId malformed or not owned by this ownerInvalidTokenFamilyException::unknownForOwner
familyId killed by reuse detectionInvalidTokenFamilyException::reuseRevoked
familyId whose newest row was ended by a revokeInvalidTokenFamilyException::ended
ttl < 1InvalidTokenConfigurationException::invalidTtl
absoluteTtl < 0InvalidTokenConfigurationException::invalidAbsoluteTtl

A successful issue dispatches RefreshTokenIssued.

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.