Self-Destruct Message System with Laravel Fortify and Twilio Verify

December 01, 2025
Written by
Lucky Opuama
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Self-Destruct Message System with Laravel Fortify and Twilio Verify

Ever wished you could send a message that vanishes after someone reads it, just like in spy movies? Maybe it’s a password, a private link, or something sensitive you don’t want lingering in someone’s inbox. That’s exactly what a self-destruct message system does.

In this tutorial, we explore how to build such a secure system using Laravel Fortify for authentication and Twilio Verify for secure two-factor verification.

Prerequisites

Scaffold a new Laravel project

To create a new Laravel project and change into its directory, choose a folder where you usually keep your PHP projects and run the commands below. They scaffold the Laravel app, and then change into the new project’s directory.

laravel new self_destruct && cd self_destruct

During installation, respond to the prompts as follows:

  • Would you like to install a starter kit? — "No starter kit"
  • Which testing framework do you prefer? — "Pest"
  • Which database will your application use? — "SQLite"
  • Would you like to run the default database migrations? (yes/no) — "no"

Next, install the key dependencies required for building this application. These tools will power authentication and SMS verification in your project:

  • Laravel Fortify is a frontend-agnostic authentication backend for Laravel. It provides the core logic for user authentication, which includes login, registration, password reset, and two-factor authentication. Since Fortify handles only the backend, it allows you to build your own custom frontend while securely managing the underlying auth workflows.
  • Twilio's PHP Helper Library enables your application to interact with Twilio’s services with a minimum of code. With this SDK, you can send SMS messages and use Twilio Verify to generate and validate one-time passwords (OTPs) for phone-based authentication.

Now, run the following command to install Laravel Fortify:

composer require laravel/fortify

Next, we need to publish its resources. To do that, run the command below:

php artisan fortify:install

Next in line is Twilio's PHP Helper Library. Run the command below to install it.

composer require twilio/sdk

Retrieve your Twilio credentials

Initially, you'll need the following Twilio credentials: Account SID, Auth token, Twilio phone number, and a Twilio Verify SID. But before retrieving these credentials, copy and paste the following configuration at the bottom of your .env file:

TWILIO_SID=<<your_account_sid>>
TWILIO_AUTH_TOKEN=<<your_auth_token>>
TWILIO_NUMBER=<<your_twilio_phone_number>>
TWILIO_VERIFY_SERVICE_SID=<<your_verify_service_sid>>

Now, log in to your Twilio Console dashboard. In the Account Info panel, copy your Account SID, Auth Token, and Twilio phone number into your .env file, replacing the values of <<your_account_sid>>, <<your_auth_token>>, and <<your_twilio_phone_number>> respectively.

Screenshot of Twilio account information including Account SID, Auth Token, and phone number.

You're almost done getting the credentials, but one more is needed. Navigate to Explore Products > User Authentication & Identity > Verify in your Twilio Console Dashboard. Click the Create new button, then fill out the prompt by providing a Friendly Name, ensure to check the box labeled Authorize the use of Friendly Name,and enable SMS as the verification channel for your service. Then click the Continue button.

A Twilio interface showing options to create a new service with verification channels such as SMS, WhatsApp, Email, and Voice.

The next prompt is a fraud guard that uses automatic SMS fraud detection to block messages from being sent. Tick the Yes radio button, then click Continue to proceed.

Configuration screen for creating a new verification service, showing the option to enable Fraud Guard.

You will be redirected to the Service settings page, where you will see your Service SID. Copy the Service SID and replace <<your_verify_service_sid>> in your .env file with it.

Screenshot of Twilio fallback service settings configuration page with form fields and settings options.

Next, to use these credentials in the Laravel app, navigate to the config directory, open the services.php file, and add the following configuration to the array returned from the file:

'twilio' => [
    'sid' => env('TWILIO_SID'),
    'token' => env('TWILIO_AUTH_TOKEN'),
    'from' => env('TWILIO_NUMBER'),
    'verify_sid' => env('TWILIO_VERIFY_SERVICE_SID'),
],

Now, let’s create a dedicated service class to manage all interactions with Twilio’s Verify API. Start by creating a new folder named Services inside the app directory. Then, within that folder, create a file named TwilioVerifyService.php. Then, open the newly created file and add the following code:

<?php
namespace App\Services;
use Twilio\Rest\Client;
class TwilioVerifyService
{
    protected $twilio;
    protected $verifySid;
    protected $fromPhone;
    public function __construct()
    {
        $this->twilio = new Client(
            config('services.twilio.sid'),
            config('services.twilio.token')
        );
        $this->verifySid = config('services.twilio.verify_sid');
        $this->fromPhone = config('services.twilio.from');
    }
    public function sendOtp(string $phone, ?string $link = null)
    {
        $this->twilio->verify->v2->services($this->verifySid)
            ->verifications
            ->create($phone, "sms");
        if ($link) {
            $this->twilio->messages->create($phone, [
                'from' => $this->fromPhone,
                'body' => "You've received a secure message. Verify to view: $link"
            ]);
        }
    }
    public function checkOtp(string $phone, string $code)
    {
        return $this->twilio->verify->v2->services($this->verifySid)
            ->verificationChecks
            ->create([
                'to' => $phone,
                'code' => $code
            ]);
    }
}

The service class begins with a constructor which loads your Twilio credentials from the config/services.php file, which in turn pulls the values from your .env file.

The class also defines two other methods:

  • sendOtp: This method sends a One-Time Password (OTP) to the recipient’s phone number using Twilio Verify, and sends a secure message link as a follow-up SMS as well
  • checkOtp: This method verifies the OTP entered by the recipient, using Twilio Verify to confirm whether the code is valid

Create the database

Creating the database will be as easy and swift as you've ever seen. Laravel makes working with databases a breeze, thanks to its clean syntax, migration system, and environment-based configuration.

We will create a table named "messages" with columns "id", "content", "recipient_phone", "viewed", and "expires_at". Don’t worry, you will get to know their use as we continue. Now, run the following command to create the table:

php artisan make:migration create_messages_table

Next, navigate to your database/migrations directory, open the recent migration file that ends with create_messages_table.php, and update the file to match the following code.

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('messages', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->text('content');
            $table->string('recipient_phone');
            $table->boolean('viewed')->default(false);
            $table->timestamp('expires_at')->nullable();
            $table->timestamps();
        });
    }

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

Now, run the code below to actually create the "messages" table in the database we just created.

php artisan migrate

Create the controller logic and the model

The controller logic handles a series of methods that create, store, view, and verify messages sent through your application. It's the brain that receives input, interacts with the model, and returns a response, whether it's saving a message, sending an OTP, or checking verification status.

To connect this logic with the database, the model comes into play, which acts as the direct link to the "messages" table. The model handles all data-related operations in a clean object-oriented way.

Now, run the command below to create the controller and the model:

php artisan make:model Message -mc

This command creates a controller named MessageController and a model named Message. Let’s start with the message model. Navigate to your app/Models directory, open the Message.php file, and update the file to match the following code.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;

class Message extends Model
{
    use HasFactory;

    public $incrementing = false;

    protected $keyType = 'string';

    protected $fillable = [
        'id',
        'content',
        'recipient_phone',
        'viewed',
        'expires_at',
    ];

    protected $casts = [
        'viewed' => 'boolean',
        'expires_at' => 'datetime',
    ];

    protected static function boot()
    {
        parent::boot();
        static::creating(function ($model) {
            if (! $model->id) {
                $model->id = (string) Str::uuid();
            }
        });
    }

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Since we're using UUIDs (Universally Unique Identifiers), we don't want the "id" column to auto-increment as it typically does. To prevent this, the $incrementing property is initialized to false. Also, because UUIDs are stored as strings rather than integers, we initialize $keyType to 'string'.

The $fillable array defines which fields are mass assignable. This helps protect the model from mass-assignment vulnerabilities. Lastly, the $casts array tells your application to cast the viewed field to a boolean (true/false), and convert expires_at into a Carbon object, making it easier to work with dates and times.

Next, go to your .env file and add the line below:

APP_BASE_URL="<<your ngrok forwarding url>>"

This APP_BASE_URL variable will prevent you from having to edit your code each time you start the ngrok server.

Now, for the controller logic. Navigate to the app/Http/Controllers directory where you’ll find a new file called MessageController.php. Open this file and update the file to match the following code.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Message;
use App\Services\TwilioVerifyService;
use Illuminate\Support\Str;

class MessageController extends Controller
{
    protected $verifyService;

    public function __construct(TwilioVerifyService $verifyService)
    {
        $this->verifyService = $verifyService;
    }

    public function create()
    {
        return view('messages.create');
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'content' => 'required|string',
            'recipient_phone' => 'required|string',
        ]);
        $message = Message::create([
            'id' => Str::uuid(),
            'content' => $data['content'],
            'recipient_phone' => $data['recipient_phone'],
            'expires_at' => now()->addMinutes(30),
        ]);
        $baseUrl = env('APP_BASE_URL');
        $link = " {$baseUrl}/messages/{$message->id}";
        $this->verifyService->sendOtp($data['recipient_phone'], $link);
        return redirect()->route('messages.create')
            ->with('status', 'Message sent');
    }

    public function show($id)
    {
        $message = Message::findOrFail($id);
        if ($message->viewed || ($message->expires_at && now()->greaterThan($message->expires_at))) {
            return view('messages.expired');
        }
        return view('messages.verify', ['message' => $message]);
    }

    public function verifyOtp(Request $request, $id)
    {
        $message = Message::findOrFail($id);
        if ($message->viewed || ($message->expires_at && now()->greaterThan($message->expires_at))) {
            return view('messages.expired');
        }
        $request->validate([
            'code' => 'required|string',
        ]);
        $check = $this->verifyService->checkOtp($message->recipient_phone, $request->code);
        if ($check->status === 'approved') {
            $message->update(['viewed' => true]);
            return view('messages.view', ['message' => $message]);
        }
        return back()->withErrors(['code' => 'Invalid code.']);
    }
}

From the code above, dependency injection brings in the TwilioVerifyService and assigns it to the $verifyService property, making it accessible throughout the controller's methods.

The create() method displays the form where users can enter the message content and the recipient’s phone number.

The store() method validates the content and recipient_phone fields, stores the message in the database using a UUID as the unique ID, and sets the expiration time to 30 minutes from when the message was sent. It then generates a link using the message UUID, sends an OTP to the recipient via Twilio along with the link, and finally redirects back to the form page with a success message.

The show() method retrieves the message by its UUID, and checks whether it has already been viewed or if it has expired. If the message is expired or has already been viewed, it displays an exception. Otherwise, it shows the OTP verification form when the recipient clicks the link.

Lastly, the verifyOtp() method validates the OTP entered by the user and uses Twilio Verify to check if the code is correct. If the verification is successful, it marks the message as viewed and displays the message content. If the OTP is invalid, it returns an error.

Integrate the frontend template

Now, it's time to add the frontend template. The frontend view follows this structure in the resources/views directory.

├── layouts/
│ └── app.blade.php 
└── messages/
  ├── create.blade.php 
  ├── expired.blade.php 
  ├── verify.blade.php
  └── view.blade.php

To create these templates files, run the following commands:

php artisan make:view messages/create
php artisan make:view messages/expired
php artisan make:view messages/verify
php artisan make:view messages/view

Next, navigate to your resources/views/messages directory, open the create.blade.php file, and add the following code.

@extends('layouts.app')
@section('content')
<div class="max-w-xl mx-auto mt-10">
    <h1 class="text-xl font-bold mb-4">Self-Destructing Message</h1>
    @if(session('status'))
        <div class="bg-green-100 text-green-800 p-2 mb-4 rounded">
            {{ session('status') }}
        </div>
    @endif
    <form method="POST" action="{{ route('messages.store') }}">
        @csrf
        <div class="mb-4">
            <label class="block font-semibold">Recipient Phone Number:</label>
            <input
                type="text"
                name="recipient_phone"
                value="{{ old('recipient_phone') }}"
                class="w-full border p-2 rounded outline-none focus:outline-none"
                required>
            @error('recipient_phone')
                <p class="text-red-600 text-sm mt-1">{{ $message }}</p>
            @enderror
        </div>
        <div class="mb-4">
            <label class="block font-semibold">Message:</label>
            <textarea
                name="content"
                rows="5"
                class="w-full border p-2 rounded outline-none focus:outline-none"
                required>{{ old('content') }}</textarea>
            @error('content')
                <p class="text-red-600 text-sm mt-1">{{ $message }}</p>
            @enderror
        </div>
        <button type="submit" class="bg-blue-600 hover:bg-blue-800 transition text-white px-4 py-2 rounded">
            Send Message
        </button>
    </form>
</div>
@endsection

Next, open the expired.blade.php and add the following code.

@extends('layouts.app')
@section('content')
    <div class="max-w-xl mx-auto mt-10 text-center">
        <h2 class="text-xl font-bold mb-4">❌ Message Unavailable</h2>
    </div>
@endsection

After that, open the verify.blade.php and add the following code.

@extends('layouts.app')
@section('content')
<div class="px-4 sm:px-6 lg:px-8 mt-10 max-w-md sm:max-w-lg mx-auto">
    <h2 class="text-2xl sm:text-3xl font-bold mb-6 text-center">Verify to View Message</h2>
    <p class="mb-4 text-center text-gray-700">
        An OTP has been sent to: <strong>{{ $message->recipient_phone }}</strong>
    </p>
    @if($errors->any())
        <div class="bg-red-100 text-red-700 p-3 mb-4 rounded">
            {{ $errors->first() }}
        </div>
    @endif
    @php
        $baseUrl = env('APP_BASE_URL');
    @endphp
    <form method="POST" action="{{ $baseUrl }}/messages/{{ $message->id }}/verify" class="bg-white p-4 sm:p-6 rounded shadow">
        @csrf
        <label class="block font-semibold mb-1 text-gray-700">Enter OTP:</label>
        <input
            type="number"
            name="code"
            class="w-full border border-gray-300 p-3 rounded focus:ring-2 focus:ring-green-400 focus:outline-none mb-4"
            placeholder="Enter OTP"
            required>
        <button
            type="submit"
            class="w-full bg-green-600 hover:bg-green-700 transition-colors text-white px-4 py-2 rounded font-semibold">
            View Message
        </button>
    </form>
</div>
@endsection

Now, open the view.blade.php and add the following code.

@extends('layouts.app')
@section('content')
    <div class="px-4 sm:px-6 lg:px-8 mt-10 max-w-3xl mx-auto">
        <h1 class="text-2xl sm:text-3xl font-bold mb-6 text-center">🕵️ Message Revealed</h1>
        <div class="bg-gray-100 p-4 sm:p-6 rounded-lg border border-gray-200 shadow-sm">
            <p class="whitespace-pre-wrap text-base sm:text-lg text-gray-800 break-words">
                {{ $message->content }}
            </p>
        </div>
        <div class="mt-6 text-center text-sm text-red-600">
            🔥 This message has now self-destructed.
        </div>
    </div>
@endsection

Now, create a new folder named layouts in your resources/views directory. In this folder, create a new file named app.blade.php and add the following code .

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{{ config('app.name', 'Self-Destruct') }}</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-50 flex flex-col">
    <header class="bg-white shadow">
        <div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
            <a href="" class="text-xl font-semibold text-gray-800">Twilio</a>
        </div>
    </header>
    <main class="flex-1">
        @yield('content')
    </main>
    <footer class="py-4 text-center text-xs text-gray-500">
        &copy; {{ now()->year }} Opuama lucky · Powered by Laravel & Twilio Verify
    </footer>
</body>
</html>

Create the route

The route sets the controller method that should handle the incoming HTTP request and also defines middleware, URL parameters, and response types.

Now, let’s make sure the route is correctly set up. Open the web.php file located in the routes directory, and update it to match the following code:

<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\MessageController;
Route::get('/', function () {
    return view('welcome');
});
Route::get('/messages/create', [MessageController::class, 'create'])->name('messages.create');
Route::post('/messages', [MessageController::class, 'store'])->name('messages.store');
Route::get('/messages/{id}', [MessageController::class, 'show'])->name('messages.show');
Route::post('/messages/{id}/verify', [MessageController::class, 'verifyOtp'])->name('messages.verifyOtp');

Test the application

Let's test the application to see how it works. Start the Laravel app by running the following command:

php artisan serve

Now, in a separate terminal session or tab, start ngrok with this command, creating a secure tunnel between the app and the public internet:

ngrok http 8000

This command will generate a forwarding URL, which you can see in the terminal screenshot below.

Ngrok session details show an error message for an update failure due to access being denied.

Replace the APP_BASE_URL placeholder in your .env file with the forwarding URL which is printed to the terminal by ngrok.

Next, open your browser to http://127.0.0.1:8000/messages/create. You should see a form similar to the one shown below.

Web interface showing fields to enter recipient phone number and message for a self-destructing message service.

Enter the recipient's phone number and type a message. It can be any text, even a secret message; then click the Send Message button.

The recipient will receive an SMS with an OTP code from Twilio Verify, and an SMS with a secure link. Open the secure link, enter the OTP that you received, and click the View Message button.

Screen prompting user to enter OTP sent to phone to view message

Next, you’ll be redirected to the message page as shown in the image below.

Screen showing Message Revealed notification with a self-destruction warning message at the bottom.

The message can only be viewed once. If you try to access it again, you'll see Message Unavailable. So make sure to view the message when you receive the OTP because there are no second chances.

If you reload the page, you'll see that it's no longer available.

Screen showing Message Unavailable error on Twilio interface with copyright notice at the bottom.

That’s how to create a self-destruct message system

With this secure self-destruct message system, you can share sensitive or private information quickly and confidently, knowing it can only be viewed once and is protected with Two-factor verification using Twilio's Verify API.

In this tutorial, we built a lightweight and powerful tool for sending confidential messages that automatically expire after being read or after a set time. This setup helps prevent unauthorized access and ensures that sensitive data doesn’t linger longer than necessary.

How about adding more features such as user authentication, message expiration timers, encryption, and multi-channel delivery to take it even further.

Happy coding!

Lucky Opuama is a software engineer and technical writer with a passion for exploring new tech stacks and writing about them. Connect with him on LinkedIn.

The Disappear icon was created by Freepik and the Message icon was created by pojok d on Flaticon.