makraz / verify-email-change-bundle
Symfony bundle for secure email address changes with verification
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
Type:symfony-bundle
pkg:composer/makraz/verify-email-change-bundle
Requires
- php: >=8.1
- symfony/event-dispatcher: ^6.4|^7.0
- symfony/framework-bundle: ^6.4|^7.0
- symfony/http-foundation: ^6.4|^7.0
- symfony/routing: ^6.4|^7.0
- symfony/security-core: ^6.4|^7.0
- twig/twig: ^3.0
Requires (Dev)
- doctrine/doctrine-bundle: ^2.10
- doctrine/orm: ^2.14|^3.0
- phpunit/phpunit: ^10.0
- symfony/phpunit-bridge: ^6.4|^7.0
- symfony/validator: ^6.4|^7.0
README
A Symfony bundle that provides secure email address change functionality with verification.
Features
- ๐ Cryptographically Secure: Uses selector + hashed token pattern to prevent timing attacks
- โฑ๏ธ Configurable Expiration: Set custom lifetimes for verification links
- ๐ซ Built-in Throttling: Prevents abuse with configurable rate limiting
- ๐ง Flexible: You control email sending, UI, and password verification
- ๐จ Twig Integration: Built-in Twig functions for checking pending email changes
- ๐งช Well Tested: Comprehensive test suite with 203 tests
- ๐ Event-Driven: Dispatches events for extensibility
Installation
composer require makraz/verify-email-change-bundle
If you're not using Symfony Flex, enable the bundle manually:
// config/bundles.php return [ // ... Makraz\Bundle\VerifyEmailChange\MakrazVerifyEmailChangeBundle::class => ['all' => true], ];
Quick Start
Step 1: Update Your User Entity
Your User entity must implement EmailChangeInterface:
<?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Makraz\Bundle\VerifyEmailChange\Model\EmailChangeInterface; #[ORM\Entity] class User implements EmailChangeInterface { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] private string $email; // ... rest of your entity public function getId(): ?int { return $this->id; } public function getEmail(): string { return $this->email; } public function setEmail(string $email): static { $this->email = $email; return $this; } // Required by EmailChangeInterface public function hasPendingEmailChange(): bool { // Implement this or use Twig functions (recommended) return false; } public function getPendingEmail(): ?string { // Implement this or use Twig functions (recommended) return null; } }
Note: The
hasPendingEmailChange()andgetPendingEmail()methods are required by the interface but can return default values if you use the built-in Twig functions (recommended approach - see below).
Step 2: Create the Database Table
Run the following command to create the migration:
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate
Or create the table manually:
CREATE TABLE email_change_request ( id INT AUTO_INCREMENT PRIMARY KEY, selector VARCHAR(20) UNIQUE NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, new_email VARCHAR(180) NOT NULL, user_identifier VARCHAR(255) NOT NULL, INDEX email_change_selector_idx (selector), INDEX email_change_user_idx (user_identifier) );
Step 3: Configure the Bundle (Optional)
# config/packages/verify_email_change.yaml verify_email_change: lifetime: 3600 # Link expires after 1 hour (default) enable_throttling: true # Prevent abuse (default: true) throttle_limit: 3600 # Wait time between requests (default: 1 hour)
Step 4: Create Your Controller
<?php namespace App\Controller; use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Attribute\Route; use Makraz\Bundle\VerifyEmailChange\EmailChange\EmailChangeHelper; use Makraz\Bundle\VerifyEmailChange\Exception\VerifyEmailChangeExceptionInterface; use Doctrine\ORM\EntityManagerInterface; class EmailChangeController extends AbstractController { public function __construct( private readonly EmailChangeHelper $emailChangeHelper, private readonly EntityManagerInterface $entityManager, private readonly MailerInterface $mailer, ) {} #[Route('/account/email/change', name: 'app_email_change_request')] public function request(Request $request): Response { /** @var User $user */ $user = $this->getUser(); // Your form handling here... $newEmail = $request->request->get('new_email'); try { // Optional: Validate the new email if ($newEmail === $user->getEmail()) { throw new \Makraz\Bundle\VerifyEmailChange\Exception\SameEmailException($newEmail); } // Optional: Check if email is already in use $existingUser = $this->entityManager->getRepository(User::class) ->findOneBy(['email' => $newEmail]); if ($existingUser) { throw new \Makraz\Bundle\VerifyEmailChange\Exception\EmailAlreadyInUseException($newEmail); } // Generate the verification signature $signature = $this->emailChangeHelper->generateSignature( 'app_email_change_verify', // Your verify route name $user, $newEmail ); // Persist the pending email change $this->entityManager->flush(); // Send verification email to the NEW address $email = (new Email()) ->to($newEmail) ->subject('Verify your new email address') ->html(sprintf( 'Click here to verify: <a href="%s">%s</a><br>This link will expire in 1 hour.', $signature->getSignedUrl(), $signature->getSignedUrl() )); $this->mailer->send($email); $this->addFlash('success', 'Verification email sent! Check your new inbox.'); } catch (VerifyEmailChangeExceptionInterface $e) { $this->addFlash('error', $e->getReason()); } return $this->redirectToRoute('app_profile'); } #[Route('/account/email/verify', name: 'app_email_change_verify')] public function verify(Request $request): Response { try { // Validate the link and get the user $user = $this->emailChangeHelper->validateTokenAndFetchUser($request); // Complete the email change $oldEmail = $this->emailChangeHelper->confirmEmailChange($user); // Persist changes $this->entityManager->flush(); // Send notification to OLD email (optional but recommended) $email = (new Email()) ->to($oldEmail) ->subject('Your email address was changed') ->html('Your email address has been successfully changed. If you did not make this change, contact support immediately.'); $this->mailer->send($email); $this->addFlash('success', 'Email changed successfully!'); } catch (VerifyEmailChangeExceptionInterface $e) { $this->addFlash('error', $e->getReason()); return $this->redirectToRoute('app_email_change_request'); } return $this->redirectToRoute('app_profile'); } #[Route('/account/email/cancel', name: 'app_email_change_cancel', methods: ['POST'])] public function cancel(): Response { /** @var User $user */ $user = $this->getUser(); $this->emailChangeHelper->cancelEmailChange($user); $this->entityManager->flush(); $this->addFlash('success', 'Email change cancelled.'); return $this->redirectToRoute('app_profile'); } }
Displaying Pending Email Changes
The bundle provides Twig functions to easily display pending email change status in your templates.
Using Twig Functions (Recommended)
{# templates/account/profile.html.twig #} <h2>Email Address</h2> <div> <strong>Current Email:</strong> {{ app.user.email }} </div> {% if has_pending_email_change(app.user) %} <div class="alert alert-info"> <p>Pending email change to: <strong>{{ get_pending_email(app.user) }}</strong></p> <p>Please check your new email inbox for the verification link.</p> <form method="post" action="{{ path('app_email_change_cancel') }}"> <button type="submit" class="btn btn-secondary">Cancel Email Change</button> </form> </div> {% else %} <a href="{{ path('app_email_change_request') }}" class="btn btn-primary"> Change Email Address </a> {% endif %}
Available Twig Functions
has_pending_email_change(user): Returnstrueif the user has a pending, non-expired email change requestget_pending_email(user): Returns the pending new email address, ornullif none exists
Alternative: Using the Helper Service
You can also check for pending changes directly in your controller:
public function profile(EmailChangeHelper $helper): Response { $user = $this->getUser(); if ($helper->hasPendingEmailChange($user)) { $pendingEmail = $helper->getPendingEmail($user); // ... use in your response } return $this->render('profile.html.twig'); }
How It Works
Flow Diagram
User requests email change
โ
EmailChangeHelper::generateSignature()
โ
Token created & stored (hashed)
โ
Verification email sent to NEW address
โ
User clicks link
โ
EmailChangeHelper::validateTokenAndFetchUser()
โ
Token validated (timing-safe comparison)
โ
EmailChangeHelper::confirmEmailChange()
โ
Email updated, request deleted
โ
Notification sent to OLD address
API Reference
EmailChangeHelper
generateSignature()
Generate a signed verification URL for an email change request.
public function generateSignature( string $routeName, EmailChangeInterface $user, string $newEmail, array $extraParams = [] ): EmailChangeSignature
Parameters:
$routeName: The route name for your verification endpoint$user: The user requesting the change$newEmail: The new email address$extraParams: Additional route parameters (optional)
Returns: EmailChangeSignature with the signed URL and expiration info
Throws:
TooManyEmailChangeRequestsException: If user has a recent pending request
validateTokenAndFetchUser()
Validate an email change request from URL parameters.
public function validateTokenAndFetchUser(Request $request): EmailChangeInterface
Returns: The user who initiated the email change
Throws:
ExpiredEmailChangeRequestException: If the link has expiredInvalidEmailChangeRequestException: If the link is invalid
confirmEmailChange()
Complete the email change after validation.
public function confirmEmailChange(EmailChangeInterface $user): string
Returns: The user's old email address (for notifications)
Throws:
InvalidEmailChangeRequestException: If no pending request exists
cancelEmailChange()
Cancel a pending email change.
public function cancelEmailChange(EmailChangeInterface $user): void
hasPendingEmailChange()
Check if a user has a pending email change request.
public function hasPendingEmailChange(EmailChangeInterface $user): bool
Returns: true if the user has a non-expired pending request, false otherwise
getPendingEmail()
Get the pending new email address for a user.
public function getPendingEmail(EmailChangeInterface $user): ?string
Returns: The pending new email address, or null if no pending request exists
hasPendingRequest()
Check if a user has a pending email change request (returns full request object).
public function hasPendingRequest(EmailChangeInterface $user): bool
Returns: true if the user has a non-expired pending request, false otherwise
getPendingRequest()
Get the full pending email change request for a user.
public function getPendingRequest(EmailChangeInterface $user): ?EmailChangeRequest
Returns: The EmailChangeRequest object, or null if no pending request exists
Events
The bundle dispatches events for extensibility:
EmailChangeInitiatedEvent
Dispatched when an email change request is initiated.
use Makraz\Bundle\VerifyEmailChange\Event\EmailChangeInitiatedEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener] class EmailChangeListener { public function __invoke(EmailChangeInitiatedEvent $event): void { $user = $event->getUser(); $newEmail = $event->getNewEmail(); $oldEmail = $event->getOldEmail(); $verificationUrl = $event->getVerificationUrl(); // Send custom email, log, etc. } }
EmailChangeConfirmedEvent
Dispatched when an email change is confirmed.
use Makraz\Bundle\VerifyEmailChange\Event\EmailChangeConfirmedEvent; #[AsEventListener] class EmailChangeConfirmedListener { public function __invoke(EmailChangeConfirmedEvent $event): void { $user = $event->getUser(); $oldEmail = $event->getOldEmail(); $newEmail = $event->getNewEmail(); // Send notification to old email, reset email verification, etc. } }
EmailChangeCancelledEvent
Dispatched when an email change is cancelled.
Configuration Reference
verify_email_change: # Time in seconds that an email change request is valid # Min: 60 (1 minute), Max: 86400 (24 hours) lifetime: 3600 # default: 1 hour # Enable request throttling to prevent abuse enable_throttling: true # default: true # Time in seconds before a new request can be made # Only used if enable_throttling is true throttle_limit: 3600 # default: 1 hour
Exception Reference
All exceptions implement VerifyEmailChangeExceptionInterface and can be caught using this interface or individually.
EmailAlreadyInUseException
Thrown when attempting to change to an email address that is already in use by another user.
Usage:
use Makraz\Bundle\VerifyEmailChange\Exception\EmailAlreadyInUseException; // Check if email is already in use $existingUser = $entityManager->getRepository(User::class) ->findOneBy(['email' => $newEmail]); if ($existingUser) { throw new EmailAlreadyInUseException($newEmail); }
Error Message: "This email address is already in use."
SameEmailException
Thrown when attempting to change to the same email address as the current one.
Usage:
use Makraz\Bundle\VerifyEmailChange\Exception\SameEmailException; if ($newEmail === $user->getEmail()) { throw new SameEmailException($newEmail); }
Error Message: "The new email address is identical to the current one."
TooManyEmailChangeRequestsException
Thrown when a user tries to create a new email change request too soon after a previous one.
Error Message: "Please wait before requesting another email change."
ExpiredEmailChangeRequestException
Thrown when attempting to verify an email change link that has expired.
Error Message: "The email change link has expired."
InvalidEmailChangeRequestException
Thrown when the email change verification link is invalid or the request doesn't exist.
Error Message: Varies depending on the specific validation failure.
Catching All Exceptions
use Makraz\Bundle\VerifyEmailChange\Exception\VerifyEmailChangeExceptionInterface; try { // Your email change logic } catch (VerifyEmailChangeExceptionInterface $e) { $this->addFlash('error', $e->getReason()); }
Testing
composer install vendor/bin/phpunit
License
This bundle is released under the MIT License. See the LICENSE file for details.
Support
For issues, questions, or contributions, please visit: https://github.com/makraz/verify-email-change-bundle