Issuing tokens
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()],
));| Field | Type | Meaning |
|---|---|---|
ipAddress | ?string | Client IP. When inheriting a family and left null, it is copied from the family’s newest row. |
userAgent | ?string | Raw user-agent string; same inheritance fallback as ipAddress. |
accessReference | ?string | Opaque link to the host’s access token (e.g. a JWT jti), passed to the revoker on revoke. |
familyId | ?string | Inherit an existing family of the same owner — a rotation replacement. |
newFamilyId | ?string | Root a new family under your own UUID. Cannot be combined with familyId. |
ttl | ?int | Sliding lifetime override in seconds (≥ 1). |
absoluteTtl | ?int | Absolute cap override in seconds (≥ 0; 0 = uncapped). Honoured only when rooting a family. |
meta | ?array | Session 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:
| Input | Throws |
|---|---|
| familyId and newFamilyId together | InvalidTokenFamilyException::ambiguous |
| newFamilyId is not a UUID | InvalidTokenFamilyException::malformed |
| newFamilyId already used by any family | InvalidTokenFamilyException::alreadyExists |
| familyId malformed or not owned by this owner | InvalidTokenFamilyException::unknownForOwner |
| familyId killed by reuse detection | InvalidTokenFamilyException::reuseRevoked |
| familyId whose newest row was ended by a revoke | InvalidTokenFamilyException::ended |
| ttl < 1 | InvalidTokenConfigurationException::invalidTtl |
| absoluteTtl < 0 | InvalidTokenConfigurationException::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 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.