| Install | |
|---|---|
composer require milenmk/laravel-email-change-confirmation |
|
| Latest Version: | 1.2.1 |
| PHP: | ^8.2|^8.3|^8.4 |
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.
Notifiable traitInstall 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
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.
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
}
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
}
The package will automatically detect email changes and handle the confirmation process. When a user tries to change their email:
MustVerifyEmail, a verification email is sent to the new addressBy 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
}
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);
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
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.']);
}
}
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>
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',
];
The package provides automatic cleanup of expired email change requests to prevent database bloat and security issues.
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
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();
}
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.
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,
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.
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.',
);
}
}
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;
}
}
Works out of the box. Just add the trait to your User model.
Works with both Livewire and Inertia stacks. For Inertia, you'll need to handle the frontend notifications manually.
The package integrates seamlessly with Fortify's profile update actions.
The package is designed to work with any Laravel application structure. Use manual integration if auto-detection doesn't work for your setup.
// 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
// 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
// Check status
$emailChange->isConfirmed(): bool
$emailChange->isDenied(): bool
$emailChange->isPending(): bool
// Update status
$emailChange->confirm(): bool
$emailChange->deny(): bool
The package includes several security features that can be configured:
Set EMAIL_CHANGE_HASH_SECRET in your .env file for enhanced security:
php -r "echo base64_encode(random_bytes(32));" for a secure 44-character base64 stringmax_requests_per_hour: Limit email change requests per user (default: 5)blocked_domains: Array of domains to block (e.g., temporary email services)confirmation_email_expire_minutes: How long confirmation links remain valid (default: 60, recommended: 30 or less)After installation, verify everything is working:
Ensure the email_changes table was created:
DESCRIBE email_changes;
MustVerifyEmail, you receive a verification email at the new addressSolution:
.envphp artisan tinker:
Mail::raw('Test email', function ($message) {
$message->to('test@example.com')->subject('Test');
});
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;
// ...
}
Solution:
HasEmailChangeConfirmation trait is added to your User modelauto_detect_email_changes is true in configSolution:
php artisan route:clearphp artisan route:list | grep email-changeSolution:
email_changes tablePlease see CONTRIBUTING.md for details.
See SECURITY.md for more information on how to report security vulnerabilities.
Please see CHANGELOG.md for more information on what has changed recently.
If this package saves you time, you can support ongoing development:
👉 Become a Patron
Check out my other Laravel packages:
This package is licensed under the MIT License. See the LICENSE file for more details.
This package is provided "as is", without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or noninfringement.
The author(s) make no guarantees regarding the accuracy, reliability, or completeness of the code, and shall not be held liable for any damages or losses arising from its use.
Please ensure you thoroughly test this package in your environment before deploying it to production.