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.
Package info
github.com/EdouardCourty/token-bundle
Type:symfony-bundle
pkg:composer/ecourty/token-bundle
Requires
- php: >=8.3
- doctrine/doctrine-bundle: ^2.0|^3.0
- doctrine/orm: ^3.0
- symfony/config: ^7.0|^8.0
- symfony/console: ^7.0|^8.0
- symfony/dependency-injection: ^7.0|^8.0
- symfony/event-dispatcher: ^7.0|^8.0
- symfony/http-foundation: ^7.0|^8.0
- symfony/http-kernel: ^7.0|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.71
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
- symfony/browser-kit: ^7.0|^8.0
- symfony/framework-bundle: ^7.0|^8.0
- symfony/var-dumper: ^7.2|^8.0
- symfony/var-exporter: ^7.2|^8.0
README
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
- Installation
- Core Features
- Configuration
- Usage
- Events
- Exceptions
- Console Command
- Development
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 command —
token:purgeto 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(aRuntimeException), 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 SQLUPDATEfor performance and does not dispatch individualTokenRevokedEventper 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.