Laravel Fortify: what it is, when to use it, and first steps to create a flexible authentication system

Video thumbnail

Content Index

Laravel Fortify is a package for authentication, registration, password recovery, email verification, and more. In short, it allows for the same functionalities as Laravel Breeze, which we used before, but the difference is that it is not as intrusive. When we install Laravel Breeze, it installs Tailwind.css and generates several associated components, controllers, views, and routes.

In the case of Laravel Fortify, this is not the case, and it provides the same features without the need for a graphical interface. Therefore, it is particularly useful if you want to develop a more customized authentication backend than what Breeze offers. It is important to note that if you are using Laravel Breeze or a similar solution, it is not necessary to use Laravel Fortify.

Another possible comparison you might be making is with Sanctum. Laravel Fortify and Laravel Sanctum are not mutually exclusive or competing packages; rather, they can be used in the same project if required. Laravel Sanctum only deals with managing API tokens and authenticating existing users using cookies or session tokens.  

Sanctum does not provide any routes that handle user registration, password resets, etc. In short, Sanctum focuses on authenticating a Rest API, and Laravel Fortify is for a traditional web application.

Previously we saw how to use AI in a Laravel project by building our own ChatGTP.

Laravel Fortify: what it is, when to use it, and how to create a flexible authentication system in Laravel

When you're building an application with Laravel and it's time to implement authentication, the same names usually come up: Breeze, Jetstream, Sanctum... and, if you need something more flexible, Laravel Fortify.

In my case, Fortify started to make sense when I needed total control over the frontend, without the framework imposing views, styles, or components that I would end up dismantling anyway. If you've ever installed Breeze "just to try" and then spent more time deleting code than using it, you probably understand why Fortify exists.

In this article, I explain what Laravel Fortify is, when to use it (and when not to), how to configure it correctly, and what real problems usually appear when you take it to production, especially in APIs and SPAs.

What is Laravel Fortify and why is it "headless"?

Laravel Fortify is an authentication backend without a graphical interface.
When it is said to be headless, it means exactly this: Fortify handles all the authentication logic, but does not include views, styles, or UI components.

With Fortify you have, among other things:

  • Login and logout
  • User registration
  • Password recovery
  • Email verification
  • Password confirmation
  • Rate limiting
  • Two-factor authentication (2FA)

All of that already implemented, tested, and maintained by the Laravel team... but without deciding for you how your login form should look.

What it really means that Fortify has no frontend

This is key:

Fortify does define routes and controllers, but it does not define views. If you try to access /login without having configured anything, you will get an error like:

Target [Laravel\Fortify\Contracts\LoginViewResponse] is not instantiable.

It's not a bug. It's Fortify telling you: "you tell me what you want your UI to look like."

This is exactly what makes it so powerful when working with:

  • Custom Blade
  • Vue / React
  • SPAs
  • Pure APIs

When does it make sense to use Laravel Fortify (and when not)?

This is where many people go wrong.

Cases where Fortify fits better than Breeze

  • Laravel Fortify makes perfect sense when:
  • You want total frontend control
  • You don't want Tailwind, components, or automatic scaffolding
  • You are building an SPA or an API
  • You need to customize authentication flows
  • You care about medium/long-term maintainability

In real projects, Fortify has been especially useful when the backend and frontend evolve independently.

Situations where Fortify does not provide advantages

Fortify is not the best option if:

  • You want something quick and visual from minute one
  • You are prototyping
  • You don't care about the default frontend
  • Your app is small and won't grow

In these cases, Laravel Breeze is more than enough and probably more productive.

Laravel Fortify vs Breeze vs Jetstream

Differences in architecture and frontend control

  • Breeze
    • Authentication + views + Tailwind + ready-to-use controllers.
      Very comfortable, but intrusive.
  • Jetstream
    • Even more complete (teams, profiles, etc.).
      Ideal for large apps, but opinionated, I no longer recommend using Jetstream as it is no longer the official one.
  • Fortify
    • Backend only. You decide everything else.

I usually summarize it like this:
Breeze and Jetstream give you a furnished house; Fortify gives you the foundations.

Level of intrusion and maintenance

One of the problems with Breeze is that, as the project grows, you end up fighting with the scaffolding. With Fortify, that doesn't happen: you only consume endpoints and logic.

Laravel Fortify and Laravel Sanctum: how they complement each other

This is a point where there is a lot of confusion.

Laravel Fortify and Laravel Sanctum do not compete.

  • Fortify:
    • Web authentication (login, registration, reset, 2FA...)
  • Sanctum:
    • API authentication (cookies, tokens)

In many real projects I have used Fortify + Sanctum together:

  • Fortify to manage users
  • Sanctum to authenticate API requests

If you are building an SPA, this combination is practically standard.

Laravel Fortify initial installation and configuration

To install Laravel Fortify we use the following command:

 $ composer require laravel/fortify

The next step is to execute the installation command:

 $ php artisan fortify:install

This command will generate the migrations, configuration file, provider, among others.

We execute the migrations:

 $ php artisan migrate

This generates:

  • Migrations
  • config/fortify.php
  • FortifyServiceProvider
  • Actions for login, registration, etc.

From here, Fortify already works... although it still doesn't "look" like it.

And with this, we can now use Laravel Fortify.

Feature configuration in Laravel Fortify

One of the things I like most about Fortify is that you can activate only what you need through features that we can enable or disable as desired:

config\fortify.php

 'features' => [
    Features::registration(),
    Features::resetPasswords(),
    Features::emailVerification(),
],

If you don't want email verification, simply comment it out.
This modular approach makes Fortify adapt very well to real projects, where you don't always need everything from the beginning.

These allow enabling/disabling the registration option, resetting the password, and email verification respectively. If you do not want to use any or several of these options, you simply have to comment them out, for example, if you want to deactivate email verification:

'features' => [
    Features::registration(),
    Features::resetPasswords(),
    // Features::emailVerification(),
],

Using Laravel Fortify without views: total frontend control

If you visit /login or /register without configuring anything, Fortify intentionally fails. That forces you to decide:

  • Blade?
  • Vue?
  • React?
  • External SPA?

When I need simple Blade, I usually define the views in the FortifyServiceProvider:

Fortify::loginView(function () {
   return view('auth.login');
});

The same for registration. Fortify imposes nothing, it just expects you to do it.

Using Laravel Fortify in APIs and SPA applications

Disable views in a pure backend

If Fortify is used only as a backend:

'views' => false,

This avoids behaviors designed for traditional web applications.

Redirects that should be modified

One of the most common problems I encountered is the automatic redirection to /home when a user is already authenticated.

For an API, this makes no sense.

The solution is to adapt middlewares like RedirectIfAuthenticated to return JSON when the request is AJAX.

Fortify middlewares you should understand (and adjust)

RedirectIfAuthenticated

By default, it redirects. In APIs, this often breaks flows.

Detecting $request->expectsJson() and returning a clear response avoids many headaches.

Authenticate middleware

Another typical adjustment is to redirect to the correct frontend when the user is not authenticated, something fundamental when backend and frontend live on different domains.

Common problems when using Laravel Fortify (and how to solve them)

401 error even when authenticated

Very common when working with Sanctum. Normally due to:

  • SANCTUM_STATEFUL_DOMAINS misconfigured
  • SESSION_DOMAIN incorrect or unnecessary

In more than one project, the problem was simply having included http:// where it shouldn't have been.

CORS errors

Another classic.

Always check:

  • config/cors.php
  • allowed_origins
  • routes included in paths

Advanced customization of Laravel Fortify

Fortify allows going much further:

  • Custom rate limiting
  • Change username to email or phone
  • Own authentication flows
  • 2FA with TOTP or even SMS

I have had cases where the default 2FA did not fit the UX, and Fortify allows overriding practically everything without hacks.

Example of use and operation

To exemplify its use, if we do a:

 $ php artisan r:l

We will see all the routes generated by Fortify:

GET|HEAD  register ............................................ register › Laravel\Fortify \RegisteredUserController@create
 POST      register ........................................................ Laravel\Fortify › RegisteredUserController@store
 POST      reset-password ................................... password.update › Laravel\Fortify › NewPasswordController@store
 GET|HEAD  reset-password/{token} ........................... password.reset › Laravel\Fortify › NewPasswordController@create
 GET|HEAD  two-factor-challenge ......... two-factor.login › Laravel\Fortify › TwoFactorAuthenticatedSessionController@create
 POST      two-factor-challenge ............................. Laravel\Fortify › TwoFactorAuthenticatedSessionController@store
 GET|HEAD  up ...............................................................................................................
 GET|HEAD  user/confirm-password ....................................... Laravel\Fortify › ConfirmablePasswordController@show
 POST      user/confirm-password ................... password.confirm › Laravel\Fortify › ConfirmablePasswordController@store
 GET|HEAD  user/confirmed-password-status .. password.confirmation › Laravel\Fortify › ConfirmedPasswordStatusController@show
 POST      user/confirmed-two-factor-authentication two-factor.confirm › Laravel\Fortify › ConfirmedTwoFactorAuthenticationC… 
 PUT       user/password ................................. user-password.update › Laravel\Fortify › PasswordController@update 
 PUT       user/profile-information . user-profile-information.update › Laravel\Fortify › ProfileInformationController@update 
 POST      user/two-factor-authentication ..... two-factor.enable › Laravel\Fortify › TwoFactorAuthenticationController@store 
 DELETE    user/two-factor-authentication .. two-factor.disable › Laravel\Fortify › TwoFactorAuthenticationController@destroy 
 GET|HEAD  user/two-factor-qr-code .................... two-factor.qr-code › Laravel\Fortify › TwoFactorQrCodeController@show
 GET|HEAD  user/two-factor-recovery-codes ........ two-factor.recovery-codes › Laravel\Fortify › RecoveryCodeController@index
 POST      user/two-factor-recovery-codes .................................... Laravel\Fortify › RecoveryCodeController@store
 GET|HEAD  user/two-factor-secret-key ........... two-factor.secret-key › Laravel\Fortify › TwoFactorSecretKeyController@show

If we enter the login route, we will see an error like the following:

http://larafirstepspackages.test/login

Target [Laravel\Fortify\Contracts\LoginViewResponse] is not instantiable.

Since, as we mentioned before, we do not have ready-made pages or screens like in Breeze, we must create them manually (or use them with other technologies such as consuming these routes through a Vue app).

If you comment out the register module:

config\fortify.php

// Features::registration(),

And try to go to the route:

http://larafirstepspackages.test/register

You will see that it returns a 404 page because we have just disabled the action to register users.

If you explore the object:

Fortify

We will see many options that we can customize in Fortify; if we review the file:

app\Providers\FortifyServiceProvider.php

We will see all the Fortify actions that we can customize, such as the lockout time after failed logins:

RateLimiter::for('login', function (Request $request) {
   ***

Login

For example, we can specify the view for login:

class FortifyServiceProvider extends ServiceProvider
{
   /**
    * Register any application services.
    */
   public function register(): void
   {
       //
   }
   public function boot(): void
   {
       ***
       Fortify::loginView(function(){
           return view('auth.login');
       });
   }
}

And its view:

resources\views\auth\login.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>Document</title>
</head>
<body>
   @if ($errors->any())
       @foreach ($errors->all() as $e)
           <div>
               {{ $e }}
           </div>
       @endforeach
   @endif
   <form action="" method="post">
       @csrf
       <input type="text" name="username">
       <input type="password" name="password">
       <input type="submit" value="Send">
   </form>
</body>
</html>

Register

Or to register:

class FortifyServiceProvider extends ServiceProvider
{
   /**
    * Register any application services.
    */
   public function register(): void
   {
       //
   }
   public function boot(): void
   {
       ***
       Fortify::registerView(function(){
           return view('auth.register');
       });
   }
}

Let's create a very simple view like the following:

resources\views\auth\register.blade.php

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>Document</title>
</head>
<body>
   @if ($errors->any())
       @foreach ($errors->all() as $e)
           <div>
               {{ $e }}
           </div>
       @endforeach
   @endif
   <form action="" method="post">
       @csrf
       <input type="text" name="name" placeholder="name">
       <input type="email" name="email">
       <input type="password" name="password" > 
       <input type="submit" value="Send">
   </form>
</body>
</html>

By entering the corresponding routes:

http://larafirstepspackages.test/login

http://larafirstepspackages.test/register

You will see the complete system for login and registration provided by Fortify; these are just some of the actions available, explore their use through the previous examples to practically understand how the package works.

With this, it is up to the reader to explore the rest of the functionalities so that you can use them in your projects that require using Fortify to create a completely customized authentication system instead of Breeze:

https://laravel.com/docs/master/fortify

Frequently asked questions about Laravel Fortify

  • Does Laravel Fortify include frontend?
    • No. It is completely headless.
  • Can Fortify be used with Vue or React?
    • Yes, and it is one of its best use cases.
  • Does Fortify replace Sanctum?
    • No. They complement each other.
  • Is it a good idea to use Fortify in small projects?
    • Only if you know that the project will grow or needs customization.

Conclusion: why Laravel Fortify is ideal for custom authentication

Laravel Fortify is not for all projects, but when it fits, it fits very well.

If you need:

  • Flexibility
  • Control
  • Clean backend
  • Integration with SPAs or APIs

Fortify is one of the best decisions you can make in Laravel.

It doesn't give you a pretty UI, but it gives you something more important: freedom.

QR Code Authentication and Mobile-Web Synchronization - Laravel + Flutter

Video thumbnail

Authentication based on QR codes allows transferring an active, authenticated session from a mobile device to a web browser instantly. This mechanism, popularized by applications like WhatsApp Web, relies on a hybrid architecture where the backend acts as the state coordinator between two completely independent clients: the web browser (guest client) and the native mobile application (authenticated client).

This is a development I did using an AI, and I asked an AI Chat to give me a prompt to achieve QR authentication using Laravel Fortify, this was the result:

Act as a Senior Laravel 11/12 developer and security expert. I want to implement an optimized "WhatsApp Web" style QR code authentication system, REUSING the `LoginToken` table and model that I already have for the 6-character flow.

The simplified flow works like this:
1. The unauthenticated user enters the website and sees a QR code containing the 6-digit numeric token. The token is saved in the DB with a 'pending' status and without a 'user_id' assigned yet.
2. The web page performs Polling (GET requests every 5 seconds via Fetch/AJAX) to an endpoint passing this 6-character token. The polling lasts for a maximum of 1 minute (60 seconds).
3. A mobile app (authenticated via Sanctum) scans the QR, reads the 6 characters, and sends them to an API endpoint. The backend looks for that active token, changes its status to 'approved', and assigns the 'user_id' of the mobile user.
4. In the next polling request from the web, the server detects the 'approved' status, logs the user into the traditional web session using `Auth::loginUsingId($userId)`, and automatically redirects them.

Generate the following complete code from start to finish (no summaries or placeholders):

1. MIGRATION AND MODEL MODIFICATION:
- Indicate how to add the `status` field (string, defaults to 'pending') to the existing `login_tokens` table, keeping `user_id` as nullable.
- Update the `LoginToken` model including a static method `generateForQr(): self`. This method must include a garbage collector that permanently deletes ANY old token from the table whose age (`created_at`) is greater than 5 minutes (`now()->subMinutes(5)`) before creating the new random 6-digit token.

2. WEB CONTROLLER (`QrAuthController`):
- Method to generate the pending QR token and return the view.
- Method `checkStatus(Request $request)` that receives the token as a parameter. If it is expired or does not exist, it returns a JSON with status 'expired'. If it is 'approved', it starts the traditional web session, regenerates the session, deletes the token from the DB, and returns a JSON with `redirect: true` and the redirection URL. If it remains pending, it returns `redirect: false`.

3. BLADE VIEW AND POLLING SCRIPT (`auth/login-qr.blade.php`):
- A clean view with Bootstrap 5 that uses a library via CDN (like qrcode.min.js) to render the QR using the 6-character string.
- Include a native script (or Alpine.js) with a `setInterval` every 5 seconds. It must track a counter: upon reaching 1 minute (12 executions / 60 seconds), it must stop the polling, hide the QR for security, and display a button to refresh/regenerate the QR.

4. ENDPOINT FOR THE MOBILE APP API (`Api/QrVerifyController.php`):
- An `approveQr(Request $request)` method protected by the `auth:sanctum` middleware.
- It must validate that the 6-character token exists, is 'pending', and has not expired. If valid, it updates the record by changing the `status` to 'approved' and associating the `user_id` of the authenticated user in the API (`$request->user()->id`). Returns a success JSON.

5. ROUTE FILES (`web.php` and `api.php`):
- Show the required web routes (under the 'guest' middleware).
- Show the API route protected by Sanctum for approval from the mobile device.

Additional technical requirements:
- Use strict typing in controller methods.
- Implement clean validations and correct HTTP response handling (404 if it does not exist, 410 if expired, etc.).

The QR Concept

A QR code is nothing more than the visual representation of a plain text string (a URL, an identifier, or a cryptographic token). The purpose of the QR in an authentication flow is to serve as an analog-visual communication channel to pass a temporary token from the browser screen to the phone's camera.

QR package in Node

There are many ways to generate a QR code; in this case, we used a Node package:

$ npm i qrcode

Model

To implement this flow, a database schema is required to act as a bridge. An existing structure of single-use temporary tokens (such as those used for passwordless login via email) can be reused, extending its properties with a status control field.

app\Models\LoginToken.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LoginToken extends Model
{
    protected $fillable = ['user_id', 'token', 'expires_at', 'status'];

    protected function casts(): array
    {
        return [
            'expires_at' => 'datetime',
        ];
    }

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

    public function isValid(): bool
    {
        return $this->expires_at->isFuture();
    }

    public function scopePending(Builder $query): void
    {
        $query->where('status', 'pending');
    }

    public static function generateForUser(User $user): self
    {
        static::where('created_at', '<=', now()->subMinutes(5))->delete();

        $token = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

        return static::create([
            'user_id' => $user->id,
            'token' => $token,
            'expires_at' => now()->addMinutes(5),
        ]);
    }

    public static function generateForQr(): self
    {
        static::where('created_at', '<=', now()->subMinutes(5))->delete();

        $token = substr(bin2hex(random_bytes(3)), 0, 6);

        return static::create([
            'user_id' => null,
            'token' => $token,
            'status' => 'pending',
            'expires_at' => now()->addMinutes(5),
        ]);
    }
}

When structuring the table, the user_id field must be defined as nullable (null by default). When the web browser requests to render the QR code, the system does not know which user is in front of the screen; therefore, the record is created solely with a random token and an initial pending status. It will be the mobile application's responsibility to associate the actual user_id upon scanning the code.

The Synchronization Flow: Active Query (Long Polling)

The main challenge of this system is to notify the web browser that the phone has already scanned and approved the access. Unlike persistent bidirectional connections like WebSockets, a highly compatible solution based on Polling (periodic queries) can be implemented using JavaScript on the web client.

Workflow:

  1. Initial Web Request: The user enters the website. The server generates a unique token with a strict expiration time (for example, 5 minutes), stores the record in the database with a pending status, and passes the token to the view.
  2. Canvas Rendering: The frontend receives the token and, using client libraries on a <canvas> element, draws the QR code on the screen.
  3. Start of Polling: A repetitive timer (setInterval) is activated in JavaScript, making an asynchronous request to the API every 5 seconds. This query strictly asks for the status of the current token.
  4. Scanning and Mutation (Mobile): The user scans the QR code from the mobile app. The app sends a signed request with its own authentication token (JWT or Sanctum Bearer Token) to a protected endpoint on the backend, changing the web token status to approved and linking its user ID to the record.
  5. Resolution and Login: In the next query performed by the web browser (maximum 5 seconds later), the server detects that the status changed to approved. At that moment, the backend destroys the temporary token to prevent its reuse, starts the user's web session using the framework's native methods (Auth::login()), and returns a success response that redirects the user to their dashboard.

resources\views\auth\login-qr.blade.php

<x-layouts::auth :title="__('Login with QR')">
    <x-auth-header :title="__('Scan the QR code')" :description="__('Open the mobile app and scan this code to log in.')" />

    <div class="mt-6 flex flex-col items-center space-y-6"
        x-data="qrAuth('{{ $loginToken->token }}', '{{ route('login.qr.check') }}')">

        <div x-show="status === 'pending'" class="relative">
            <canvas id="qr-canvas" class="rounded-xl"></canvas>
        </div>

        <div x-show="status === 'expired'" class="text-center space-y-4">
            <div class="text-gray-400">
                <svg class="mx-auto h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
                </svg>
            </div>
            <p class="text-gray-400 text-sm">{{ __('The QR code has expired.') }}</p>
            <flux:button variant="primary" x-on:click="window.location.reload()">
                {{ __('Generate new QR') }}
            </flux:button>
        </div>

        <div x-show="status === 'approved'" class="text-center space-y-4">
            <div class="text-green-400">
                <svg class="mx-auto h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                </svg>
            </div>
            <p class="text-green-400 text-sm">{{ __('Login successful. Redirecting...') }}</p>
        </div>

        <p class="text-gray-500 text-xs text-center" x-text="statusText"></p>

        <div class="mt-4 text-center">
            <flux:link href="/login" wire:navigate>
                {{ __('Back to login') }}
            </flux:link>
        </div>
    </div>

    <script>
        window.qrAuth = function(token, checkUrl) {
            return {
                token: token,
                checkUrl: checkUrl,
                status: 'pending',
                statusText: '{{ __('Generating QR code...') }}',
                pollCount: 0,
                maxPolls: 12,
                pollInterval: null,

                init() {
                    this.$nextTick(() => {
                        this.renderQr();
                    });
                },

                async renderQr() {
                    try {
                        const canvas = document.getElementById('qr-canvas');
                        if (!canvas) {
                            this.status = 'expired';
                            this.statusText = '{{ __('Error generating QR code') }}';
                            return;
                        }

                        await window.QRCode.toCanvas(canvas, this.token, {
                            width: 220,
                            margin: 2,
                            color: {
                                dark: '#a855f7',
                                light: '#ffffff',
                            },
                        });
                        this.statusText = '{{ __('Scan the code with the mobile app') }}';
                        this.startPolling();
                    } catch (error) {
                        this.status = 'expired';
                        this.statusText = '{{ __('Error generating QR code') }}';
                    }
                },

                startPolling() {
                    this.pollInterval = setInterval(() => {
                        this.pollCount++;

                        if (this.pollCount >= this.maxPolls) {
                            this.stopPolling();
                            this.status = 'expired';
                            this.statusText = '{{ __('The QR code has expired') }}';
                            return;
                        }

                        fetch(`${this.checkUrl}?token=${this.token}`, {
                            headers: { 'Accept': 'application/json' },
                        })
                        .then(response => response.json())
                        .then(data => {
                            if (data.status === 'approved') {
                                this.stopPolling();
                                this.status = 'approved';
                                this.statusText = '{{ __('Login successful') }}';
                                window.location.href = data.redirect;
                            } else if (data.status === 'expired') {
                                this.stopPolling();
                                this.status = 'expired';
                                this.statusText = '{{ __('The QR code has expired') }}';
                            }
                        })
                        .catch(() => {
                            this.statusText = '{{ __('Connection error...') }}';
                        });
                    }, 5000);
                },

                stopPolling() {
                    if (this.pollInterval) {
                        clearInterval(this.pollInterval);
                        this.pollInterval = null;
                    }
                },

                destroy() {
                    this.stopPolling();
                },
            };
        };
    </script>
</x-layouts::auth>

Implementation of Endpoints in the Backend (Laravel)

The system requires two logical controllers: 

1. A public one that responds to queries from the web browser

This method (checkStatus) is invoked recurrently by the polling script. It does not require prior authentication because the web client acts as a guest trying to gain access.

routes\web.php

Route::middleware('guest')->group(function () {
    Route::get('login/qr', [QrAuthController::class, 'showQrForm'])->name('login.qr');
    Route::get('login/qr/check', [QrAuthController::class, 'checkStatus'])->name('login.qr.check');
});

app\Http\Controllers\Social\QrAuthController.php

<?php

namespace App\Http\Controllers\Social;

use App\Http\Controllers\Controller;
use App\Models\LoginToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;

class QrAuthController extends Controller
{
    public function showQrForm(): View
    {
        $loginToken = LoginToken::generateForQr();

        return view('auth.login-qr', ['loginToken' => $loginToken]);
    }

    public function checkStatus(Request $request): JsonResponse
    {
        $request->validate([
            'token' => ['required', 'string', 'size:6'],
        ]);

        $loginToken = LoginToken::where('token', $request->token)->first();

        if (! $loginToken || $loginToken->expires_at->isPast()) {
            if ($loginToken) {
                $loginToken->delete();
            }

            return response()->json([
                'status' => 'expired',
            ]);
        }

        if ($loginToken->status === 'approved' && $loginToken->user_id) {
            $user = $loginToken->user;
            // clears the guest session
            session()->regenerate();
            Auth::login($user);
            $loginToken->delete();

            return response()->json([
                'status' => 'approved',
                'redirect' => route('dashboard'),
            ]);
        }

        return response()->json([
            'status' => 'pending',
        ]);
    }
}

2. One protected by middleware that handles confirmation from the mobile device

This endpoint must be protected by the API authentication middleware (auth:sanctum). This ensures that the user sending the request from the phone is fully identified.

routes\api.php

Route::post('login/qr/approve', [QrVerifyController::class, 'approveQr'])
    ->middleware('auth:sanctum');

app\Http\Controllers\Api\QrVerifyController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\LoginToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class QrVerifyController extends Controller
{
    public function approveQr(Request $request): JsonResponse
    {
        $request->validate([
            'token' => ['required', 'string', 'size:6'],
        ]);

        $loginToken = LoginToken::where('token', $request->token)
            ->whereNull('user_id')
            ->pending()
            ->first();

        if (! $loginToken || $loginToken->expires_at->isPast()) {
            if ($loginToken) {
                $loginToken->delete();
            }

            return response()->json([
                'message' => 'The token has expired or is invalid.',
            ], 422);
        }

        $loginToken->update([
            'user_id' => $request->user()->id,
            'status' => 'approved',
        ]);

        return response()->json([
            'message' => 'Login approved successfully.',
        ]);
    }
}

Integrating the QR Code Reader into the Mobile Client (Flutter)

The mobile client uses the device's native camera through specialized packages like mobile_scanner. Upon detecting the data embedded in the QR, it must process the string, extract the token, and immediately invoke the API approval endpoint.

It is critical for operating system memory management to free up hardware resources (the camera) when the user leaves the scanning screen. Otherwise, the mobile application may experience memory leaks or visual freezes ("black screens").

import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:easy_localization/easy_localization.dart';

import 'package:desarrollolibre/generated/locale_keys.g.dart';
import 'package:desarrollolibre/utils/api/academy_helper.dart';
import 'package:desarrollolibre/utils/helpers.dart';

class QrLoginPage extends StatefulWidget {
  static const ROUTE = "/academy/qr-login";

  final String userToken;

  const QrLoginPage({super.key, required this.userToken});

  @override
  State<QrLoginPage> createState() => _QrLoginPageState();
}

class _QrLoginPageState extends State<QrLoginPage> {
  bool _loading = false;
  // Instantiate the controller to be able to pause/turn off the camera
  final MobileScannerController _scannerController = MobileScannerController(
    detectionSpeed:
        DetectionSpeed.noDuplicates, // Avoids rare duplicate captures
  );

  @override
  void dispose() {
    // We make sure to release the camera when the widget dies completely
    _scannerController.dispose();
    super.dispose();
  }

  Future<void> _onDetect(BarcodeCapture capture) async {
    if (_loading) return;

    final barcode = capture.barcodes.firstOrNull;
    if (barcode == null || barcode.rawValue == null) return;

    final token = barcode.rawValue!.trim();

    if (token.length != 6) return;

    setState(() => _loading = true);

    // Key stability points: We pause the scanner so that it does not keep
    // analyzing images while we process the HTTP request
    await _scannerController.stop();

    final success = await AcademyHelper.qrLoginApprovePost(
      widget.userToken,
      token,
    );

    if (!mounted) return;

    setState(() => _loading = false);

    if (success) {
      showToastMessage(context, LocaleKeys.qrLoginApproved.tr());
      // We return safely, the camera is already turned off
      Navigator.of(context).pop(true);
    } else {
      showToastMessage(context, LocaleKeys.qrTokenExpired.tr());
      // If it failed (e.g., expired web token), we turn the camera back on to retry
      await _scannerController.start();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(LocaleKeys.scanQr.tr())),
      body: Stack(
        children: [
          MobileScanner(
            controller: _scannerController, // Assign the controller
            onDetect: _onDetect,
          ),
          if (_loading)
            Container(
              color: Colors
                  .black54, // Dims the screen slightly during loading
              child: const Center(child: CircularProgressIndicator()),
            ),
        ],
      ),
    );
  }
}

Project Synchronization and Context Transfer between AIs

When developing systems involving decoupled architectures (such as a Laravel backend and an independent Flutter mobile client), one of the biggest challenges is maintaining design consistency between both projects when working with Artificial Intelligence.

If you open a new chat for mobile development without providing the context of the backend you previously built, the AI will lack visibility regarding endpoint names, request structures, required parameters, or authentication mechanisms. Consequently, it will generate inconsistent code that breaks the integration.

There are two main strategies to transfer the technical context from one project to another efficiently and avoid having to rewrite specifications from scratch:

1. Unified Directory Strategy (Temporary Monorepo)

The most direct solution for the AI to assimilate the full context of both ecosystems consists of temporarily grouping both projects within the same root or workspace (for example, opening the general containing folder in the code editor).

By cloning or moving the Flutter project folder directly next to the Laravel directory, you share the same execution environment. This allows developer assistance tools or integrated AI terminals to automatically read and index Eloquent models, API routes, and controllers from one side, to then autocomplete or generate views, HTTP calls, and state controllers in Flutter on the other side, maintaining absolute consistency in method signatures.

Dynamic Technical Documentation Strategy (Context Markdown)

If you prefer to keep both repositories strictly separate for architectural or team performance reasons, the ideal method is to force the first AI (the one that developed the backend) to compile a structured executive summary in Markdown (.md) format.

Prompt: 

Generate a structured technical summary in a single Markdown (.md) block containing exclusively the necessary context for another AI to implement the mobile client in Flutter. Include:
1. Generated API endpoints with their HTTP methods.
2. Exact format of the JSON objects expected by the server (Request) and those it responds with (Response).
3. Critical business rules (required parameters, token expiration, statuses).
Avoid introductions and go straight to the Markdown format.

This technical file acts as an API contract or synchronization manifesto, which you can drag directly to the start of the conversation for the new mobile project, and this was the result:

# Integration of Code Authentication to Email in Flutter

## Flow

1. The user enters their email → a 6-digit code is sent to their email.
2. The user enters the code → it is verified and a Sanctum token is obtained.

---

## 1. Send code to email

```
POST [https://laradesarrollolibre13.test/api/v1/login/token/send](https://laradesarrollolibre13.test/api/v1/login/token/send)
```

### Headers

| Header | Value |
|--------|-------|
| `Accept` | `application/json` |
| `Content-Type` | `application/json` |

### Body

```json
{
"email": "user@example.com"
}
```

### 200 Response

```json
{
"message": "Code sent to your email address.",
"token_id": 42
}
```

The `token_id` must be stored locally for the second step.

### 422 Response — Email does not exist

```json
{
"message": "...",
"errors": {
"email": ["The selected email is invalid."]
}
}
```

### Rate limit

10 requests per minute per email+IP.

---

## 2. Verify code

```
POST [https://laradesarrollolibre13.test/api/v1/login/token/verify](https://laradesarrollolibre13.test/api/v1/login/token/verify)
```

### Headers

| Header | Value |
|--------|-------|
| `Accept` | `application/json` |
| `Content-Type` | `application/json` |

### Body

```json
{
"token_id": 42,
"token": "583291"
}
```

### 200 Response — Successful login

```json
{
"message": "Login successful.",
"user": {
"id": 1,
"name": "User",
"email": "user@example.com",
...
},
"token": "1|sanctum_token_plain_text_here"
}
```

The `token` is a plain text Sanctum token. It must be stored securely and sent as `Authorization: Bearer {token}` from then on.

### 422 Response — Incorrect code

```json
{
"message": "The entered code is incorrect."
}
```

### 422 Response — Expired code

```json
{
"message": "The code has expired. Request a new one."
}
```

---

## Dependencia

```yaml
dependencies:
 http: ^1.2.0
```

## Implementación

```dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

class EmailTokenAuth {
 final String apiBaseUrl;
 final http.Client _client = http.Client();

 EmailTokenAuth({required this.apiBaseUrl});

 Future<({int tokenId, String message})> sendToken(String email) async {
   final response = await _client.post(
     Uri.parse('$apiBaseUrl/api/v1/login/token/send'),
     headers: {
       'Accept': 'application/json',
       'Content-Type': 'application/json',
     },
     body: jsonEncode({'email': email}),
   );

   final body = jsonDecode(response.body);

   if (response.statusCode != 200) {
     throw Exception(body['message'] ?? 'Error al enviar código');
   }

   return (tokenId: body['token_id'] as int, message: body['message'] as String);
 }

 Future<({String message, Map user, String token})> verifyToken({
   required int tokenId,
   required String code,
 }) async {
   final response = await _client.post(
     Uri.parse('$apiBaseUrl/api/v1/login/token/verify'),
     headers: {
       'Accept': 'application/json',
       'Content-Type': 'application/json',
     },
     body: jsonEncode({'token_id': tokenId, 'token': code}),
   );

   final body = jsonDecode(response.body);

   if (response.statusCode != 200) {
     throw Exception(body['message'] ?? 'Error al verificar código');
   }

   return (
     message: body['message'] as String,
     user: body['user'] as Map,
     token: body['token'] as String,
   );
 }

 void dispose() {
   _client.close();
 }
}
```

## Ejemplo de uso con UI

```dart
class EmailLoginScreen extends StatefulWidget {
 final String apiBaseUrl;

 const EmailLoginScreen({super.key, required this.apiBaseUrl});

 @override
 State<EmailLoginScreen> createState() => _EmailLoginScreenState();
}

class _EmailLoginScreenState extends State<EmailLoginScreen> {
 final _auth = EmailTokenAuth(apiBaseUrl: 'https://laradesarrollolibre13.test');
 final _emailCtrl = TextEditingController();
 final _codeCtrl = TextEditingController();
 bool _loading = false;
 int? _tokenId;
 String? _message;

 Future<void> _sendToken() async {
   setState(() => _loading = true);
   try {
     final result = await _auth.sendToken(_emailCtrl.text.trim());
     setState(() {
       _tokenId = result.tokenId;
       _message = result.message;
     });
   } catch (e) {
     if (mounted) {
       ScaffoldMessenger.of(context).showSnackBar(
         SnackBar(content: Text('$e')),
       );
     }
   } finally {
     if (mounted) setState(() => _loading = false);
   }
 }

 Future<void> _verifyToken() async {
   if (_tokenId == null) return;

   setState(() => _loading = true);
   try {
     final result = await _auth.verifyToken(
       tokenId: _tokenId!,
       code: _codeCtrl.text.trim(),
     );

     if (mounted) {
       // Guardar result.token como Sanctum token para próximas requests
       // Navegar a home
     }
   } catch (e) {
     if (mounted) {
       ScaffoldMessenger.of(context).showSnackBar(
         SnackBar(content: Text('$e')),
       );
     }
   } finally {
     if (mounted) setState(() => _loading = false);
   }
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('Iniciar sesión')),
     body: Padding(
       padding: const EdgeInsets.all(24),
       child: Column(
         children: [
           TextField(
             controller: _emailCtrl,
             decoration: const InputDecoration(labelText: 'Email'),
             keyboardType: TextInputType.emailAddress,
           ),
           const SizedBox(height: 16),
           ElevatedButton(
             onPressed: _loading ? null : _sendToken,
             child: const Text('Enviar código'),
           ),
           if (_tokenId != null) ...[
             const SizedBox(height: 24),
             TextField(
               controller: _codeCtrl,
               decoration: const InputDecoration(
                 labelText: 'Código de 6 dígitos',
                 hintText: '123456',
               ),
               keyboardType: TextInputType.number,
               maxLength: 6,
             ),
             const SizedBox(height: 16),
             ElevatedButton(
               onPressed: _loading ? null : _verifyToken,
               child: const Text('Verificar código'),
             ),
           ],
           if (_loading) const Padding(
             padding: EdgeInsets.all(16),
             child: CircularProgressIndicator(),
           ),
         ],
       ),
     ),
   );
 }

 @override
 void dispose() {
   _auth.dispose();
   _emailCtrl.dispose();
   _codeCtrl.dispose();
   super.dispose();
 }
}
```

  ## Notes

- The code expires 5 minutes after being sent.
- `token_id` is the ID of the record in the `login_tokens` table, required for verification.
- The returned Sanctum token must be persisted (SharedPreferences, secure storage, etc.).
- There is a rate limit of 10 requests/minute for both endpoints.
- Both endpoints are public (do not require prior authentication).

Authentication via Temporary Codes (OTP) by Email in Laravel Fortify

Video thumbnail

Authentication based on temporary one-time passwords or OTPs representing a modern, passwordless alternative to the classic credential flow. This mechanism allows verifying the user's identity by sending a six-digit numerical token directly to their email inbox, eliminating the need to manage or remember passwords on the client device.

At the user experience level, this flow commonly consists of two consecutive interfaces: a first screen for capturing and validating the email, and a second window dedicated exclusively to entering and verifying the received token.

1. The Token Generation and Delivery Flow

When the user submits the form with their email address, the backend processes the request through a dispatch method that coordinates persistence, security, and messaging:

[ Web Client ] --( 1. Sends Email )--> [ Backend: sendToken() ]
                                               |
                                        ( 2. Finds User )
                                        ( 3. Registers OTP )
                                               |
                                        ( 4. Saves to Session )
                                               |
                                               v
[ Inbox ] <--( 5. Dispatches Mail )-- [ Email Service ]

Send OTP Token

This is the controller to:

  1. showEmailForm(): Render the form to request the email
  2. showCodeForm(): Render the form to ask for the token
  3. sendToken(): Generate OTP Token
  4. verifyToken(): Validate OTP Token

app\Http\Controllers\Social\LoginTokenController.php

<?php

namespace App\Http\Controllers\Social;

use App\Http\Controllers\Controller;
use App\Mail\LoginTokenMail;
use App\Models\LoginToken;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;

class LoginTokenController extends Controller
{
    public function showEmailForm(): View
    {
        return view('auth.login-token-email');
    }

    public function sendToken(Request $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'email', 'exists:users,email'],
        ]);

        $user = User::where('email', $request->email)->first();

        $loginToken = LoginToken::generateForUser($user);

        Mail::to($user)->send(new LoginTokenMail($user, $loginToken->token));

        session(['login_token_id' => $loginToken->id]);

        return redirect()->route('login.token.code');
    }

    public function showCodeForm(): View|RedirectResponse
    {
        if (! session('login_token_id')) {
            return redirect()->route('login.token');
        }

        return view('auth.login-token-code');
    }

    public function verifyToken(Request $request): RedirectResponse
    {
        $request->validate([
            'token' => ['required', 'string', 'size:6'],
        ]);

        $tokenId = session('login_token_id');

        if (! $tokenId) {
            return redirect()->route('login.token')
                ->withErrors(['email' => 'The session has expired. Request a new code.']);
        }

        $loginToken = LoginToken::find($tokenId);

        if (! $loginToken || ! $loginToken->isValid()) {
            session()->forget('login_token_id');

            return redirect()->route('login.token')
                ->withErrors(['token' => 'The code has expired. Request a new one.']);
        }

        if ($loginToken->token !== $request->token) {
            return redirect()->back()
                ->withErrors(['token' => 'The entered code is incorrect.']);
        }

        $user = $loginToken->user;

        Auth::login($user);
        $loginToken->delete();
        session()->forget('login_token_id');
        session()->regenerate();

        return redirect()->intended('/dashboard');
    }
}

Storing the identifier of the generated token (auth_token_id) within the client's web session instead of relying solely on the user ID is an architectural security requirement. If a user attempts to log in simultaneously from two different browsers, each environment will isolate its own flow in an independent HTTP session. In this way, the system knows exactly which code to evaluate on each screen without mixing the pending requests in the database.

Routes:

routes\web.php

<?php

use App\Http\Controllers\Social\LoginTokenController;
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    Route::get('login/token', [LoginTokenController::class, 'showEmailForm'])->name('login.token');
    Route::post('login/token', [LoginTokenController::class, 'sendToken'])->name('login.token.send')
        ->middleware('throttle:login-token');
    Route::get('login/token/code', [LoginTokenController::class, 'showCodeForm'])->name('login.token.code');
    Route::post('login/token/verify', [LoginTokenController::class, 'verifyToken'])->name('login.token.verify')
        ->middleware('throttle:login-token');
});

Model

Unlike the QR authentication flow, where the user_id field is initialized as null because the identity of the client in front of the screen is unknown, in OTP authentication the user_id is explicitly linked from the very first moment. However, both flows share exactly the same data structure and the same strict five-minute expiration period (generateForQr()).

app\Models\LoginToken.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LoginToken extends Model
{
    protected $fillable = ['user_id', 'token', 'expires_at', 'status'];

    protected function casts(): array
    {
        return [
            'expires_at' => 'datetime',
        ];
    }

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

    public function isValid(): bool
    {
        return $this->expires_at->isFuture();
    }

    public function scopePending(Builder $query): void
    {
        $query->where('status', 'pending');
    }

    public static function generateForUser(User $user): self
    {
        static::where('created_at', '<=', now()->subMinutes(5))->delete();

        $token = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

        return static::create([
            'user_id' => $user->id,
            'token' => $token,
            'expires_at' => now()->addMinutes(5),
        ]);
    }
}

Views

The view for asking for the email:

resources\views\auth\login-token-email.blade.php

<x-layouts::auth :title="__('Log in with code')">
    <x-auth-header :title="__('Log in with code')" :description="__('Enter your email to receive a 6-digit verification code.')" />

    <form method="POST" action="{{ route('login.token.send') }}" class="mt-6 space-y-6">
        @csrf

        <flux:input
            type="email"
            name="email"
            :label="__('Email')"
            :placeholder="__('you@email.com')"
            required
            autofocus
        />

        @if ($errors->has('email'))
            <flux:error>{{ $errors->first('email') }}</flux:error>
        @endif

        <flux:button type="submit" variant="primary" class="w-full">
            {{ __('Send code') }}
        </flux:button>
    </form>

    <div class="mt-6 text-center">
        <flux:link href="/login" wire:navigate>
            {{ __('Back to login') }}
        </flux:link>
    </div>
</x-layouts::auth>

For the OTP code:

resources\views\emails\login-token.blade.php

@extends('emails.layout')

@section('content')

<h2 style="margin: 0 0 24px 0; font-size: 24px; font-weight: 700; color: #111827;">
    Hello {{ $user->name }}!
</h2>

<p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.6; color: #4b5563;">
    You have requested to log in to <strong style="color: #111827;">{{ config('app.name') }}</strong>.
    Here is your verification code:
</p>

<div class="card card-purple" style="background-color: #7c3aed; color: #ffffff; border: none; border-radius: 8px; padding: 24px; margin: 24px 0; text-align: center;">
    <p style="margin: 0 0 8px 0; font-size: 14px; font-weight: 600; color: #e9d5ff; text-transform: uppercase; letter-spacing: 1px;">
        Your login code
    </p>
    <p style="margin: 0; font-size: 36px; font-weight: 700; color: #ffffff; letter-spacing: 8px;">
        {{ $token }}
    </p>
</div>

<div class="panel" style="border-left: 4px solid #7c3aed; background-color: #f3f4f6; padding: 16px 20px; margin: 20px 0; border-radius: 4px;">
    <p style="margin: 0; font-size: 14px; color: #374151; line-height: 1.6;">
        <strong>Important:</strong> This code expires in <strong>5 minutes</strong>.
        If you did not request this login, please ignore this message.
    </p>
</div>

<hr class="divider" style="border: 0; border-top: 1px solid #e5e7eb; margin: 32px 0;">

<p style="margin: 0 0 8px 0; font-size: 16px; color: #4b5563; line-height: 1.6;">
    Regards,
</p>
<p style="margin: 0; font-size: 16px; font-weight: 600; color: #111827;">
    {{ config('app.name') }}
</p>

@endsection

And the class:

app\Mail\LoginTokenMail.php

<?php

namespace App\Mail;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class LoginTokenMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(
        public User $user,
        public string $token,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Tu código de inicio de sesión',
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.login-token',
        );
    }

    public function attachments(): array
    {
        return [];
    }
}

Adaptability and Integration in Mobile Clients (API)

This authentication flow is agnostic with respect to client technology and can be perfectly integrated into mobile applications (such as a native development in Flutter) by exposing the same methods through a RESTful API.

The only architectural variation lies in the final authorization method: while the web platform generates a session cookie after executing Auth::login(), the API endpoint dedicated to the mobile environment must intercept the code validation and return a personal access token managed by Laravel Sanctum.

app\Http\Controllers\Api\LoginTokenController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Mail\LoginTokenMail;
use App\Models\LoginToken;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;

class LoginTokenController extends Controller
{
    public function sendToken(Request $request): JsonResponse
    {
        $request->validate([
            'email' => ['required', 'email', 'exists:users,email'],
        ]);

        $user = User::where('email', $request->email)->first();

        $loginToken = LoginToken::generateForUser($user);

        Mail::to($user)->send(new LoginTokenMail($user, $loginToken->token));

        return response()->json([
            'message' => 'Code sent to your email address.',
            'token_id' => $loginToken->id,
        ]);
    }

    public function verifyToken(Request $request): JsonResponse
    {
        $request->validate([
            'token_id' => ['required', 'integer', 'exists:login_tokens,id'],
            'token' => ['required', 'string', 'size:6'],
        ]);

        $loginToken = LoginToken::findOrFail($request->token_id);

        if (! $loginToken->isValid()) {
            $loginToken->delete();

            return response()->json([
                'message' => 'The code has expired. Request a new one.',
            ], 422);
        }

        if ($loginToken->token !== $request->token) {
            return response()->json([
                'message' => 'The entered code is incorrect.',
            ], 422);
        }

        $user = $loginToken->user;
        $loginToken->delete();

        return response()->json([
            'message' => 'Successful login.',
            'user' => $user,
            'token' => getUserTokenAuth(user: $user),
        ]);
    }
}

Route:

routes\api.php

use App\Http\Controllers\Api\LoginTokenController;
use Illuminate\Support\Facades\Route;

Route::post('login/token/send', [LoginTokenController::class, 'sendToken'])
    ->middleware('throttle:login-token');
Route::post('login/token/verify', [LoginTokenController::class, 'verifyToken'])
    ->middleware('throttle:login-token');

Just like in the QR system, the issuance of this Bearer Token must be centralized in a single global helper function in the backend, ensuring that any business rule or device restriction is evaluated at a single point before granting access to the platform.

function getUserTokenAuth(string $token = 'myapptoken', ?User $user = null)
{
    if ($user == null) {
        $user = auth()->user() ?? auth('sanctum')->user();
    }
    
    if ($user != null) {
        // 1. We capture the User-Agent from the Request
        $userAgent = request()->userAgent() ?? 'Unknown Device';

        // 2. We format the User-Agent to be a short, readable device name
        if (str_contains($userAgent, 'Dart')) {
            // Flutter requests (The default User-Agent is usually "Dart/X.X (dart:io)")
            $deviceName = 'Mobile App (Flutter)';
        } elseif (str_contains($userAgent, 'Postman')) {
            $deviceName = 'Postman API Client';
        } elseif (str_contains($userAgent, 'Chrome')) {
            $deviceName = 'Chrome Browser';
        } elseif (str_contains($userAgent, 'Safari') && ! str_contains($userAgent, 'Chrome')) {
            $deviceName = 'Safari Browser';
        } elseif (str_contains($userAgent, 'Firefox')) {
            $deviceName = 'Firefox Browser';
        } else {
            // If it is a rare agent, we take the first 40 readable characters
            $deviceName = Str::limit($userAgent, 40, '...');
        }

        // 4. We create the new clean token by assigning it the formatted name
        return $user->createToken($deviceName)->plainTextToken;

    }

    return '';
}

Frequently asked questions about Laravel Breeze

  • Does Laravel Breeze work in Laravel 12?
    • Yes, it works perfectly, although it is installed manually.
  • Can I use Breeze just as a base and then delete things?
    • Yes, in fact, it is a common practice.
  • When should I switch to Jetstream?
    • When you need teams, 2FA, or other advanced features.
  • Is Breeze suitable for production projects?
    • Yes, as long as you adapt the security and configuration to the real environment.
  • What files and routes does Laravel Breeze create?
  • Authentication routes and auth middleware
    • Breeze creates routes for:
      • /login
      • /register
      • /forgot-password
      • /dashboard

Conclusion

Laravel Breeze is not just an “authentication package”. It is a clean and didactic way to start Laravel projects, understand their structure, and build on a solid base.

If you are taking your first steps, start simple, understand what you use, and grow from there. Breeze fits perfectly into that philosophy.

Next step, learn how to create social authentication on Google/Gmail or GitHub with Laravel Socialite.

Laravel Fortify is a package for authentication, registration, password recovery, email verification and more, in short, it allows you to perform the same functionalities as Laravel Breeze that we used before, but without the graphical part.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.

Andrés Cruz

ES En español