A Symfony bundle for managing secure, typed, and revocable tokens attached to any entity — for password resets, email verification, share links, and more.
- Requirements
- Installation
- Core Features
- Configuration
- Usage
- Events
- Exceptions
- Console Command
- Development
- PHP ≥ 8.3
- Symfony ≥ 7.0
- Doctrine ORM ≥ 3.0
composer require ecourty/token-bundleRegister 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:migrateThe bundle automatically registers its Doctrine entity mapping — no manual configuration required.
- 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
# 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) |
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;
}
}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']],
);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.
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.
// 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);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
}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
}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.
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...
}
}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 |
# 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"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 qaThis bundle is released under the MIT License.