PHPackages                             foxen/laravel-cancellation-tokens - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. foxen/laravel-cancellation-tokens

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

foxen/laravel-cancellation-tokens
=================================

A focused package to manage the full cancellation token lifecycle - generation, storage, verification, expiry, and consumption - so you never hand-roll this system again. Provides secure, single-use, time-limited, revocable tokens for cancellable workflows without login requirements.

v1.0.1(3mo ago)019[1 PRs](https://github.com/foxen-digital/laravel-cancellation-tokens/pulls)MITPHPPHP ^8.3CI failing

Since Apr 2Pushed 2w agoCompare

[ Source](https://github.com/foxen-digital/laravel-cancellation-tokens)[ Packagist](https://packagist.org/packages/foxen/laravel-cancellation-tokens)[ Docs](https://github.com/foxen/laravel-cancellation-tokens)[ GitHub Sponsors](https://github.com/foxen)[ RSS](/packages/foxen-laravel-cancellation-tokens/feed)WikiDiscussions main Synced 1mo ago

READMEChangelog (1)Dependencies (13)Versions (5)Used By (0)

Laravel Cancellation Tokens
===========================

[](#laravel-cancellation-tokens)

[![Latest Version on Packagist](https://camo.githubusercontent.com/cc00b546375750c83cef2ca3beb2b00c45b4d24eae393224b2fcfe83e2c3324a/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f666f78656e2f6c61726176656c2d63616e63656c6c6174696f6e2d746f6b656e732e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/foxen/laravel-cancellation-tokens)[![GitHub Tests Action Status](https://camo.githubusercontent.com/c69cd027a6ad6816823a16935d3440d84a2e5b2682a8fc4731e1253ee5635304/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f666f78656e2d6469676974616c2f6c61726176656c2d63616e63656c6c6174696f6e2d746f6b656e732f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/foxen-digital/laravel-cancellation-tokens/actions?query=workflow%3Arun-tests+branch%3Amain)[![Total Downloads](https://camo.githubusercontent.com/2cdae5746320995350947dfd848c3189529468ae6fd8222ac16be1d7e43ff740/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f666f78656e2f6c61726176656c2d63616e63656c6c6174696f6e2d746f6b656e732e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/foxen/laravel-cancellation-tokens)

A focused Laravel package that manages the full cancellation token lifecycle — generation, storage, verification, expiry, and consumption — so you never hand-roll this system again.

Provides cryptographically secure, single-use, time-limited tokens for cancellable workflows (bookings, orders, subscriptions) **without requiring login**. The plain-text token is returned once for embedding in a URL; only an HMAC-SHA256 hash is ever stored.

Installation
------------

[](#installation)

```
composer require foxen/laravel-cancellation-tokens
```

Publish the migration and config:

```
php artisan vendor:publish --tag="cancellation-tokens-migrations"
php artisan vendor:publish --tag="cancellation-tokens-config"
php artisan migrate
```

Add a hash key to your `.env` file. This key is used for HMAC-SHA256 token hashing and **must** be set before creating or verifying tokens:

```
CANCELLATION_TOKEN_HASH_KEY=your-secret-key-here
```

> **Important:** Generate a strong, random value. You can use `php -r "echo base64_encode(random_bytes(32));"` to generate one. This key is separate from `APP_KEY` and should not be shared with it.

Configuration
-------------

[](#configuration)

The published config file at `config/cancellation-tokens.php`:

```
return [
    'table'          => 'cancellation_tokens',  // Database table name
    'prefix'         => 'ct_',                  // Token prefix (e.g. ct_a1b2c3...)
    'default_expiry' => 10080,                  // Minutes until expiry (7 days)
    'hash_key'       => env('CANCELLATION_TOKEN_HASH_KEY'),
];
```

Basic Usage
-----------

[](#basic-usage)

A complete booking cancellation flow using the `HasCancellationTokens` trait.

### 1. Add the trait to your cancellable model

[](#1-add-the-trait-to-your-cancellable-model)

```
use Foxen\CancellationToken\Traits\HasCancellationTokens;

class Booking extends Model
{
    use HasCancellationTokens;
}
```

### 2. Create a token and send it

[](#2-create-a-token-and-send-it)

When a booking is confirmed, generate a cancellation token and include it in the confirmation email:

```
$plainToken = $booking->createCancellationToken($user);

// Embed in a URL — the route only needs the token
$url = url('/booking/cancel/' . $plainToken);

// Send email containing $url...
```

The token is prefixed automatically (e.g. `ct_a1B2c3...`, 67 characters). Only the HMAC-SHA256 hash is stored in the database — the plain-text value is returned **exactly once**.

> **Note:** Creating a new token for the same booking/user pair automatically removes any previous unused tokens for that pair.

### 3. Handle the cancellation request

[](#3-handle-the-cancellation-request)

```
use Foxen\CancellationToken\Facades\CancellationToken;
use Foxen\CancellationToken\Exceptions\TokenVerificationException;

Route::get('/booking/cancel/{token}', function (string $token) {
    try {
        $cancellationToken = CancellationToken::consume($token);

        // Access the associated models
        $booking = $cancellationToken->cancellable;
        $user = $cancellationToken->tokenable;

        // Perform the cancellation
        $booking->cancel();

        return view('booking.cancelled');
    } catch (TokenVerificationException $e) {
        // $e->reason is a TokenVerificationFailure enum:
        //   - NotFound  — token doesn't exist
        //   - Expired   — token has passed its expiry time
        //   - Consumed  — token has already been used
        return match ($e->reason) {
            TokenVerificationFailure::Expired => response('This cancellation link has expired.'),
            TokenVerificationFailure::Consumed => response('This booking has already been cancelled.'),
            TokenVerificationFailure::NotFound => response('Invalid cancellation link.'),
        };
    }
});
```

`consume()` verifies the token and marks it as used in a single call (`used_at` is set). You can also call `verify()` to check a token without consuming it:

```
$token = CancellationToken::verify($plainToken);
// $token->cancellable — the booking
// $token->tokenable — the user who requested cancellation
// Token is NOT consumed yet
```

### 4. Validate tokens in form requests

[](#4-validate-tokens-in-form-requests)

For cancellation via form submission, use the `ValidCancellationToken` validation rule:

```
use Foxen\CancellationToken\Rules\ValidCancellationToken;

class CancelBookingRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'token' => ['required', 'string', new ValidCancellationToken],
        ];
    }
}
```

If validation fails, the rule stores the failure reason on itself. You can access it after validation to customise your response:

```
use Foxen\CancellationToken\Rules\ValidCancellationToken;

$rule = new ValidCancellationToken;

// After validation, inspect the failure reason:
$rule->failureReason; // TokenVerificationFailure enum or null
```

Using the Facade
----------------

[](#using-the-facade)

When you don't want to add the trait to a model — or you need to create tokens across arbitrary model types — use the Facade directly:

```
use Foxen\CancellationToken\Facades\CancellationToken;
use Carbon\Carbon;

// Create with default expiry (7 days)
$token = CancellationToken::create($subscription, $admin);

// Create with custom expiry
$token = CancellationToken::create($order, $customer, Carbon::now()->addHours(24));

// Verify without consuming
$cancellationToken = CancellationToken::verify($token);

// Verify and consume (single-use)
$cancellationToken = CancellationToken::consume($token);
```

The `create()` method accepts three arguments:

- **`$cancellable`** — the model being cancelled (e.g. `Booking`, `Subscription`, `Order`)
- **`$tokenable`** — the actor who may cancel (e.g. `User`, `Customer`, any model)
- **`$expiresAt`** *(optional)* — a `Carbon` instance; defaults to the configured `default_expiry`

Both `$cancellable` and `$tokenable` must be persisted models (they must exist in the database).

Events
------

[](#events)

The package dispatches events at key points in the token lifecycle. All events carry the `CancellationToken` model as a public `$token` property.

EventWhen it fires`TokenCreated`After a token is created and persisted`TokenVerified`After a token is successfully verified`TokenConsumed`After a token is consumed (marked as used)`TokenExpired`When an expired token is presented to `verify()` or `consume()`> On failure paths (`TokenExpired`), the event fires **before** the `TokenVerificationException` is thrown, so your listeners always run.

### Listening for events

[](#listening-for-events)

```
use Foxen\CancellationToken\Events\TokenConsumed;
use Foxen\CancellationToken\Events\TokenExpired;

// In a service provider's boot() method:
protected function boot(): void
{
    Event::listen(TokenConsumed::class, function (TokenConsumed $event) {
        $booking = $event->token->cancellable;
        Log::info("Booking {$booking->id} was cancelled.");
    });

    Event::listen(TokenExpired::class, function (TokenExpired $event) {
        // Alert the user that their cancellation link expired
        $event->token->tokenable->notify(new CancellationLinkExpired(
            $event->token->cancellable
        ));
    });
}
```

Token Cleanup
-------------

[](#token-cleanup)

The `CancellationToken` model implements Laravel's `Prunable` trait. Tokens are automatically pruned when they are:

- **Expired** — `expires_at` is in the past
- **Consumed** — `used_at` is not null

Schedule the prune command in your `routes/console.php` (or `app/Console/Kernel.php` for older Laravel versions):

```
use Illuminate\Support\Facades\Schedule;

Schedule::command('model:prune', [
    '--model' => \Foxen\CancellationToken\Models\CancellationToken::class,
])->daily();
```

Or prune all prunable models together:

```
Schedule::command('model:prune')->daily();
```

No custom Artisan commands are needed — the package integrates with Laravel's built-in pruning system.

Testing
-------

[](#testing)

### Unit tests with `CancellationTokenFake`

[](#unit-tests-with-cancellationtokenfake)

The fake bypasses the database entirely, making your unit tests fast:

```
use Foxen\CancellationToken\Facades\CancellationToken;
use Foxen\CancellationToken\Models\CancellationToken;

it('creates a cancellation token for the booking', function () {
    $fake = CancellationToken::fake();

    $booking = Booking::make(['id' => 1]);
    $user = User::make(['id' => 1]);

    $token = CancellationToken::create($booking, $user);

    // Assert the token was created for the right models
    $fake->assertTokenCreatedFor($booking, $user);

    // Or just check the cancellable, ignoring the actor:
    // $fake->assertTokenCreatedFor($booking);
});

it('consumes a token', function () {
    $fake = CancellationToken::fake();

    $booking = Booking::make(['id' => 1]);
    $user = User::make(['id' => 1]);

    $token = CancellationToken::create($booking, $user);
    CancellationToken::consume($token);

    $fake->assertTokenConsumed($token);
});

it('does not create tokens unnecessarily', function () {
    $fake = CancellationToken::fake();

    // No tokens created — assertion passes
    $fake->assertNoTokensCreated();
});
```

The `CancellationTokenFake` also enforces token lifecycle rules — calling `consume()` twice on the same token throws `TokenVerificationException`, just like the real service.

### Feature tests with `CancellationTokenFactory`

[](#feature-tests-with-cancellationtokenfactory)

For tests that need real database records, use the included factory:

```
use Foxen\CancellationToken\Models\CancellationToken;

// Create a valid, unexpired token
$token = CancellationToken::factory()->create();

// Create a consumed token
$token = CancellationToken::factory()->consumed()->create();

// Create an expired token
$token = CancellationToken::factory()->expired()->create();

// Associate with specific models
$token = CancellationToken::factory()
    ->for($booking, 'cancellable')
    ->for($user, 'tokenable')
    ->create();
```

Note that the factory creates **database records with hashed token values** — the plain-text token is not available. This is by design: the factory is for setting up test state, not for simulating the full create-verify-consume lifecycle (use the service directly for that).

Security
--------

[](#security)

This package follows the same token storage approach Laravel uses for password reset tokens:

- **HMAC-SHA256 hashing** — tokens are hashed with a dedicated `hash_key` before storage
- **Plain-text never persisted** — the raw token is returned from `create()` exactly once and never stored, logged, or cached
- **Timing-safe comparison** — `hash_equals()` is used for all hash comparisons
- **64 bytes of entropy** — `Str::random(64)` backed by `random_bytes()`
- **Single-use enforcement** — `used_at` timestamp prevents replay
- **Automatic invalidation** — creating a new token for the same pair removes previous unused tokens

Credits
-------

[](#credits)

- [mrdth](https://github.com/mrdth)
- [All Contributors](../../contributors)

License
-------

[](#license)

The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance90

Actively maintained with recent releases

Popularity8

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity52

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 90.6% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

90d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/781215?v=4)[mrdth](/maintainers/mrdth)[@mrdth](https://github.com/mrdth)

---

Top Contributors

[![mrdth](https://avatars.githubusercontent.com/u/781215?v=4)](https://github.com/mrdth "mrdth (29 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (2 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (1 commits)")

---

Tags

laravelfoxenlaravel-cancellation-tokens

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/foxen-laravel-cancellation-tokens/health.svg)

```
[![Health](https://phpackages.com/badges/foxen-laravel-cancellation-tokens/health.svg)](https://phpackages.com/packages/foxen-laravel-cancellation-tokens)
```

###  Alternatives

[spatie/laravel-permission

Permission handling for Laravel 12 and up

12.9k102.4M1.3k](/packages/spatie-laravel-permission)[spatie/laravel-pdf

Create PDFs in Laravel apps

1.0k4.8M46](/packages/spatie-laravel-pdf)[spatie/laravel-passkeys

Use passkeys in your Laravel app

471890.7k36](/packages/spatie-laravel-passkeys)[dedoc/scramble

Automatic generation of API documentation for Laravel applications.

2.1k11.2M96](/packages/dedoc-scramble)[rawilk/profile-filament-plugin

Profile &amp; MFA starter kit for filament.

3913.7k](/packages/rawilk-profile-filament-plugin)[codewithdennis/filament-select-tree

The multi-level select field enables you to make single selections from a predefined list of options that are organized into multiple levels or depths.

329530.5k29](/packages/codewithdennis-filament-select-tree)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
