How to Create a Magic Link in Laravel with Twilio SMS

November 18, 2025
Written by
Popoola Temitope
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Create a Magic Link in Laravel with Twilio SMS

It’s best practice for users to use unique passwords for different websites. While this helps protect their accounts, it also increases the chances of users forgetting their passwords, which negatively impacts the user experience.

Magic links address this issue by allowing users to log in without remembering passwords or relying on the "Forgot Password" feature. Instead, they can simply request a magic login link, making it easier to access their accounts while maintaining robust security and improving the overall user experience.

In this tutorial, you'll learn how to create a magic link in Laravel with Twilio SMS. When a user enters their phone number, they’ll receive a one-time magic link via SMS. Clicking the link will log them into the application instantly.

Prerequisites

To follow along with this tutorial, you will need:

  • PHP 8.3 or higher installed
  • Composer installed globally
  • A Twilio account with an active phone number capable of sending SMS
  • Ngrok installed on your computer and an ngrok account

Create a new Laravel project

Let's get started by creating a new Laravel project using Composer. To do this, open your terminal, navigate to your working directory, and run the command below.

composer create-project laravel/laravel laravel_magic_link_project
cd laravel_magic_link_project

After the installation is complete, open the project folder in your preferred text editor or IDE.

Configure the database

Next, let’s define the database tables and their schema properties. To do this, you need to prepare the application for migration using the command below.

php artisan make:migration RegisterUsers

The command above will create a migration file inside the database/migrations folder. Navigate to the folder, open the migration file that ends with _register_users.php, and replace its code with the following to define the register_users table schema properties:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('register_users', function (Blueprint $table) {
            $table->id();
            $table->string('full_name');
            $table->string('phone_no')->unique();
            $table->string('password');
            $table->string('login_code')->nullable();
            $table->timestamp('login_code_time')->nullable();
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('register_users');
    }
};

Save the changes, then run the command below to migrate the database.

php artisan migrate

Retrieve and store your Twilio credentials as environment variables

Next, let’s add your Twilio Account SID, Access Token, and phone number as environment variables. To do this, open the .env file and add the following code.

BASE_URL=<ngrok-forwarding-url>
TWILIO_ACCOUNT_SID=<twilio-account-sid>
TWILIO_AUTH_TOKEN=<twilio-auth-token>
TWILIO_PHONE_NUMBER=<twilio-phone-number>

Then, to retrieve your Account SID, Access Token, and phone number, log in to your Twilio Console dashboard. In the Account Info section, you will find your Twilio credentials, as shown in the screenshot below.

Screenshot of Twilio account info with SID, Auth Token, phone number, and API Keys section.

Replace <twilio-account-sid>, <twilio-auth-token>, and <twilio-phone-number> in .env with the corresponding Twilio values.

Install Twilio's PHP Helper Library

To enable the application to easily interact with Twilio’s Programmable Messaging API, you need to install the Twilio PHP Helper Library using the following command:

composer require twilio/sdk

Create a database model

Let’s create a database model class named RegisterUser to define how the application interacts with the underlying database table. To do this, run the command below.

php artisan make:model RegisterUsers

The above command will generate a RegisterUsers.php file inside the app/Models directory. Navigate to the folder, open the file, and up it to match the following code.

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

#[\AllowDynamicProperties] 
class RegisterUsers extends Authenticatable
{
    use Notifiable;

    protected $table = 'register_users';
    protected $fillable = [
        'full_name',
        'phone_no',
        'password',
        'login_code',
        'login_code_time',
    ];
    public $timestamps = false;
    protected $hidden = [
        'password',
    ];
    protected $casts = [
        'login_code_time' => 'datetime',
    ];
}

Next, let’s add the RegisterUsers model to config/auth.php to handle authenticated user data. To do this, navigate to the config folder, open the auth.php file, locate the users subsection under the providers section, and replace its code with the following:

'users' => [
    'driver' => 'eloquent',
    'model' => App\Models\RegisterUsers::class,
],

Create the registration controller

Now, to implement the user authentication logic for the application, you need to create a controller file. To do this, run the following command:

php artisan make:controller AuthController

The command above will generate an AuthController.php file inside the app/Http/Controllers folder. Navigate to the folder and open AuthController.php.

Next, let’s create the application logic to allow users to register, log in with a magic link, and view their profile after successful authentication. To do this, replace the contents of the AuthController.php file with the following code:

<?php

namespace App\Http\Controllers;

use App\Models\RegisterUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Twilio\Rest\Client;
use Carbon\Carbon;

class AuthController extends Controller
{
    public function showRegister()
    {
        return view('components.register');
    }

    public function showLogin()
    {
        return view('components.login');
    }

    public function register(Request $request)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'phone' => 'required|string|max:15',
            'password' => 'required|string|min:6|confirmed',
        ]);
        if (RegisterUsers::where('phone_no', $request->phone)->exists()) {
            return back()->with('error', 'A user with this phone number already exists.');
        }

        try {
            RegisterUsers::create([
                'full_name' => $request->name,
                'phone_no' => $request->phone,
                'password' => Hash::make($request->password),
            ]);

            return redirect('/login')->with('success', 'Registration successful. Please login.');
        } catch (\Exception $e) {
            Log::error('Registration error: ' . $e->getMessage());
            return back()->with('error', 'Registration failed. Please try again.');
        }
    }

    public function sendMagicLink(Request $request)
    {
        $request->validate([
            'phone' => 'required|string|max:15',
        ]);

        $user = RegisterUsers::where('phone_no', $request->phone)->first();
        if (!$user) {
            return back()->with('error', 'Phone number not found. Please register first.');
        }
        $magicCode = random_int(100000, 999999);
        $expiresAt = Carbon::now()->addMinutes(10);
        $user->login_code = $magicCode;
        $user->login_code_time = $expiresAt;
        $user->save();
        $loginUrl = route('magic.login', [
            'user_id' => $user->id,
            'code' => $magicCode,
        ]);
        $twilioSid = env('TWILIO_ACCOUNT_SID');
        $twilioAuthToken = env('TWILIO_AUTH_TOKEN');
        $twilioPhone = env('TWILIO_PHONE_NUMBER');

        try {
            $client = new Client($twilioSid, $twilioAuthToken);
            $client->messages->create(
                $user->phone_no,
                [
                    'from' => $twilioPhone,
                    'body' => "Here is your magic login link: $loginUrl (Valid for 10 minutes).",
                ]
            );

            return back()->with('success', 'Magic login code sent via SMS.');
        } catch (\Exception $e) {
            Log::error('Twilio SMS Error: ' . $e->getMessage());
            return back()->with('error', 'Failed to send SMS. Please try again.');
        }
    }

    public function magicLogin(Request $request)
    {
        $user = RegisterUsers::find($request->query('user_id'));
        if (
            !$user ||
            !$user->login_code_time ||
            Carbon::now()->gt(Carbon::parse($user->login_code_time)) ||
            $request->query('code') != $user->login_code
        ) {
            return redirect('/login')->with('error', 'Magic link expired or invalid.');
        }
        Auth::login($user);
        $user->login_code = null;
        $user->login_code_time = null;
        $user->save();
        return redirect('/profile')->with('success', 'Logged in successfully.');
    }

    public function showProfile()
    {
        if (!Auth::check()) {
            return redirect('/login')->with('error', 'You must be logged in to access the profile page.');
        }
        return view('components.profile');
    }
}

In the code above:

  • The register() function handles user registration by validating the input fields, hashing the password, and saving the user data in the database
  • The sendMagicLink() function generates a magic login link and sends it to the user's phone number via SMS using the Twilio Programmable Messaging API
  • The magicLogin() function validates the magic link, logs the user in, and redirects them to the profile page
  • The showProfile() function displays the user's profile page if they are logged in. If the user is not authenticated, they are redirected to the login page

Create the application user Interface

Let’s create the user interface for the registration, login, and profile pages. To do this, run the commands below:

php artisan make:component layout
php artisan make:component register
php artisan make:component login
php artisan make:component profile

The commands generate layout.blade.php, register.blade.php, login.blade.php, and profile.blade.php template files inside the resources/views/components directory.

Open the layout.blade.php file and replace its contents with the following:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>User Authentication</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body style="background-color:#daecef;">
    <div class="container">
        {{ $slot }}
    </div>
</body>
</html>

Open the register.blade.php file and replace its contents with the following:

<x-layout>
    <div class="container d-flex justify-content-center align-items-center vh-100">
        <div class="card p-4 shadow-sm" style="max-width: 400px; width: 100%;">
            <h2 class="text-center mb-4">Register</h2>
            @if (session('success'))
                <div class="alert alert-success text-center">
                    {{ session('success') }}
                </div>
            @endif
            @if (session('error'))
                <div class="alert alert-danger text-center">
                    {{ session('error') }}
                </div>
            @endif
            <form method="POST" action="{{ env('BASE_URL') }}/register">
                @csrf
                <div class="mb-3">
                    <label for="name" class="form-label">Full Name</label>
                    <input type="text" class="form-control" id="name" name="name" placeholder="Enter full name" required>
                </div>
                <div class="mb-3">
                    <label for="phone" class="form-label">Phone Number</label>
                    <input type="tel" class="form-control" id="phone" name="phone" minlength="9" placeholder="Enter phone number" required>
                </div>
                <div class="mb-3">
                    <label for="password" class="form-label">Password</label>
                    <input type="password" class="form-control" id="password" name="password" minlength="7" placeholder="Enter password" required>
                </div>
                <div class="mb-3">
                    <label for="password_confirmation" class="form-label">Confirm Password</label>
                    <input type="password" class="form-control" id="password_confirmation" name="password_confirmation" minlength="7" placeholder="Confirm password" required>
                </div>
                <button type="submit" class="btn btn-success w-100">Register</button>
            </form>
            <p class="text-center mt-3">Already have an account? <a href="{{ url('/login') }}">Login</a></p>
        </div>
    </div>
</x-layout>

Open the login.blade.php file and replace its contents with the following.

<x-layout>
    <div class="container d-flex justify-content-center align-items-center vh-100">
        <div class="card p-4 shadow-sm" style="max-width: 400px; width: 100%;">
            <h2 class="text-center mb-4">Login to your Dashboard</h2>
            @if (session('success'))
                <div class="alert alert-success text-center">
                    {{ session('success') }}
                </div>
            @endif
            @if (session('error'))
                <div class="alert alert-danger text-center">
                    {{ session('error') }}
                </div>
            @endif
            <form method="POST" action="{{ env('BASE_URL') }}/send-magic-link">
                @csrf
                <div class="mb-3">
                    <label for="phone" class="form-label">Phone Number</label>
                    <input type="tel" class="form-control" id="phone" name="phone" placeholder="Enter phone number" required>
                </div>
                <button type="submit" class="btn btn-primary w-100">Send Magic Link</button>
            </form>
        </div>
    </div>
</x-layout>

Finally, open the profile.blade.php file and replace its contents with the following.

<x-layout>
    <div class="container mt-5">
        <div class="mx-auto" style="max-width: 600px;">
            <div class="card border-0 shadow-sm rounded-4 p-4 bg-light">
                <h2 class="text-center mb-4 fw-semibold text-dark">User Profile</h2>
                <div class="mb-4 border-bottom pb-3">
                    <div class="text-muted text-uppercase small">Full Name</div>
                    <div class="fs-5 fw-medium text-dark">{{ auth()->user()->full_name }}</div>
                </div>
                <div class="mb-4 border-bottom pb-3">
                    <div class="text-muted text-uppercase small">Phone Number</div>
                    <div class="fs-5 fw-medium text-dark">{{ auth()->user()->phone_no }}</div>
                </div>
                <form action="{{ env('BASE_URL') }}/logout" method="POST" class="pt-3">
                    @csrf
                    <button type="submit" class="btn btn-outline-danger w-100 rounded-pill py-2">
                        Logout
                    </button>
                </form>
            </div>
        </div>
    </div>
</x-layout>

Configure the application's routes

Next, let’s configure the application routes. To do this, navigate to the routes folder, open the web.php file, and replace its contents with the following:

<?php

use App\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/register', [AuthController::class, 'showRegister']);

Route::post('/register', [AuthController::class, 'register']);

Route::get('/login', [AuthController::class, 'showLogin']);

Route::post('/send-magic-link', [AuthController::class, 'sendMagicLink']);

Route::get('/magic-login', [AuthController::class, 'magicLogin'])->name('magic.login');

Route::get('/profile', [AuthController::class, 'showProfile'])->name('profile');

Route::post('/logout', function () {
    Auth::logout();
    return redirect('/login'); 
})->name('logout');

Start the application

Now, let’s start the application’s development server by running the command below:

php artisan serve

Then, you need to make the application accessible over the internet using ngrok to ensure the magic link works properly. To do this, open another terminal tab or window and run the command below:

ngrok http http://127.0.0.1:8000

The command above will generate a forwarding URL, as shown in the screenshot below.

Screenshot of terminal showing ngrok tunnel details including online status, region, and public URL.

Lastly, open the .env file and replace the <ngrok-forwarding-url> placeholder with the generated forwarding URL.

Test the application

To test the application, open the generated forwarding URL in your browser and append " /register" to it. Then, register a new account, as shown in the screenshot below.

Registration form page with fields for full name, phone number, password, and confirm password, on a light blue background.
Registration form page with fields for full name, phone number, password, and confirm password, on a light blue background.

After successful registration, you will be directed to the login page, where you can log in with a magic link. On the login page, enter your registered phone number and click the Send Magic Link button, as shown in the screenshot below.

Login page prompting user to enter phone number and send magic link.

After requesting the magic link, you will receive an SMS on your phone containing the magic link, as shown in the screenshot below.

Screenshot of a text message containing a Twilio magic login link and expiration details.
Screenshot of a text message containing a Twilio magic login link and expiration details.

Click the magic link to log in to your account, as shown in the screenshot below.

User profile page on a mobile screen showing full name, phone number, and a logout button.
User profile page on a mobile screen showing full name, phone number, and a logout button.

That’s how to create a magic link in Laravel with Twilio SMS

In this tutorial, you learned how to create a secure magic link system to easily authenticate users in a Laravel application using Twilio's Programmable Messaging API. This approach simplifies the login process and enhances security by eliminating the need for traditional passwords.

Popoola Temitope is a mobile developer and a technical writer who loves writing about frontend technologies. He can be reached on LinkedIn .

Magic icons and Link icons created by Freepik on Flaticon.