milenmk / laravel-email-change-confirmation
A Laravel package for secure email change confirmation with support for both traditional controllers and Livewire components
Requires
- php: ^8.2|^8.3|^8.4
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/notifications: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- illuminate/validation: ^10.0|^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.0
- mockery/mockery: ^1.4.4
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^10.0|^11.0
- squizlabs/php_codesniffer: ^3.13
- tightenco/duster: ^3.2
Suggests
- livewire/livewire: Required for Livewire component support (^3.0)
Conflicts
- laravel/framework: <10.0
This package is auto-updated.
Last update: 2025-08-15 10:50:30 UTC
README
A Laravel package that provides secure email change confirmation functionality. When users attempt to change their email address, they must confirm the change via their current email address before the change takes effect.
Features
- 🔒 Secure email changes - Users must confirm via their current email
- 📧 Automatic email verification - Integrates with Laravel's email verification
- 🎯 Framework agnostic - Works with any Laravel starter kit or custom application
- âš¡ Livewire support - Built-in support for Livewire applications
- 🔧 Highly configurable - Customize every aspect of the package
- 🎨 Extensible - Override controllers, notifications, and services
- 🚀 Auto-detection - Automatically detects email changes using model observers
- 📱 Responsive emails - Beautiful, mobile-friendly confirmation emails
Installation
Requirements
- PHP 8.3 or higher
- Laravel 10.0, 11.0, or 12.0
- User model must use the
Notifiable
trait
Install the package via Composer:
composer require milenmk/laravel-email-change-confirmation
Publish and run the migrations:
php artisan vendor:publish --tag="email-change-confirmation-migrations"
php artisan migrate
Optionally, publish the configuration file:
php artisan vendor:publish --tag="email-change-confirmation-config"
Add the HasEmailChangeConfirmation
trait to your user model
Security Configuration (Recommended)
For enhanced security, add a hash secret to your .env
file:
# Generate a secure 32-byte base64-encoded secret php -r "echo 'EMAIL_CHANGE_HASH_SECRET=' . base64_encode(random_bytes(32)) . PHP_EOL;"
Add the generated line to your .env
file. This enables HMAC-based hashing instead of plain SHA-256 for better
security.
Quick Start
1. Add the Trait to Your User Model
Add the HasEmailChangeConfirmation
trait to your User model:
<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use MilenMk\LaravelEmailChangeConfirmation\Traits\HasEmailChangeConfirmation; class User extends Authenticatable { use HasEmailChangeConfirmation; // ... rest of your model }
2. Ensure Your User Model Uses the Notifiable Trait
The package requires the Notifiable
trait to send emails:
<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use MilenMk\LaravelEmailChangeConfirmation\Traits\HasEmailChangeConfirmation; class User extends Authenticatable { use Notifiable, HasEmailChangeConfirmation; // ... rest of your model }
3. That's It!
The package will automatically detect email changes and handle the confirmation process. When a user tries to change their email:
- The original email remains unchanged
- A confirmation email is sent to the current email address
- The user must click "Confirm" to complete the change
- If the user implements
MustVerifyEmail
, a verification email is sent to the new address
Usage
Automatic Detection (Recommended)
By default, the package automatically detects email changes using model observers. Simply update the user's email as you normally would:
// In a controller $user = auth()->user(); $user->email = 'new@example.com'; $user->save(); // Email change confirmation is automatically triggered
// In a Livewire component public function updateEmail() { $this->user->email = $this->newEmail; $this->user->save(); // Automatically handled }
Manual Integration
If you prefer manual control, disable auto-detection in the config and use the service directly:
use MilenMk\LaravelEmailChangeConfirmation\Facades\EmailChangeConfirmation; // Request an email change $emailChange = EmailChangeConfirmation::requestEmailChange($user, 'new@example.com'); // Check pending changes $pendingChanges = EmailChangeConfirmation::getPendingEmailChanges($user); // Cancel pending changes $cancelled = EmailChangeConfirmation::cancelPendingEmailChanges($user);
Displaying Pending Email Changes
When a user has a pending email change, you can display a notification with a cancel button:
{{-- In your Blade template --}} @if (auth()->user()->hasPendingEmailChange()) @php $pendingChange = auth() ->user() ->getLatestPendingEmailChange(); @endphp <div class="alert alert-warning"> <strong>Pending Email Change</strong> <br /> You have requested to change your email to <strong>{{ $pendingChange->new_email }}</strong> . <br /> Please check your current email address ({{ $pendingChange->current_email }}) for confirmation instructions. <hr /> <form method="POST" action="{{ route('email-change-confirmation.cancel-pending') }}" class="d-inline"> @csrf <button type="submit" class="btn btn-sm btn-outline-danger">Cancel Request</button> </form> </div> @endif
Using the Service Class
use MilenMk\LaravelEmailChangeConfirmation\Services\EmailChangeService; class ProfileController extends Controller { public function updateEmail(Request $request, EmailChangeService $emailChangeService) { $request->validate(['email' => 'required|email']); $user = auth()->user(); $newEmail = $request->input('email'); if ($emailChangeService->validateEmailChange($user, $newEmail)) { $emailChangeService->requestEmailChange($user, $newEmail); return back()->with('success', 'Email change confirmation sent!'); } return back()->withErrors(['email' => 'Invalid email change request.']); } }
Livewire Integration
The package provides seamless Livewire integration:
<?php namespace App\Livewire; use Livewire\Component; class UpdateProfile extends Component { public $email; public function updateEmail() { $this->validate(['email' => 'required|email']); auth() ->user() ->update(['email' => $this->email]); // The package automatically handles the rest and dispatches // a browser event for UI feedback } public function render() { return view('livewire.update-profile'); } }
In your Blade template:
<div x-data="{ showNotification: false }" @email-change-notification.window="showNotification = true; setTimeout(() => showNotification = false, 5000)" > <div x-show="showNotification" class="alert alert-info"> Email change confirmation sent! Check your current email address. </div> <!-- Your form here --> </div>
Configuration
The package is highly configurable. Here are the key configuration options:
// config/email-change-confirmation.php return [ // User model to use 'user_model' => App\Models\User::class, // Auto-detect email changes (recommended) 'auto_detect_email_changes' => true, // Route configuration 'route_prefix' => 'email-change', 'middleware' => ['web', 'auth', 'signed'], // Redirect routes after actions 'redirect_after_confirm' => null, // e.g., 'dashboard' or 'profile.edit' 'redirect_after_deny' => null, // e.g., 'dashboard' or 'profile.edit' 'redirect_after_cancel' => null, // e.g., 'dashboard' or 'profile.edit' // Email settings 'confirmation_email_expire_minutes' => 60, 'from_email' => null, 'from_name' => null, // Cleanup configuration 'auto_cleanup_expired' => true, 'cleanup_schedule' => 'hourly', // hourly, daily, weekly // Notification settings 'send_notification_to_user' => true, 'notification_message' => 'Email change confirmation sent...', // Email verification integration 'auto_send_email_verification' => true, // Security settings 'hash_algorithm' => 'sha256', 'hash_secret' => env('EMAIL_CHANGE_HASH_SECRET'), 'max_pending_changes_per_user' => 1, 'max_requests_per_hour' => 5, 'blocked_domains' => [], // Customization - override these classes 'email_change_model' => MilenMk\LaravelEmailChangeConfirmation\Models\EmailChange::class, 'email_change_controller' => MilenMk\LaravelEmailChangeConfirmation\Controllers\EmailChangeController::class, 'email_change_notification' => MilenMk\LaravelEmailChangeConfirmation\Notifications\EmailChangeConfirmation::class, 'email_change_service' => MilenMk\LaravelEmailChangeConfirmation\Services\EmailChangeService::class, // Livewire integration 'livewire_enabled' => true, 'livewire_notification_event' => 'email-change-notification', ];
Automatic Cleanup of Expired Requests
The package provides automatic cleanup of expired email change requests to prevent database bloat and security issues.
Manual Cleanup
You can manually clean up expired requests using the provided Artisan command:
# Run cleanup synchronously php artisan email-change:cleanup-expired # Dispatch cleanup job to queue php artisan email-change:cleanup-expired --queue
Automatic Cleanup with Task Scheduling
To automatically clean up expired requests, add the command to your app/Console/Kernel.php
:
// app/Console/Kernel.php protected function schedule(Schedule $schedule) { // Clean up expired email changes every hour $schedule->command('email-change:cleanup-expired') ->hourly() ->withoutOverlapping(); // Or run daily at 2 AM $schedule->command('email-change:cleanup-expired') ->dailyAt('02:00') ->withoutOverlapping(); }
Configuration
Configure cleanup behavior in your config file:
// config/email-change-confirmation.php 'auto_cleanup_expired' => true, // Enable automatic cleanup 'cleanup_schedule' => 'hourly', // How often to run (hourly, daily, weekly) 'confirmation_email_expire_minutes' => 60, // When requests expire
When expired requests are cleaned up, they are marked as denied
with a denied_at
timestamp, preserving the audit
trail while preventing them from being used.
Customization
Extending the Controller
Create your own controller that extends the package controller:
<?php namespace App\Http\Controllers; use MilenMk\LaravelEmailChangeConfirmation\Controllers\EmailChangeController as BaseController; use MilenMk\LaravelEmailChangeConfirmation\Models\EmailChange; use Illuminate\Http\RedirectResponse; class CustomEmailChangeController extends BaseController { protected function handleSuccessfulConfirmation(EmailChange $emailChange): RedirectResponse { // Custom logic after successful confirmation // Log the email change \Log::info('Email changed', [ 'user_id' => $emailChange->user_id, 'old_email' => $emailChange->current_email, 'new_email' => $emailChange->new_email, ]); // Send custom notification $emailChange->user->notify(new \App\Notifications\EmailChangedNotification()); return parent::handleSuccessfulConfirmation($emailChange); } protected function getSuccessRedirect(): RedirectResponse { // Custom redirect logic return redirect()->route('profile.settings'); } }
Update your configuration:
// config/email-change-confirmation.php 'email_change_controller' => App\Http\Controllers\CustomEmailChangeController::class,
Configuring Redirects
You can configure where users are redirected after email change actions:
// config/email-change-confirmation.php 'redirect_after_confirm' => 'dashboard', // After confirming email change 'redirect_after_deny' => 'profile.edit', // After denying email change 'redirect_after_cancel' => 'profile.edit', // After canceling pending change
If no redirect route is configured, the package will try common routes like dashboard
, home
, profile.show
, or
profile
, and fall back to the root URL (/
).
For the cancel action specifically, if no redirect is configured, it will use back()
to return to the previous page.
Custom Notification
Create your own notification class:
<?php namespace App\Notifications; use MilenMk\LaravelEmailChangeConfirmation\Notifications\EmailChangeConfirmation as BaseNotification; use Illuminate\Notifications\Messages\MailMessage; class CustomEmailChangeNotification extends BaseNotification { protected function buildMailMessage(string $confirmUrl, string $denyUrl): MailMessage { return (new MailMessage()) ->subject('Confirm Your Email Change - ' . config('app.name')) ->greeting('Hello ' . $this->username . '!') ->line('We received a request to change your email address.') ->line('New email address: **' . $this->newEmail . '**') ->action('Confirm Email Change', $confirmUrl) ->line('If you did not request this change, please click the deny button below.') ->action('Deny Request', $denyUrl) ->line( 'This link will expire in ' . config('email-change-confirmation.confirmation_email_expire_minutes') . ' minutes.', ); } }
Custom Service
Extend the service for custom business logic:
<?php namespace App\Services; use MilenMk\LaravelEmailChangeConfirmation\Services\EmailChangeService as BaseService; use Illuminate\Database\Eloquent\Model; class CustomEmailChangeService extends BaseService { public function requestEmailChange(Model $user, string $newEmail): EmailChange { // Custom validation if ($this->isEmailBlacklisted($newEmail)) { throw new \Exception('This email domain is not allowed.'); } // Custom rate limiting if ($this->hasRecentEmailChangeAttempt($user)) { throw new \Exception('Please wait before requesting another email change.'); } return parent::requestEmailChange($user, $newEmail); } private function isEmailBlacklisted(string $email): bool { // Your custom logic return false; } private function hasRecentEmailChangeAttempt(Model $user): bool { // Your custom logic return false; } }
Working with Different Laravel Setups
Laravel Breeze
Works out of the box. Just add the trait to your User model.
Laravel Jetstream
Works with both Livewire and Inertia stacks. For Inertia, you'll need to handle the frontend notifications manually.
Laravel Fortify
The package integrates seamlessly with Fortify's profile update actions.
Custom Applications
The package is designed to work with any Laravel application structure. Use manual integration if auto-detection doesn't work for your setup.
Trait Methods
// Check if user has pending email changes $user->hasPendingEmailChange(): bool // Get pending email changes $user->pendingEmailChanges(): HasMany // Get latest pending email change $user->getLatestPendingEmailChange(): ?EmailChange // Check if user can request email change $user->canRequestEmailChange(): bool
Service Methods
// Request email change EmailChangeConfirmation::requestEmailChange($user, $newEmail): EmailChange // Confirm email change EmailChangeConfirmation::confirmEmailChange($emailChange): bool // Deny email change EmailChangeConfirmation::denyEmailChange($emailChange): bool // Get pending changes EmailChangeConfirmation::getPendingEmailChanges($user): Collection // Cancel pending changes EmailChangeConfirmation::cancelPendingEmailChanges($user): int // Validate email change EmailChangeConfirmation::validateEmailChange($user, $newEmail): bool
Model Methods
// Check status $emailChange->isConfirmed(): bool $emailChange->isDenied(): bool $emailChange->isPending(): bool // Update status $emailChange->confirm(): bool $emailChange->deny(): bool
Security Features
- Signed URLs: All confirmation links use Laravel's signed URL feature
- HMAC Hash verification: Email addresses are hashed using HMAC-SHA256 for additional security
- Time-based expiration: Confirmation links expire after a configurable time
- User verification: Multiple layers of user identity verification
- Rate limiting: Configurable limits on pending email changes and requests per hour
- Domain blocking: Block disposable/temporary email domains
- Security logging: Comprehensive logging of security events for monitoring
Security Configuration
The package includes several security features that can be configured:
Hash Secret (Recommended)
Set EMAIL_CHANGE_HASH_SECRET
in your .env
file for enhanced security:
- Minimum length: 16 characters (32+ recommended)
- Maximum length: No limit (but 64 characters is sufficient)
- Allowed characters: Any printable ASCII characters, base64-encoded strings recommended
- Generation: Use
php -r "echo base64_encode(random_bytes(32));"
for a secure 44-character base64 string
Rate Limiting
max_requests_per_hour
: Limit email change requests per user (default: 5)- Route-level throttling: Additional protection at the HTTP level
Domain Blocking
blocked_domains
: Array of domains to block (e.g., temporary email services)- Case-insensitive matching
Expiration Settings
confirmation_email_expire_minutes
: How long confirmation links remain valid (default: 60, recommended: 30 or less)
Verification Steps
After installation, verify everything is working:
1. Check Database Tables
Ensure the email_changes
table was created:
DESCRIBE email_changes;
2. Test Email Change
- Log into your application
- Try to change your email address
- Check that:
- The email in the database doesn't change immediately
- You receive a confirmation email at your current address
- The email contains confirm and deny buttons
3. Test Confirmation Flow
- Click the "Confirm" button in the email
- Verify that:
- Your email address is updated in the database
- If you implement
MustVerifyEmail
, you receive a verification email at the new address - You're redirected to the appropriate page
4. Test Denial Flow
- Request another email change
- Click the "Deny" button in the email
- Verify that:
- The email change is marked as denied
- Your original email address remains unchanged
- You're redirected to the appropriate page
Troubleshooting
Issue: Emails Not Sending
Solution:
- Check your mail configuration in
.env
- Test mail sending with
php artisan tinker
:Mail::raw('Test email', function ($message) { $message->to('test@example.com')->subject('Test'); });
- Check your application logs for mail errors
Issue: User Model Doesn't Have Notifiable Trait
Error: User model must use the Notifiable trait
Solution:
Add the Notifiable
trait to your User model:
use Illuminate\Notifications\Notifiable; class User extends Authenticatable { use Notifiable; // ... }
Issue: Auto-Detection Not Working
Solution:
- Ensure the
HasEmailChangeConfirmation
trait is added to your User model - Check that
auto_detect_email_changes
istrue
in config - If still not working, try manual integration
Issue: Routes Not Working
Solution:
- Clear route cache:
php artisan route:clear
- Check that routes are registered:
php artisan route:list | grep email-change
- Ensure middleware configuration is correct
Issue: Migration Fails
Solution:
- Check if you have existing
email_changes
table - If using UUIDs for users, ensure the migration handles this correctly
- Check database connection and permissions
Contributing
Please see CONTRIBUTING.md for details.
Security
See SECURITY.md for more information on how to report security vulnerabilities.
License
The MIT License (MIT). Please see License File for more information.
Credits
Changelog
Please see CHANGELOG.md for more information on what has changed recently.