PHPackages                             ecourty/token-bundle - 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. [Database &amp; ORM](/categories/database)
4. /
5. ecourty/token-bundle

ActiveSymfony-bundle[Database &amp; ORM](/categories/database)

ecourty/token-bundle
====================

A Symfony bundle for managing secure, typed and revocable tokens attached to any entity. For password resets, email verification, share links and more.

1.0.0(1mo ago)00MITPHPPHP &gt;=8.3CI passing

Since May 10Pushed 1mo agoCompare

[ Source](https://github.com/EdouardCourty/token-bundle)[ Packagist](https://packagist.org/packages/ecourty/token-bundle)[ RSS](/packages/ecourty-token-bundle/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (1)Dependencies (15)Versions (2)Used By (0)

Token Bundle
============

[](#token-bundle)

[![CI](https://github.com/EdouardCourty/token-bundle/actions/workflows/ci.yml/badge.svg)](https://github.com/EdouardCourty/token-bundle/actions/workflows/ci.yml)

A Symfony bundle for managing secure, typed, and revocable tokens attached to any entity — for password resets, email verification, share links, and more.

Table of Contents
-----------------

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Core Features](#core-features)
- [Configuration](#configuration)
- [Usage](#usage)
    - [Making an Entity a Token Subject](#making-an-entity-a-token-subject)
    - [Creating a Token](#creating-a-token)
    - [Retrieving a Token](#retrieving-a-token)
    - [Consuming a Token](#consuming-a-token)
    - [Revoking Tokens](#revoking-tokens)
    - [Finding a Valid Token](#finding-a-valid-token)
    - [Resolving the Subject Entity](#resolving-the-subject-entity)
    - [Protecting Controller Routes](#protecting-controller-routes)
- [Events](#events)
- [Exceptions](#exceptions)
- [Console Command](#console-command)
- [Development](#development)

---

Requirements
------------

[](#requirements)

- PHP **≥ 8.3**
- Symfony **≥ 7.0**
- Doctrine ORM **≥ 3.0**

---

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

[](#installation)

```
composer require ecourty/token-bundle
```

Register the bundle in `config/bundles.php` (if not using Symfony Flex):

```
return [
    // ...
    Ecourty\TokenBundle\TokenBundle::class => ['all' => true],
];
```

Create the `tokens` table with a Doctrine migration:

```
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
```

> The bundle automatically registers its Doctrine entity mapping — no manual configuration required.

---

Core Features
-------------

[](#core-features)

- **Typed tokens** — each token has a `type` (e.g. `password_reset`, `email_verify`, `share`)
- **Any entity as subject** — attach a token to any Doctrine entity via `TokenSubjectInterface`
- **Expiration** — every token requires an expiry date (no permanent tokens)
- **Single-use** — tokens can be flagged as single-use, automatically consumed after first use
- **Max-uses** — tokens can be limited to N uses, auto-consumed when the limit is reached
- **JSON payload** — attach arbitrary data to any token
- **Revocation** — revoke individual tokens or all tokens for a subject (optionally filtered by type)
- **Event-driven** — hook into `TokenCreatedEvent`, `TokenConsumedEvent`, `TokenRevokedEvent`
- **Purge command** — `token:purge` to clean up expired, consumed, and revoked tokens
- **Race-safe** — atomic increment for multi-use tokens prevents overconsumption

---

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

[](#configuration)

```
# config/packages/token.yaml
token:
    token_length: 64  # default: 64, minimum: 16
```

OptionTypeDefaultDescription`token_length``int``64`Length of the generated token string (min: `16`)---

Usage
-----

[](#usage)

### Making an Entity a Token Subject

[](#making-an-entity-a-token-subject)

Any Doctrine entity can become a token subject by implementing `TokenSubjectInterface`:

```
use Ecourty\TokenBundle\Contract\TokenSubjectInterface;

class User implements TokenSubjectInterface
{
    public function getTokenSubjectId(): string
    {
        return (string) $this->id;
    }
}
```

### Creating a Token

[](#creating-a-token)

Inject `TokenManager` and call `create()`:

```
use Ecourty\TokenBundle\Service\TokenManager;

class PasswordResetService
{
    public function __construct(private TokenManager $tokenManager) {}

    public function sendResetLink(User $user): void
    {
        $token = $this->tokenManager->create(
            type: 'password_reset',
            subject: $user,
            expiresIn: '+1 hour',
            singleUse: true,
        );

        // $token->getToken() — the secure random string to include in a reset link
    }
}
```

**With payload and max-uses:**

```
$token = $this->tokenManager->create(
    type: 'share',
    subject: $document,
    expiresIn: '+7 days',
    singleUse: false,
    maxUses: 10,
    payload: ['permissions' => ['read']],
);
```

### Retrieving a Token

[](#retrieving-a-token)

Use `get()` to look up a token by its string value and validate it **without consuming** it. This is useful to check if a token is valid before showing a form or performing an action:

```
$token = $this->tokenManager->get($tokenString, 'password_reset');

// Token is valid — show the reset form, resolve the subject, etc.
$user = $this->tokenManager->resolveSubject($token);
```

`get()` throws the same exceptions as `consume()` if the token is invalid.

### Consuming a Token

[](#consuming-a-token)

`consume()` accepts either a token string (with its type) or a `Token` entity directly:

```
// By token string
$token = $this->tokenManager->consume($tokenString, 'password_reset');

// Or by Token entity (e.g. after a get() call)
$token = $this->tokenManager->get($tokenString, 'password_reset');
// ... display a form, check the subject, etc.
$this->tokenManager->consume($token);
```

Both paths validate the token before consuming it and throw the same exceptions:

```
use Ecourty\TokenBundle\Exception\TokenAlreadyConsumedException;
use Ecourty\TokenBundle\Exception\TokenExpiredException;
use Ecourty\TokenBundle\Exception\TokenMaxUsesReachedException;
use Ecourty\TokenBundle\Exception\TokenNotFoundException;
use Ecourty\TokenBundle\Exception\TokenRevokedException;

try {
    $token = $this->tokenManager->consume($tokenString, 'password_reset');
} catch (TokenNotFoundException) {
    // Token does not exist or wrong type
} catch (TokenExpiredException) {
    // Token has expired
} catch (TokenRevokedException) {
    // Token was manually revoked
} catch (TokenAlreadyConsumedException) {
    // Single-use token already used
} catch (TokenMaxUsesReachedException) {
    // Max uses reached
}
```

> **Tip:** All token exceptions extend `AbstractTokenException` (a `RuntimeException`), so you can catch them all at once if needed.

### Revoking Tokens

[](#revoking-tokens)

```
// Revoke a specific token by its string value
$this->tokenManager->revoke($tokenString);

// Revoke all password_reset tokens for a user
$count = $this->tokenManager->revokeAll($user, 'password_reset');

// Revoke ALL tokens for a subject regardless of type
$count = $this->tokenManager->revokeAll($user);
```

### Finding a Valid Token

[](#finding-a-valid-token)

Returns the first valid (not expired, not consumed, not revoked, not at max uses) token for the given subject and type:

```
$token = $this->tokenManager->findValid($user, 'password_reset');

if ($token === null) {
    // No valid token exists — create a new one
}
```

### Resolving the Subject Entity

[](#resolving-the-subject-entity)

After consuming or finding a token, retrieve the original subject entity directly:

```
$token = $this->tokenManager->consume($tokenString, 'password_reset');
$user = $this->tokenManager->resolveSubject($token);

if ($user === null) {
    // Subject entity was deleted
}
```

### Protecting Controller Routes

[](#protecting-controller-routes)

Use the `#[RequiresToken]` attribute to protect a controller action with a token check. The listener validates the token **before** the controller executes:

```
use Ecourty\TokenBundle\Attribute\RequiresToken;
use Ecourty\TokenBundle\Entity\Token;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;

class DocumentController
{
    #[RequiresToken(type: 'share')]
    public function view(Request $request): JsonResponse
    {
        // The validated Token entity is available in the request
        $token = $request->attributes->get('_token');
        assert($token instanceof Token);

        return new JsonResponse(['document' => '...']);
    }
}
```

By default, the token is read from the `X-Token` HTTP header via the built-in `HeaderTokenResolver`.

The attribute accepts two parameters:

ParameterTypeDefaultDescription`type``string`*(required)*Token type to validate against`resolver``class-string``HeaderTokenResolver::class`FQCN of a `TokenResolverInterface` to use**Built-in resolvers:**

ResolverReads from`HeaderTokenResolver``X-Token` HTTP header (default)`QueryStringTokenResolver``?token=` query string parameter```
use Ecourty\TokenBundle\Resolver\QueryStringTokenResolver;

#[RequiresToken(type: 'share', resolver: QueryStringTokenResolver::class)]
public function sharedDocument(Request $request): JsonResponse
{
    // Token is read from ?token=...
}
```

**Custom resolver:**

Implement `TokenResolverInterface` to extract the token from anywhere in the request (cookies, custom headers, etc.):

```
use Ecourty\TokenBundle\Contract\TokenResolverInterface;
use Symfony\Component\HttpFoundation\Request;

class BearerTokenResolver implements TokenResolverInterface
{
    public function resolve(Request $request): ?string
    {
        $header = $request->headers->get('Authorization');
        if ($header !== null && str_starts_with($header, 'Bearer ')) {
            return substr($header, 7);
        }

        return null;
    }
}
```

```
#[RequiresToken(type: 'api_access', resolver: BearerTokenResolver::class)]
public function apiEndpoint(Request $request): JsonResponse
{
    // Token is extracted from the Authorization header
}
```

> Resolver classes are automatically tagged and discovered when they implement `TokenResolverInterface`.

**Handling access denied:**

When a token is missing, invalid, expired, or revoked, a `TokenAccessDeniedException` is thrown. You can handle it globally by listening to the `TokenAccessDeniedEvent`:

```
use Ecourty\TokenBundle\Event\TokenAccessDeniedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;

#[AsEventListener]
class TokenAccessDeniedHandler
{
    public function __invoke(TokenAccessDeniedEvent $event): void
    {
        $event->setResponse(new JsonResponse(
            ['error' => 'Invalid or missing token'],
            403,
        ));
    }
}
```

The event provides `$event->request`, `$event->exception` (the underlying token exception), and `$event->tokenType`. Setting a response on the event prevents the exception from propagating.

---

Events
------

[](#events)

The bundle dispatches Symfony events on token lifecycle actions:

EventDispatched whenExtra properties`TokenCreatedEvent`After a token is created &amp; persisted`$createdAt``TokenConsumedEvent`After a token is successfully consumed`$consumedAt``TokenRevokedEvent`After a single token is revoked via `revoke()``$revokedAt``TokenAccessDeniedEvent`When a `#[RequiresToken]` check fails (dispatched from the exception listener)`$request`, `$exception`, `$tokenType`> **Note:** `revokeAll()` performs a bulk SQL `UPDATE` for performance and does **not** dispatch individual `TokenRevokedEvent` per token.

All events carry the `Token` entity via `$event->token`.

**Example listener:**

```
use Ecourty\TokenBundle\Event\TokenCreatedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
class TokenCreatedListener
{
    public function __invoke(TokenCreatedEvent $event): void
    {
        // e.g. log, send notification, audit trail...
    }
}
```

---

Exceptions
----------

[](#exceptions)

All exceptions extend `AbstractTokenException` (`RuntimeException`):

ExceptionThrown when`TokenNotFoundException`Token string not found or type mismatch`TokenExpiredException`Token has expired`TokenRevokedException`Token was revoked`TokenAlreadyConsumedException`Single-use token already consumed`TokenMaxUsesReachedException`Token has reached its maximum number of uses`TokenAccessDeniedException`Token check failed on a `#[RequiresToken]` route---

Console Command
---------------

[](#console-command)

```
# Purge all expired, consumed, and revoked tokens
php bin/console token:purge

# Preview what would be deleted without actually deleting
php bin/console token:purge --dry-run

# Purge only tokens of a specific type
php bin/console token:purge --type=password_reset

# Only purge tokens that expired before a given date
php bin/console token:purge --before="2026-01-01"
php bin/console token:purge --before="-30 days"
```

---

Development
-----------

[](#development)

```
composer install

# Run all tests
composer test

# Run specific test suites
composer test-unit
composer test-integration
composer test-functional

# Static analysis (PHPStan, level max)
composer phpstan

# Code style (PHP CS Fixer)
composer cs-fix       # fix
composer cs-check     # dry-run check

# Full QA pipeline (PHPStan + CS check + tests)
composer qa
```

---

License
-------

[](#license)

This bundle is released under the [MIT License](LICENSE).

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance94

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% 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

Unknown

Total

1

Last Release

30d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/3150ffb131124e5f03272d9ed8084c514f18fff6aafff1a5973c016993f6ef66?d=identicon)[ecourty](/maintainers/ecourty)

---

Top Contributors

[![EdouardCourty](https://avatars.githubusercontent.com/u/37371516?v=4)](https://github.com/EdouardCourty "EdouardCourty (11 commits)")

---

Tags

symfonybundlesecuritydoctrinetokenrevocation

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/ecourty-token-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/ecourty-token-bundle/health.svg)](https://phpackages.com/packages/ecourty-token-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.5M370](/packages/easycorp-easyadmin-bundle)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M195](/packages/sulu-sulu)[open-dxp/opendxp

Content &amp; Product Management Framework (CMS/PIM)

9017.2k55](/packages/open-dxp-opendxp)

PHPackages © 2026

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