Step 1 - Scaffold
I scaffolded an app with these commands:
laravel new laracasts --jet
cd laracasts
php artisan make:model Device -m
php artisan make:request VerifyEmailRequest
php artisan make:listener SetDeviceInSession --event=Illuminate\Auth\Events\Login
The frontend stack I chose was Livewire, but the steps done here would work with Inertia as well.
Step 2 - Views
I added the field below on both ./resources/views/auth/login.blade.php and ./resources/views/auth/register.blade.php forms:
<div class="mt-4">
<x-jet-label for="device" value="{{ __('Device') }}" />
<x-jet-input id="device" class="block mt-1 w-full" type="text" name="device" :value="old('device')" required />
</div>
Step 3 - Migrations
I didn't change any migrations shipped with Laravel or JetStream.
This is the code for the create_devices_table migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateDevicesTable extends Migration
{
public function up()
{
Schema::create('devices', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->timestamp('verified_at')->nullable();
$table->unique(['user_id', 'name']);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('devices');
}
}
Step 4 - Models
The Device model is pretty simple:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Device extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
'verified_at',
];
protected $casts = [
'verified_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
}
On the User model is where more modifications live on:
<?php
namespace App\Models;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens;
use HasFactory;
use HasProfilePhoto;
use Notifiable;
use TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'remember_token',
'two_factor_recovery_codes',
'two_factor_secret',
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'profile_photo_url',
];
public function devices()
{
return $this->hasMany(Device::class);
}
public function hasVerifiedEmail()
{
if (is_null($this->email_verified_at)) {
return false;
}
if (! \session()->has('device')) {
return false;
}
$device = \session()->get('device');
return ! \is_null($device->verified_at);
}
public function markEmailAsVerified()
{
$now = $this->freshTimestamp();
$device = \session()->get('device');
if ($device && \is_null($device->verified_at)) {
$device->forceFill(['verified_at' => $now])->save();
}
if (\is_null($this->email_verified_at)) {
return $this->forceFill(['email_verified_at' => $now])->save();
}
return false;
}
public function sendEmailVerificationNotification()
{
if (\session()->has('device')) {
$device = \session()->get('device');
VerifyEmail::$createUrlCallback = function ($user) use ($device) {
return URL::temporarySignedRoute(
'verification.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $user->getKey(),
'hash' => sha1($user->getEmailForVerification()),
'device' => Crypt::encryptString($device->getKey()),
]
);
};
}
$this->notify(new VerifyEmail());
}
}
Notable changes:
- Added the
implements MustVerifyEmail, as per docs
- Added the
devices relation
- Override
hasVerifiedEmail to check if the current device in session is already verified
- Override
markEmailAsVerified to also mark the current device in session as verified
- Override
sendEmailVerificationNotification to add the device id as a encrypted query parameter
Step 5 - Modified JetStream Actions
JetStream creates an Actions folder to allow a developer to customize some actions performed.
I just modified the CreateNewUser action to also create a new Device:
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array $input
* @return \App\Models\User
*/
public function create(array $input)
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
'device' => ['required'],
])->validate();
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$device = $user->devices()->create(['name' => $input['device']]);
\session()->put('device', $device);
return $user;
}
}
Step 6 - VerifyEmailRequest
As listed on Step 1 - Scaffold I created a new VerifyEmailRequest form request to extend the one shipped with Laravel.
The idea is to extract the device from the signed URL sent in the verification URL and set it into the session:
<?php
namespace App\Http\Requests;
use Illuminate\Support\Facades\Crypt;
class VerifyEmailRequest extends \Laravel\Fortify\Http\Requests\VerifyEmailRequest
{
protected function prepareForValidation()
{
$device = $this->extractDeviceFromQuery();
if ($device) {
$this->session->put('device', $device);
}
}
private function extractDeviceFromQuery()
{
$deviceId = $this->query('device');
if (! $deviceId) {
return null;
}
try {
$deviceId = Crypt::decryptString($deviceId);
} catch (\Throwable $exception) {
return null;
}
return $this->user()->devices()->find($deviceId);
}
}
Step 7 - SetDeviceInSession
I also created a listener for the Illuminate\Auth\Events\Login, to set the current device in session when a user logs in:
<?php
namespace App\Listeners;
use App\Models\Device;
use Illuminate\Auth\Events\Login;
class SetDeviceInSession
{
public function handle(Login $event)
{
$deviceName = \request()->input('device');
if (! $deviceName) {
\session()->forget('device');
return;
}
$device = Device::query()->updateOrCreate([
'user_id' => $event->user->getKey(),
'name' => $deviceName,
]);
\session()->put('device', $device);
if (\is_null($device->verified_at)) {
$event->user->sendEmailVerificationNotification();
}
}
}
Step 8 - Wire up
To tell Laravel to use the VerifyEmailRequest form request, I registered it in the FortifyServiceProvider:
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
use Laravel\Fortify\Http\Requests\VerifyEmailRequest;
class FortifyServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(VerifyEmailRequest::class, \App\Http\Requests\VerifyEmailRequest::class);
}
public function boot()
{
// these were added by JetStream
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
}
}
To tell Laravel to call the SetDeviceInSession listener, I registered it in the EventServiceProvider:
<?php
namespace App\Providers;
use App\Listeners\SetDeviceInSession;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
// this one was already added by Laravel/JetStream
Registered::class => [
SendEmailVerificationNotification::class,
],
// this one was the one I added
Login::class => [
SetDeviceInSession::class,
],
];
public function boot()
{
//
}
}
Hope this helps.