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.

Maintainers

Package info

github.com/EdouardCourty/token-bundle

Type:symfony-bundle

pkg:composer/ecourty/token-bundle

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-05-10 17:30 UTC

This package is auto-updated.

Last update: 2026-05-10 17:32:31 UTC


README

CI

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

Requirements

  • PHP ≥ 8.3
  • Symfony ≥ 7.0
  • Doctrine ORM ≥ 3.0

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

  • 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 commandtoken:purge to clean up expired, consumed, and revoked tokens
  • Race-safe — atomic increment for multi-use tokens prevents overconsumption

Configuration

# config/packages/token.yaml
token:
    token_length: 64  # default: 64, minimum: 16
Option Type Default Description
token_length int 64 Length of the generated token string (min: 16)

Usage

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

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

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

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

// 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

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

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

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:

Parameter Type Default Description
type string (required) Token type to validate against
resolver class-string HeaderTokenResolver::class FQCN of a TokenResolverInterface to use

Built-in resolvers:

Resolver Reads 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

The bundle dispatches Symfony events on token lifecycle actions:

Event Dispatched when Extra properties
TokenCreatedEvent After a token is created & 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

All exceptions extend AbstractTokenException (RuntimeException):

Exception Thrown 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

# 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

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

This bundle is released under the MIT License.