En este tutorial vamos a construir un login sin contraseña usando Laravel en el servidor y Vue en el navegador, unidos mediante Inertia. El usuario escribirá su email, recibirá un código de seis cifras y lo introducirá para acceder. Si es la primera vez que lo usa, su cuenta se creará en ese momento.
Inertia permite crear una aplicación de una sola página con componentes de Vue sin tener que desarrollar una API: los controladores de Laravel siguen decidiendo qué página se muestra y con qué datos, y Vue se encarga de pintarla.
Introducción
La lógica del servidor es prácticamente la misma que vimos en el tutorial de cómo crear un login sin contraseña en Laravel, así que aquí nos centraremos en lo que cambia: cómo se configura Inertia con Vue y cómo se integra la verificación de Cloudflare Turnstile en un formulario de Vue. Para esto último usaremos el componente de Vue que incluye el paquete Laracaptcha desde su versión 1.2.
Contenidos
Para seguir el tutorial te vendrá bien conocer Vue. Si nunca lo has usado, consulta antes estas guías:
- Aprende Vue: Tutorial de introducción a Vue
- Practica con Vue: Cómo crear una aplicación REST con Vue
El código de la aplicación está disponible en GitHub, por si quieres consultarlo mientras avanzas. Esta es la pantalla de acceso que vamos a crear:

Instalación del proyecto
Necesitarás PHP 8.3 o superior, composer y Node.js con npm. Si te falta PHP, aquí tienes el tutorial de instalación de PHP en Windows.
Crea el proyecto
Desde la línea de comandos, crea un proyecto de Laravel y accede a su directorio:
composer create-project laravel/laravel login-vue
cd login-vue
Laravel ofrece kits de inicio que ya traen Inertia y Vue instalados, pero incluyen su propio sistema de login con contraseña. Como vamos a sustituirlo por completo, partiremos de un proyecto vacío y configuraremos Inertia a mano, lo cual además te servirá para entender qué hace cada pieza.
Instala las dependencias
En el servidor necesitamos el adaptador de Inertia para Laravel y Laracaptcha:
composer require inertiajs/inertia-laravel edulazaro/laracaptcha
En el navegador necesitamos Vue, el adaptador de Inertia para Vue y el plugin de Vue para Vite:
npm install vue @inertiajs/vue3 @vitejs/plugin-vue
El archivo .env lleva las mismas variables que en el tutorial de Blade: las claves de prueba de Turnstile y MAIL_MAILER=log para que los códigos se escriban en el log.
Configuración de Inertia
Inertia necesita cuatro cosas: un middleware en el servidor, una vista raíz, la configuración de Vite y el punto de entrada de la aplicación en JavaScript. Puedes consultar todos los detalles en la documentación de Inertia sobre la instalación en el servidor y la instalación en el cliente.
Crea el middleware
Genera el middleware de Inertia con este comando:
php artisan inertia:middleware
A continuación regístralo en el archivo bootstrap/app.php, dentro del grupo de rutas web:
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [HandleInertiaRequests::class]);
})
En el método share del middleware vamos a indicar los datos que recibirán todas las páginas: el usuario que ha iniciado sesión y los mensajes de estado que guardemos en la sesión:
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => ['user' => fn () => $request->user()?->only('name', 'email')],
'status' => fn () => $request->session()->get('status'),
];
}
Usamos funciones para que estos valores solo se calculen cuando una página los necesita. Puedes saber más en el apartado de datos compartidos de la documentación.
Crea la vista raíz
Inertia carga todas las páginas dentro de una única vista de Blade. Crea el archivo resources/views/app.blade.php:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/login.css">
@vite('resources/js/app.js')
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
La directiva @inertia crea el elemento en el que se montará Vue, mientras que @inertiaHead permite que cada página cambie el título del documento.
Configura Vite
Edita el archivo vite.config.js para agregar el plugin de Vue y un alias que apunte a los componentes de Laracaptcha:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({ input: ['resources/js/app.js'], refresh: true }),
vue(),
],
resolve: {
alias: {
'@laracaptcha': new URL('./vendor/edulazaro/laracaptcha/resources/js', import.meta.url).pathname,
},
},
});
Laracaptcha no se publica en npm: sus componentes van incluidos en el paquete de composer como código fuente, y es Vite quien los compila junto al resto de la aplicación. Gracias al alias podremos importarlos desde @laracaptcha/vue.
Crea la aplicación de Vue
Finalmente, reemplaza el contenido de resources/js/app.js por el siguiente:
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
const appName = import.meta.env.VITE_APP_NAME;
createInertiaApp({
title: (title) => (title ? `${title} · ${appName}` : appName),
resolve: (name) => {
const pages = import.meta.glob('./pages/**/*.vue', { eager: true });
return pages[`./pages/${name}.vue`];
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) }).use(plugin).mount(el);
},
});
La función resolve busca las páginas en el directorio resources/js/pages, que es el que Inertia 3 espera por defecto. Así, cuando el servidor renderice la página auth/Login, se cargará el componente resources/js/pages/auth/Login.vue.
El controlador
El controlador es el mismo que en el tutorial de Blade, con la clase EmailCode, el correo y los límites por dirección y por máquina, pero con dos cambios. El primero es que, en lugar de vistas de Blade, devuelve páginas de Inertia. La página del login recibe además lo que el componente de Laracaptcha necesita para dibujar el widget:
public function create(): Response
{
return Inertia::render('auth/Login', ['captcha' => Captcha::props()]);
}
El método Captcha::props() devuelve el proveedor configurado, la clave pública y la dirección del script. Si algún día cambias Turnstile por reCAPTCHA en el .env, el componente cambiará de proveedor sin tocar el código de Vue.
El segundo cambio es el nombre del campo. El formulario de Vue enviará el token en un campo llamado captcha, que validamos con la regla de Laracaptcha:
$data = $request->validate([
'email' => ['required', 'string', 'email', 'max:255'],
'captcha' => ['required', CaptchaRule::make()],
], [
'captcha.required' => 'Completa la verificación de seguridad.',
]);
Las rutas son las mismas que en la versión con Blade. La única diferencia es la del panel, que no necesita controlador: con Route::inertia basta con indicar la página que debe mostrar:
Route::inertia('/panel', 'Dashboard')->middleware('auth')->name('dashboard');
Las páginas de Vue
Ya podemos crear las tres páginas de la aplicación: el formulario del email, el del código y el panel.
Crea el formulario del email
Crea el archivo resources/js/pages/auth/Login.vue:
<script setup>
import { Head, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Captcha } from '@laracaptcha/vue';
defineProps({
captcha: { type: Object, required: true },
});
const form = useForm({ email: '', captcha: '' });
const captchaWidget = ref(null);
function submit() {
form.post('/login', {
onError: () => captchaWidget.value?.reset(),
});
}
</script>
<template>
<Head title="Entrar" />
<main class="card">
<h1>Entrar</h1>
<form @submit.prevent="submit">
<label for="email">Email</label>
<input id="email" v-model="form.email" type="email" required autofocus autocomplete="username">
<p v-if="form.errors.email" class="error">{{ form.errors.email }}</p>
<Captcha ref="captchaWidget" v-bind="captcha" v-model="form.captcha" language="es" />
<p v-if="form.errors.captcha" class="error">{{ form.errors.captcha }}</p>
<button type="submit" :disabled="form.processing">Enviarme el código</button>
</form>
</main>
</template>
Vamos a ver las partes más importantes de este componente:
useForm: Es el helper de formularios de Inertia. Guarda los datos, los envía conform.posty deja los errores de validación del servidor enform.errors, de modo que no tendrás que gestionar las peticiones a mano. Tienes más información en la documentación de formularios de Inertia.v-bind="captcha": Pasa al componente todos los datos que devolvióCaptcha::props().v-model="form.captcha": Guarda el token en el formulario en cuanto el usuario supera la verificación, y lo vacía si caduca.onError: Cloudflare solo acepta cada token una vez. Si el servidor rechaza el envío, por ejemplo porque se ha superado el límite de códigos, el token ya está gastado, así que llamamos al métodoresetdel componente para obtener otro.
Si usas reCAPTCHA v3, el componente no dibuja nada: obtiene el token automáticamente y lo renueva antes de que caduque.
Crea el formulario del código
El formulario del código usa dos formularios de Inertia: uno para enviar el código y otro para pedir uno nuevo:
<script setup>
import { Head, Link, useForm } from '@inertiajs/vue3';
defineProps({
email: { type: String, required: true },
});
const form = useForm({ code: '' });
const resend = useForm({});
function submit() {
form.post('/login/codigo');
}
function sendAnother() {
resend.post('/login/codigo/reenviar', {
onSuccess: () => form.reset('code'),
});
}
</script>
Al usar dos formularios, cada uno guarda sus propios errores: si se supera el límite de reenvíos, el mensaje aparecerá en resend.errors.code. En la plantilla mostramos el que corresponda, y el mensaje de estado compartido por el middleware está disponible en $page.props.status:
<p v-if="$page.props.status" class="notice" role="status">{{ $page.props.status }}</p>
<p v-if="form.errors.code || resend.errors.code" class="error">
{{ form.errors.code || resend.errors.code }}
</p>
Este es el aspecto del formulario:

Crea el panel
El panel obtiene el usuario de los datos compartidos con usePage, y cierra la sesión con el router de Inertia:
<script setup>
import { Head, router, usePage } from '@inertiajs/vue3';
const page = usePage();
</script>
<template>
<Head title="Panel" />
<main class="card">
<h1>Hola, {{ page.props.auth.user.name }}</h1>
<button type="button" @click="router.post('/logout')">Cerrar sesión</button>
</main>
</template>
Ahora ya puedes compilar los assets y arrancar el servidor:
npm run build
php artisan serve
Tests
Inertia incluye aserciones para comprobar desde los tests de Laravel qué página se renderiza y con qué datos, sin necesidad de un navegador. Por ejemplo, podemos comprobar que la página del login recibe los datos del captcha:
public function test_the_login_page_gets_what_the_captcha_component_needs(): void
{
$this->get('/login')->assertOk()->assertInertia(fn (Assert $page) => $page
->component('auth/Login')
->where('captcha.provider', 'fake')
->where('captcha.script', '')
->has('captcha.siteKey'));
}
Con Captcha::fake(), el proveedor pasa a ser fake y el script queda vacío, por lo que el componente de Vue no dibuja nada y los tests no dependen de Cloudflare. El repositorio incluye once tests que cubren todo el flujo, y puedes consultar más opciones en la documentación de testing de Inertia.
Finalizando
Ya tienes un login sin contraseña con Laravel, Inertia y Vue. Para llevarlo a producción, configura un SMTP real, tus propias claves de Turnstile y los registros SPF del dominio desde el que envías los correos, y ejecuta npm run build en cada despliegue.
Este tutorial forma parte de una serie en la que creamos el mismo login con distintas tecnologías:
- Blade: Cómo crear un login sin contraseña en Laravel
- Livewire: Cómo crear un login sin contraseña con Laravel y Livewire
- React: Cómo crear un login sin contraseña con Laravel, Inertia y React
- Svelte: Cómo crear un login sin contraseña con Laravel, Inertia y Svelte
Tienes el código completo en GitHub y todas las opciones del paquete en la documentación de Laracaptcha.
Esto ha sido todo. Espero que este tutorial te sirva de ayuda.