---
title: "Redeeming & rotating — Refresh Tokens for Laravel | Roundly"
description: "Atomically redeem a token with anti-double-spend, rotate it in one call, scope redemption to one owner type, and handle failures."
url: https://roundly-consulting.com/open-source/docs/refresh-tokens-for-laravel/rotation
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Refresh Tokens for Laravel](https://roundly-consulting.com/open-source/docs/refresh-tokens-for-laravel.md)

# Redeeming & rotating

redeem() atomically claims and rotates a token. Of N concurrent redemptions of the same token, exactly one wins; every failure mode collapses to null, so a caller cannot tell unknown from expired from revoked from race-lost:

```php
$result = RefreshTokens::redeem($plainFromClient); // ?RedemptionResult

if ($result === null) {
    // unknown / expired / revoked / race-lost / reuse — host maps to its own error
    throw ValidationException::withMessages(['refresh_token' => 'Please sign in again.']);
}

$owner = $result->user;            // the owner model (User, Client, …)
$familyId = $result->familyId;     // the stable session id
$spent = $result->redeemedToken;   // the now-revoked row
```

The claim is a single UPDATE … WHERE revoked\_at IS NULL — a compare-and-swap the database serialises. No transaction, no row lock; it works identically on PostgreSQL and SQLite. A winning redeem dispatches RefreshTokenRedeemed.

## Guard-scoped redemption

Pass the owner morph class your endpoint serves. A token of another owner type is treated as unknown — null, not consumed, no reuse signal — so presenting a user’s token at the clients endpoint can neither burn the user’s session nor trip reuse detection:

```php
// POST /auth/refresh — users only
$result = RefreshTokens::redeem($plainFromClient, ownerType: (new User)->getMorphClass());

// POST /clients/refresh — API clients only
$result = RefreshTokens::redeem($plainFromClient, ownerType: (new Client)->getMorphClass());
```

## One-call rotation

rotate() does redeem + issue a same-family replacement in one call. The owner is known only once rotate() returns, so pick the new access token’s id first, pass it in a RotationContext, then mint the access token for $rotation->user with it:

```php
use Illuminate\Support\Str;
use RoundlyConsulting\RefreshTokens\DataTransferObjects\RotationContext;

$jti = (string) Str::uuid();                // the next access token's id

$rotation = RefreshTokens::rotate($plainFromClient, new RotationContext(
    ownerType: (new User)->getMorphClass(),  // optional guard scope
    ipAddress: $request->ip(),              // current ip/ua replace the inherited ones
    userAgent: $request->userAgent(),
    accessReference: $jti,
    ttl: 3600,                              // optional sliding-lifetime override
    meta: ['auth_time' => $authTime],       // merged over the inherited meta
));
// ?RotationResult { user, newRefreshToken (NewRefreshToken), redeemedFamilyId }

if ($rotation === null) {
    // re-authenticate
}

// … mint the new access token for $rotation->user with $jti …

$plainText = $rotation->newRefreshToken->plainText; // return to the client ONCE
```

rotate() fires one RefreshTokenRedeemed for the spent token and one RefreshTokenIssued for the replacement.

## What a replacement inherits

Issuing into an existing family — via rotate() or an explicit issue() with familyId — copies the session from the family’s newest row, revoked or not:

- family\_started\_at and absolute\_expires\_at — verbatim, so the absolute cap holds across the whole chain.
- meta — merged with the new meta (new keys win).
- Device and geo columns — browser, browser\_version, os, os\_version, device\_type, is\_bot, country, city, country\_code.
- ip\_address and user\_agent — from the context when given, otherwise copied too.

The replacement’s expiry is min(now + ttl, absolute\_expires\_at).

An expired family can’t be inherited (InvalidTokenFamilyException::expired): not past its absolute lifetime, and not when its newest row expired unused — so an uncapped family (absolute\_ttl = 0) is never revived with a fresh TTL. A row merely consumed by a rotation stays inheritable, so redeem → mint → issue works even if that row’s own expiry passes during the mint.

## Redeem, mint, then issue

When the replacement must reference an access token minted after the redeem, split the call — inheritance makes this path behave exactly like rotate():

```php
// When the replacement must reference an access token minted AFTER the redeem:
$result = RefreshTokens::redeem($plainFromClient);

if ($result === null) {
    // re-authenticate
}

// … mint the new access token for $result->user as $access …

$new = RefreshTokens::issue($result->user, new IssueContext(
    accessReference: $access->jti,
    familyId: $result->familyId,   // inherit the session — behaves exactly like rotate()
));
```

If the family ended between the redeem and the issue — a reuse verdict, a revoke, or its absolute lifetime passed during the mint — issue() throws InvalidTokenFamilyException or comes back already revoked: deny the freshly minted access token and ask the client to sign in again.

## Failure semantics

An invalid context or config — a RotationContext ttl below 1, an accessReference over 64 bytes, or an invalid ttl, absolute\_ttl or token\_length config — throws InvalidTokenConfigurationException before the redeem, so the presented token stays usable and the corrected call works.

Past that point, treat null (or an exception) as “re-authenticate”. rotate() consumes the old token before the replacement exists and is deliberately not rolled back — un-claiming a consumed token would reopen the double-spend window. If the replacement can’t be minted (a database blip, the owner deleted mid-rotation, or the family killed by a concurrent reuse response), the client simply holds no valid refresh token and signs in again. No token is ever forged.

An explicit familyId is validated: the family must exist for that same owner, must not have been killed by reuse detection, its newest row must not have been ended by a revoke, and it must not have expired — otherwise InvalidTokenFamilyException. This prevents grafting a token into another user’s, or a dead, lineage.

## 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/refresh-tokens-for-laravel.md)
