Laravel + Vue

O que muda em relação à trilha em Blade

Esse guia parte do princípio de que você já terminou a trilha "Aprendendo Laravel 13" e tem o Painel de Usuários funcionando 100% em Blade. Vamos reescrever a mesma aplicação, aula por aula, na mesma ordem — pra cada tópico, você vai ver claramente o que muda e o que continua idêntico.

Inertia.jsVue 3 / Composition APITailwindVite buildApachePWA / Offline

Fundamentos de Inertia e Vue

Aulas F1–F5 · conteúdo novo

O problema que o Inertia resolve

Existem, basicamente, dois jeitos de montar um sistema com um front-end reativo (que atualiza a tela sem recarregar a página inteira) usando Vue ou React:

  • SPA + API separada: você constrói uma API em Laravel (retornando JSON — Aula 15 e 23 da trilha anterior) e um projeto Vue totalmente à parte, que faz suas próprias requisições, cuida de rotas no navegador (Vue Router) e precisa reimplementar do zero coisas que o Laravel já resolve de graça: sessão, autenticação, validação com mensagens de erro por campo, CSRF.
  • Inertia: você mantém as rotas, os Controllers, a validação, a autenticação — tudo isso continua 100% Laravel. O Inertia só troca o que o Controller devolve: em vez de view('usuarios.index'), ele devolve Inertia::render('Usuarios/Index', [...]), que renderiza um componente Vue no lugar de um arquivo Blade.

Na prática: o navegador ainda navega por URLs normais (/usuarios, /usuarios/criar), o Laravel ainda decide quem pode acessar o quê — só que, por baixo dos panos, o Inertia troca a página sem recarregar o HTML inteiro (parecido com uma SPA), buscando só os dados novos via uma requisição que ele mesmo gerencia. Você não escreve essa requisição na mão — diferente do "Bônus AJAX" que vimos na trilha anterior, aqui isso é automático.

De onde vem cada peça

CamadaQuem cuida
Rotas, autenticação, validação, regras de negócioLaravel (idêntico ao que você já fez)
Ponte entre as duas pontasInertia.js (server-side + client-side)
Templates, componentes, reatividade da telaVue 3
Estilo visualTailwind

Vamos fazer isso peça por peça, no seu projeto já existente (o mesmo painel-usuarios da trilha anterior) — sem usar nenhum starter kit pronto, pra você entender exatamente o que cada comando faz.

Passo 1 — o lado do Laravel

terminal
composer require inertiajs/inertia-laravel
php artisan inertia:middleware

inertia:middleware gera o arquivo app/Http/Middleware/HandleInertiaRequests.php — uma peça central que vamos detalhar na Aula F5. Registre esse middleware no grupo web:

bootstrap/app.php
use App\Http\Middleware\HandleInertiaRequests;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        HandleInertiaRequests::class,
    ]);
})

Passo 2 — a view "raiz" que carrega o Vue

Diferente da trilha em Blade, agora só existe uma view Blade no projeto inteiro — ela não muda mais entre páginas, só serve de "casca" pro Vue assumir o controle:

resources/views/app.blade.php
<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="utf-8">
    <title>Painel de Usuários</title>
    @vite('resources/js/app.js')
    @inertiaHead
</head>
<body>
    @inertia
</body>
</html>

@vite(...) carrega seu JavaScript compilado (mais sobre isso na Aula D1). @inertia imprime uma <div id="app"> vazia, que é onde o Vue vai "montar" a aplicação. @inertiaHead permite que cada página Vue defina seu próprio <title>, se quiser.

Passo 3 — o lado do npm/Vue

terminal
npm install vue @inertiajs/vue3
npm install -D @vitejs/plugin-vue
vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        vue(),
    ],
});

laravel-vite-plugin já vem em qualquer projeto Laravel novo; adicionamos só o vue(), que ensina o Vite (a ferramenta que empacota seu JavaScript) a entender arquivos .vue.

Passo 4 — o ponto de entrada do JavaScript

resources/js/app.js
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';

createInertiaApp({
    resolve: (name) => {
        const paginas = import.meta.glob('./Pages/**/*.vue');
        return paginas[`./Pages/${name}.vue`]();
    },
    setup({ el, App, props, plugin }) {
        createApp({ render: () => h(App, props) })
            .use(plugin)
            .mount(el);
    },
});

import.meta.glob('./Pages/**/*.vue') é um recurso do Vite que mapeia todos os arquivos .vue dentro de resources/js/Pages/ — mas, sem nenhuma opção extra, cada entrada desse mapa não é o componente já carregado, é uma função que carrega ele sob demanda (por isso o () no final de paginas[...](): é essa chamada que efetivamente busca o arquivo). Na prática, isso significa que cada página vira seu próprio pedacinho de JavaScript, baixado só quando o usuário navega até ela — em vez de carregar o sistema inteiro de uma vez na primeira visita. Quando o Controller pedir Inertia::render('Usuarios/Index', ...), é esse mecanismo que sabe achar e carregar o componente em resources/js/Pages/Usuarios/Index.vue. h(App, props) é a função que o Vue usa internamente pra criar elementos — você não vai mexer nela diretamente, só sabe que é o "motor" por trás da montagem da aplicação.

Rodando em desenvolvimento

terminal (dois processos, cada um no seu terminal)
php artisan serve
npm run dev

npm run dev sobe o servidor de desenvolvimento do Vite, que recompila e atualiza a tela sozinho a cada vez que você salva um arquivo .vue — chamado de Hot Module Replacement, ou HMR. Isso só existe em desenvolvimento; vamos ver o processo de produção (o que interessa pro seu servidor Apache) na Aula D1.

Mão na massa

No seu projeto painel-usuarios já existente, execute os quatro passos acima na ordem. Ao final, rode php artisan serve e npm run dev juntos. Ainda não existe nenhuma página Vue nem rota apontando pra ela — então por enquanto só confirme que os dois comandos rodam sem erro.

Antes de misturar com Inertia, vale entender Vue "puro". Um arquivo .vue (chamado Single File Component, componente de arquivo único) tem até três blocos: <script setup> (a lógica, em JavaScript), <template> (o HTML) e <style> (CSS, opcional — como usamos Tailwind, quase não vamos precisar desse bloco).

Reatividade — a ideia central do Vue

Reatividade significa: quando um dado muda, tudo que depende dele na tela se atualiza sozinho, sem você escrever código dizendo "agora atualize aquele texto ali". Isso é obtido com a função ref(), que "embrulha" um valor comum numa caixinha observável pelo Vue.

exemplo.vue
<script setup>
import { ref } from 'vue';

const contador = ref(0);

function incrementar() {
    contador.value++;
}
</script>

<template>
    <p>Cliques: {{ contador }}</p>
    <button @click="incrementar">Clique aqui</button>
</template>

Dentro do <script>, você acessa/altera o valor com contador.value (o .value é necessário porque contador é, de fato, um objeto especial, não o número puro). Dentro do <template>, o Vue "desembrulha" automaticamente — você escreve só {{ contador }}, sem .value. @click="incrementar" é a forma do Vue de escutar um evento do navegador (o @ é atalho pra v-on:) — sempre que o botão for clicado, a função é chamada.

v-if / v-for — igual @if / @foreach do Blade

exemplo.vue
<template>
    <p v-if="usuarios.length === 0">Nenhum usuário cadastrado.</p>

    <ul>
        <li v-for="usuario in usuarios" :key="usuario.id">
            {{ usuario.name }}
        </li>
    </ul>
</template>

v-for="usuario in usuarios" repete o elemento pra cada item do array — igual o @foreach do Blade. :key="usuario.id" é obrigatório em todo v-for: é como o Vue identifica cada item individualmente, pra saber o que mudou entre uma atualização e outra, sem precisar redesenhar a lista inteira. O : antes de key é atalho pra v-bind:, que vamos ver a seguir.

v-bind — ligando um atributo HTML a uma variável

exemplo.vue
<img :src="usuario.foto_url" :alt="usuario.name">

Sem os dois-pontos, src="usuario.foto_url" tentaria carregar literalmente uma imagem chamada "usuario.foto_url" — com :src, o Vue entende que é uma expressão JavaScript a ser avaliada, não um texto fixo.

v-model — ligação de mão dupla, pra formulários

exemplo.vue
<script setup>
import { ref } from 'vue';
const nome = ref('');
</script>

<template>
    <input v-model="nome" type="text">
    <p>Você digitou: {{ nome }}</p>
</template>

v-model conecta o valor do input à variável nos dois sentidos: digitar no campo atualiza nome, e mudar nome pelo código atualiza o campo — sem você escrever nenhum evento manualmente.

Mão na massa (isolado, fora do projeto)

Crie um arquivo solto resources/js/Pages/Teste.vue com um contador de cliques (como no primeiro exemplo) e uma lista de 3 nomes fixos usando v-for. Não precisa rota nem Controller ainda — é só pra praticar a sintaxe. Se quiser visualizar, a Aula F4 já te dá o gancho pra isso.

Componente — o equivalente ao componente Blade da Aula 3

Assim como <x-alerta> era um componente Blade reutilizável, um arquivo .vue inteiro é, por natureza, um componente — reutilizável em qualquer outra página, importando ele.

resources/js/Components/Badge.vue
<script setup>
defineProps({
    texto: String,
});
</script>

<template>
    <span class="inline-block bg-indigo-100 text-indigo-700 text-xs font-medium px-2.5 py-1 rounded-full">
        {{ texto }}
    </span>
</template>

defineProps({...}) declara quais dados esse componente espera receber de fora — é o equivalente Vue do $slot/parâmetros que um componente Blade recebia. Aqui, texto é uma prop do tipo String.

Usando o componente em outra página

exemplo
<script setup>
import Badge from '@/Components/Badge.vue';
</script>

<template>
    <Badge texto="gerente" />
</template>

@/ é um atalho configurado por padrão (pelo laravel-vite-plugin) que aponta pra resources/js/ — evita caminhos longos tipo ../../Components/Badge.vue.

computed — um valor derivado, que se recalcula sozinho

Lembra do accessor idade() que você criou no model User (Aula 7 da trilha anterior)? O equivalente no Vue, pra um valor calculado a partir de outro dado reativo, é o computed:

exemplo.vue
<script setup>
import { ref, computed } from 'vue';

const senha = ref('');

const senhaForte = computed(() => senha.value.length >= 8);
</script>

<template>
    <input v-model="senha" type="password">
    <p v-if="!senhaForte">A senha precisa ter pelo menos 8 caracteres.</p>
</template>

senhaForte se recalcula automaticamente toda vez que senha muda — diferente de uma função comum, que você teria que chamar manualmente toda hora.

Mão na massa (isolado, fora do projeto)

Crie o componente Badge.vue exatamente como no exemplo. No arquivo Teste.vue que você criou na aula passada, importe e use esse componente duas vezes, com textos diferentes.

O problema: toda página precisa saber quem está logado

Na trilha em Blade, você usava auth()->user() ou @can(...) direto em qualquer view, porque o Blade roda no servidor e tem acesso a tudo. No Vue, o componente roda no navegador — ele só sabe o que o Controller mandou explicitamente como prop. Ficaria terrível ter que lembrar de passar 'usuarioLogado' => Auth::user() em todo Inertia::render() do sistema. O middleware HandleInertiaRequests (gerado na Aula F2) resolve isso: ele compartilha dados automaticamente em toda requisição Inertia, sem você repetir em cada Controller.

app/Http/Middleware/HandleInertiaRequests.php
use Illuminate\Http\Request;

public function share(Request $request): array
{
    return [
        ...parent::share($request),

        'auth' => [
            'user' => $request->user(),
        ],

        'can' => [
            'cadastrarUsuarios' => $request->user()
                ? $request->user()->can('cadastrar-usuarios')
                : false,
        ],

        'flash' => [
            'sucesso' => fn () => $request->session()->get('sucesso'),
        ],
    ];
}

Repare que reaproveitamos o mesmo Gate 'cadastrar-usuarios' que você já criou na Aula 9 da trilha anterior — não escrevemos a regra de novo, só perguntamos o resultado dela aqui e entregamos pronto pro Vue. 'flash' => ['sucesso' => fn () => ...] usa uma closure (em vez do valor direto) porque o Inertia só avalia isso se a página realmente pedir esse dado — evita processar à toa.

Lendo esses dados em qualquer componente Vue

qualquer página .vue
<script setup>
import { usePage } from '@inertiajs/vue3';

const page = usePage();
</script>

<template>
    <p>Logado como: {{ page.props.auth.user.name }}</p>

    <a v-if="page.props.can.cadastrarUsuarios" href="/usuarios/criar">
        Cadastrar novo usuário
    </a>
</template>

usePage() dá acesso ao "estado global" da página Inertia atual — page.props contém tudo que o share() devolveu, mais qualquer prop específica que o Controller daquela rota tenha mandado. O v-if="page.props.can.cadastrarUsuarios" é exatamente o equivalente Vue do @can('cadastrar-usuarios') que você usava no Blade.

O Layout — o equivalente ao layouts/app.blade.php

resources/js/Layouts/AppLayout.vue
<script setup>
import { usePage } from '@inertiajs/vue3';
const page = usePage();
</script>

<template>
    <div class="min-h-screen bg-gray-50">
        <nav class="bg-gray-900 text-white px-6 py-4">
            <span class="font-bold text-lg">Painel de Usuários</span>
        </nav>

        <main class="max-w-4xl mx-auto mt-6 px-4">
            <div v-if="page.props.flash.sucesso" class="bg-green-100 text-green-800 px-4 py-3 rounded mb-4">
                {{ page.props.flash.sucesso }}
            </div>

            <slot />
        </main>
    </div>
</template>

<slot /> é o equivalente Vue do @yield('conteudo') — o espaço onde o conteúdo de cada página específica entra.

Usando o layout numa página — diferente do @extends do Blade, no Vue você importa o layout e diz explicitamente que aquela página o usa:

resources/js/Pages/Usuarios/Index.vue (esqueleto)
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
</script>

<template>
    <h1 class="text-2xl font-bold text-gray-800">Usuários cadastrados</h1>
</template>

defineOptions({ layout: AppLayout }) é a forma moderna do Vue 3 de configurar opções do componente quando se usa <script setup> — aqui, dizendo "essa página deve ser renderizada dentro do AppLayout".

Mão na massa

Crie o HandleInertiaRequests compartilhando auth, can e flash exatamente como no exemplo. Crie o AppLayout.vue. Crie a página Usuarios/Index.vue (mesmo que ainda vazia, só com o <h1>) usando o layout. Ainda não existe rota apontando pra ela — isso vem já na próxima aula.

Básico

Aulas 1–5 · equivalentes à trilha Blade
O que não mudaSuas rotas em routes/web.php continuam exatamente iguais — mesma sintaxe, mesmos verbos HTTP, mesmo Route::middleware('auth')->group(...) da Aula 8. O roteamento é 100% responsabilidade do Laravel; o Inertia não interfere nisso.

O que muda: o retorno do Controller

Na trilha em Blade, index() devolvia view('usuarios.index', compact('usuarios')). Agora, ele devolve Inertia::render(...):

app/Http/Controllers/UsuarioController.php
namespace App\Http\Controllers;

use App\Models\User;
use Inertia\Inertia;

class UsuarioController extends Controller
{
    public function index()
    {
        return Inertia::render('Usuarios/Index', [
            'usuarios' => User::all(),
        ]);
    }

    public function create()
    {
        return Inertia::render('Usuarios/Criar');
    }
}

Inertia::render('Usuarios/Index', [...]) tem dois parâmetros: o primeiro é o nome do componente Vue a renderizar — precisa bater exatamente com o caminho dentro de resources/js/Pages/ (nesse caso, Usuarios/Index.vue). O segundo é um array associativo de props — o equivalente direto do que compact('usuarios') fazia pra uma view Blade, só que agora esses dados viram JSON automaticamente e chegam no componente Vue como propriedades reativas.

Convenção de nomeAssim como view('usuarios.index') usava ponto pra indicar subpasta, Inertia::render('Usuarios/Index', ...) usa barra — porque, por baixo dos panos, isso vira um caminho de arquivo JavaScript (./Pages/Usuarios/Index.vue), não uma dot notation do Blade.

Mão na massa

Atualize o método index() do UsuarioController pra devolver Inertia::render('Usuarios/Index', ['usuarios' => User::all()]). Como a página Usuarios/Index.vue já existe (você criou o esqueleto na Aula F5), acesse /usuarios no navegador — deve continuar mostrando só o título por enquanto, mas sem nenhum erro. Se der erro de "componente não encontrado", confira se o caminho do arquivo bate exatamente com o nome usado no Inertia::render.

O que não mudaTudo que você aprendeu sobre Controllers na trilha anterior continua valendo: a classe, os métodos index/create/store, o Request $request injetado automaticamente, o Route Model Binding (Produto $produto resolvido sozinho pelo Laravel). Controller Resource (Route::resource) também funciona idêntico. A única mudança sistemática, em todo o projeto, é trocar view(...) por Inertia::render(...) nos métodos que devolvem uma tela.

Métodos que não devolvem tela nenhuma — como store(), que só salva e redireciona — continuam usando redirect(...) normalmente, sem Inertia nenhum envolvido. Vamos ver isso com detalhe na Aula 5.

Recebendo a prop e listando com v-for

Na Aula F5 você criou o esqueleto de Usuarios/Index.vue. Agora vamos recebê-lo com dados de verdade — o equivalente Vue do @foreach que você usava no Blade:

resources/js/Pages/Usuarios/Index.vue
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link } from '@inertiajs/vue3';

defineOptions({ layout: AppLayout });

defineProps({
    usuarios: Array,
});
</script>

<template>
    <div class="flex items-center justify-between mb-4">
        <h1 class="text-2xl font-bold text-gray-800">Usuários cadastrados</h1>
        <Link href="/usuarios/criar" class="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700">
            Cadastrar novo usuário
        </Link>
    </div>

    <ul>
        <li v-for="usuario in usuarios" :key="usuario.id">
            {{ usuario.name }} — nascido em {{ usuario.data_nascimento }}
        </li>
    </ul>
</template>

defineProps({ usuarios: Array }) declara que essa página espera receber uma prop chamada usuarios, do tipo Array — o nome precisa bater exatamente com a chave usada no Inertia::render() do Controller (Aula 1). Repare que o User::all() do PHP, uma Collection do Eloquent, chega no Vue já como um array de objetos JavaScript comuns — o Laravel serializa isso pra JSON automaticamente, você não faz nada manual pra isso acontecer.

<Link> em vez de <a href>

Repare que usamos o componente <Link> do Inertia, importado de @inertiajs/vue3, em vez de uma tag <a> comum. Visualmente funciona igual (é renderizado como um <a> por baixo), mas o <Link> intercepta o clique e deixa o Inertia trocar de página sem recarregar o navegador inteiro — é essa interceptação que dá a sensação de "SPA" (aplicação de página única) ao sistema. Se você usasse <a href="/usuarios/criar"> normal, o navegador recarregaria a página do zero — ainda funcionaria, só perderia essa vantagem.

Mão na massa

Complete Usuarios/Index.vue exatamente como no exemplo. Acesse /usuarios logado e confirme que a lista de usuários (criados na trilha anterior, se você está usando o mesmo banco) aparece. Clique no botão "Cadastrar novo usuário" — vai dar erro de componente não encontrado, porque Usuarios/Criar.vue ainda não existe. Resolvemos isso na Aula 5.

O que não muda — nadaEloquent não sabe (nem precisa saber) que existe um front-end em Vue do outro lado. O model User, a migration que adicionou papel/data_nascimento/criador_id, o $fillable — copie tudo direto da trilha anterior, sem alterar uma linha. A única diferença é quem consome esses dados: antes era uma view Blade, agora é um componente Vue recebendo os mesmos dados como prop JSON.

Mão na massa

Se você está usando um banco novo pra esse projeto, rode as mesmas migrations da trilha anterior. Se está reaproveitando o mesmo banco, não precisa fazer nada — já está tudo pronto.

O que não muda no backendO método store() continua com a mesma validação ($request->validate([...])), as mesmas regras, o mesmo User::create(...), o mesmo redirect('/usuarios') no final. Se você aplicou o Gate (Aula 9), a Action (Aula 18) ou o Event (Aula 13) da trilha anterior, tudo isso continua igual — vamos revisitar cada um na hora certa. O que muda de verdade é só como o formulário no navegador conversa com esse Controller.

useForm — o "cérebro" reativo de um formulário Inertia

useForm é uma função do Inertia que cria um objeto reativo pra guardar os campos do formulário, enviar a requisição, e receber de volta os erros de validação — tudo isso sem você escrever nenhuma chamada AJAX manual, e sem precisar de token CSRF configurado à mão (o Inertia já lida com a sessão/cookie do Laravel sozinho).

resources/js/Pages/Usuarios/Criar.vue
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { useForm } from '@inertiajs/vue3';

defineOptions({ layout: AppLayout });

const form = useForm({
    name: '',
    email: '',
    password: '',
    password_confirmation: '',
    data_nascimento: '',
    papel: 'comum',
});

function enviar() {
    form.post('/usuarios');
}
</script>

<template>
    <h1 class="text-2xl font-bold text-gray-800 mb-4">Cadastrar novo usuário</h1>

    <form @submit.prevent="enviar" class="space-y-4">
        <div>
            <label class="block text-sm font-medium text-gray-700">Nome</label>
            <input v-model="form.name" type="text" class="border border-gray-300 rounded px-3 py-2 w-full">
            <p v-if="form.errors.name" class="text-red-600 text-sm mt-1">{{ form.errors.name }}</p>
        </div>

        <div>
            <label class="block text-sm font-medium text-gray-700">E-mail</label>
            <input v-model="form.email" type="email" class="border border-gray-300 rounded px-3 py-2 w-full">
            <p v-if="form.errors.email" class="text-red-600 text-sm mt-1">{{ form.errors.email }}</p>
        </div>

        <div>
            <label class="block text-sm font-medium text-gray-700">Senha</label>
            <input v-model="form.password" type="password" class="border border-gray-300 rounded px-3 py-2 w-full">
            <p v-if="form.errors.password" class="text-red-600 text-sm mt-1">{{ form.errors.password }}</p>
        </div>

        <div>
            <label class="block text-sm font-medium text-gray-700">Confirmar senha</label>
            <input v-model="form.password_confirmation" type="password" class="border border-gray-300 rounded px-3 py-2 w-full">
        </div>

        <div>
            <label class="block text-sm font-medium text-gray-700">Data de nascimento</label>
            <input v-model="form.data_nascimento" type="date" class="border border-gray-300 rounded px-3 py-2 w-full">
            <p v-if="form.errors.data_nascimento" class="text-red-600 text-sm mt-1">{{ form.errors.data_nascimento }}</p>
        </div>

        <div>
            <label class="block text-sm font-medium text-gray-700">Papel</label>
            <select v-model="form.papel" class="border border-gray-300 rounded px-3 py-2 w-full">
                <option value="comum">Comum</option>
                <option value="gerente">Gerente</option>
                <option value="admin">Admin</option>
            </select>
        </div>

        <button
            type="submit"
            :disabled="form.processing"
            class="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 disabled:opacity-50"
        >
            Cadastrar
        </button>
    </form>
</template>

Desmontando o que está acontecendo

  • useForm({ name: '', email: '', ... }) cria o objeto form, com cada campo já inicializado — o equivalente reativo dos <input name="..."> soltos que você tinha no Blade.
  • password_confirmation existe porque a regra confirmed do lado do Laravel (herdada do store() da trilha em Blade, Aula 5) exige um campo com esse nome exato, contendo o mesmo valor da senha — sem ele aqui, qualquer cadastro falharia na validação assim que tivesse senha preenchida.
  • v-model="form.name" liga o campo direto à propriedade do objeto — digitar no input já atualiza form.name automaticamente.
  • @submit.prevent="enviar" escuta o envio do formulário e chama a função enviar; o .prevent é um modificador do Vue que já cancela o recarregamento padrão da página (o mesmo papel do e.preventDefault() que você viu no "bônus AJAX" da trilha anterior, só que embutido na sintaxe).
  • form.post('/usuarios') envia a requisição pro Controller — se a validação do Laravel falhar, o Inertia automaticamente preenche form.errors com as mensagens por campo, sem você escrever nenhum tratamento de erro manual.
  • form.errors.name é exatamente o equivalente do @error('name') do Blade.
  • form.processing é um booleano que o Inertia already gerencia sozinho: fica true enquanto a requisição está em andamento. Usamos ele em :disabled="form.processing" pra travar o botão e evitar duplo clique — um cuidado de UX que, no Blade, você teria que implementar manualmente com JavaScript.
E o old()?Repare que não existe nenhum equivalente pro old('nome') do Blade — e não precisa! Como o formulário inteiro é reativo e vive na memória do navegador (não recarrega a página), os valores digitados simplesmente continuam lá depois de um erro de validação. O problema que o old() resolvia no Blade (a página inteira recarregava, perdendo tudo) nem existe mais nesse modelo.

Mão na massa

Crie Usuarios/Criar.vue exatamente como no exemplo. Teste três cenários, como você já fez na trilha anterior: (1) enviar vazio — os erros devem aparecer sem a página recarregar; (2) e-mail duplicado — mesma coisa; (3) preencher tudo certo — deve redirecionar pra /usuarios e o novo usuário aparecer na lista.

Intermediário

Aulas 6–11 · equivalentes à trilha Blade
O que não mudaOs métodos criador() e usuariosCriados() no model User são copiados sem alterar nada. Relacionamento é uma preocupação de banco de dados, e o Vue não tem nenhuma opinião sobre isso.

O que muda: como isso chega na tela

Quando você inclui um relacionamento carregado (via with('criador'), lembra da Aula 6 original sobre eager loading?) dentro de uma prop do Inertia, ele chega no Vue como um objeto aninhado comum:

Controller
'usuarios' => User::with('criador')->get(),
template Vue
<li v-for="usuario in usuarios" :key="usuario.id">
    {{ usuario.name }}
    <span v-if="usuario.criador">(cadastrado por {{ usuario.criador.name }})</span>
</li>

usuario.criador no Vue é exatamente o mesmo dado que $usuario->criador era no Blade — só que agora é um objeto JavaScript, acessado com ponto igual em PHP.

Mão na massa

Copie os relacionamentos pro model. Atualize a query do index() pra usar with('criador'), e ajuste Usuarios/Index.vue pra mostrar quem cadastrou cada usuário, como no exemplo.

O que não mudaOs scopes (scopeGerentes, scopeAdmins, scopePodeCadastrar) e o accessor idade() continuam exatamente como você escreveu na trilha anterior — a lógica de cálculo mora no PHP, não no Vue.

Uma diferença de atenção: accessors do Eloquent só entram automaticamente no JSON se estiverem no array $appends do Model, ou se você tocar neles explicitamente ao montar o array de retorno. Sem isso, o Inertia serializa só os atributos "de banco" (as colunas), não os calculados.

app/Models/User.php
protected $appends = ['idade'];

A partir daí, idade vem junto em qualquer User::all() ou User::find() que você mandar como prop, sem precisar montar o array manualmente.

template Vue
<li v-for="usuario in usuarios" :key="usuario.id">
    {{ usuario.name }} — {{ usuario.idade }} anos
</li>

Mão na massa

Copie os scopes pro model. Adicione protected $appends = ['idade'];. Mostre a idade na lista de usuários em Vue.

O que não mudaRoute::middleware('auth')->group(...) continua barrando quem não está logado antes mesmo de chegar no Controller — igualzinho à trilha anterior. O único middleware novo no seu projeto é o HandleInertiaRequests, que você já registrou na Aula F2.

Vale só reforçar uma diferença de comportamento: quando um usuário não autenticado tenta acessar uma rota protegida numa requisição Inertia (ex: clicando num <Link>), o Laravel ainda redireciona pro login — mas o Inertia detecta que é um redirecionamento de sessão expirada e força um reload completo da página (em vez de tentar trocar só o componente Vue), garantindo que o navegador realmente carregue a tela de login do zero. Isso já vem pronto, você não precisa configurar nada.

O que não mudaAuth::attempt(), Auth::logout(), o AuthController inteiro, e o Gate::define('cadastrar-usuarios', ...) no AppServiceProvider — tudo copiado sem alterar nada. O que muda é a tela de login (agora em Vue) e como o resultado do Gate chega no front-end (já resolvemos isso na Aula F5, com o 'can' compartilhado).

Ajustando o AuthController pra Inertia

app/Http/Controllers/AuthController.php
use Inertia\Inertia;

public function mostrarFormulario()
{
    return Inertia::render('Auth/Login');
}

public function login(Request $request)
{
    $credenciais = $request->validate([
        'email' => 'required|email',
        'password' => 'required',
    ]);

    if (Auth::attempt($credenciais)) {
        $request->session()->regenerate();
        return redirect('/usuarios');
    }

    return back()->withErrors(['email' => 'Credenciais inválidas.']);
}

Repare que login() nem precisa mudar — ele continua devolvendo redirect()/back()->withErrors(), e o Inertia entende esses dois automaticamente, convertendo em navegação de página ou em form.errors, sem você escrever nada especial.

A tela de login em Vue

resources/js/Pages/Auth/Login.vue
<script setup>
import { useForm } from '@inertiajs/vue3';

const form = useForm({
    email: '',
    password: '',
});

function enviar() {
    form.post('/login');
}
</script>

<template>
    <div class="max-w-sm mx-auto mt-20">
        <form @submit.prevent="enviar" class="space-y-4">
            <input v-model="form.email" type="email" placeholder="E-mail" class="border rounded px-3 py-2 w-full">
            <p v-if="form.errors.email" class="text-red-600 text-sm">{{ form.errors.email }}</p>

            <input v-model="form.password" type="password" placeholder="Senha" class="border rounded px-3 py-2 w-full">

            <button :disabled="form.processing" class="bg-indigo-600 text-white px-4 py-2 rounded w-full">
                Entrar
            </button>
        </form>
    </div>
</template>

Essa página não usa o AppLayout (não faz sentido mostrar a navbar/menu antes da pessoa estar logada) — por isso não tem defineOptions({ layout: ... }).

Usando o Gate compartilhado (já resolvido na Aula F5)

qualquer página, revisão
<Link v-if="$page.props.can.cadastrarUsuarios" href="/usuarios/criar">
    Cadastrar novo usuário
</Link>

$page dentro do <template> é um atalho global que o Inertia disponibiliza — equivalente a chamar usePage() no <script>, só que sem precisar importar nada, direto no HTML.

Mão na massa

Ajuste o AuthController e crie Auth/Login.vue. Copie o Gate pro AppServiceProvider. No Index.vue, troque o botão de cadastro fixo pela versão com v-if="$page.props.can.cadastrarUsuarios". Logue com um usuário comum e confirme que o botão some; logue como admin e confirme que ele aparece.

O que não mudaapp/Services/EvolutionApiService.php é copiado sem alterar nada. O Service Container é uma peça do Laravel, que roda inteiramente no servidor — o Vue nunca "vê" essa classe, nem precisa saber que ela existe.
O que não mudaO binding do EvolutionApiService no register() é copiado sem alterar nada — mesmo singleton, mesma leitura de config('services.evolution_api...').

A única peça nova de "provider" no projeto inteiro é o middleware HandleInertiaRequests (Aula F2/F5) — que, tecnicamente, nem é um Service Provider, é um Middleware; ele só aparece aqui porque cumpre um papel parecido: preparar coisas globais antes da aplicação de fato "rodar" a lógica da rota.

Avançado

Aulas 12–15, 17–18 · equivalentes à trilha Blade
O que não muda — nadaVerificarAniversariantesJob e o agendamento em routes/console.php são copiados sem alterar nada. Jobs rodam em segundo plano, sem navegador nenhum aberto — o Vue simplesmente não faz parte dessa conversa.

Mão na massa

Copie o Job e o agendamento pro projeto novo. Rode manualmente pelo Tinker, como você já fez antes, e confirme que continua funcionando.

O que não mudaO Event UsuarioCadastrado e os três Listeners (NotificarAdminListener, EnviarBoasVindasEmailListener, EnviarBoasVindasWhatsappListener) são copiados sem alterar uma linha — o criador_id, o dispatch() no store(), o return; condicional checando se quem criou é gerente. Tudo isso roda no servidor, de forma assíncrona, sem o navegador nem saber que aconteceu.

O que é novo: avisando o usuário que deu certo

Na trilha em Blade, você não tinha uma forma explícita de dizer "cadastro feito com sucesso!" — o redirect('/usuarios') já bastava, porque a página recarregava e o novo usuário simplesmente aparecia na lista. Aqui também funciona assim, mas dá pra melhorar a experiência com uma mensagem de confirmação, usando o flash que você já preparou na Aula F5:

app/Http/Controllers/UsuarioController.php
public function store(Request $request)
{
    Gate::authorize('cadastrar-usuarios');

    $validado = $request->validate([ /* ... */ ]);
    $validado['password'] = bcrypt($validado['password']);
    $validado['criador_id'] = Auth::id();

    $novoUsuario = User::create($validado);

    UsuarioCadastrado::dispatch($novoUsuario, Auth::user());

    return redirect('/usuarios')->with('sucesso', "Usuário {$novoUsuario->name} cadastrado!");
}

->with('sucesso', '...') guarda essa mensagem na sessão por uma única requisição (é o mecanismo nativo de "flash data" do Laravel, que já existia antes do Inertia). O HandleInertiaRequests que você configurou na Aula F5 já está lendo essa chave e repassando pra qualquer página Vue via page.props.flash.sucesso — e o AppLayout.vue já está mostrando ela. Nenhum código novo é necessário nessa aula, só essa mudança de uma linha no Controller.

Mão na massa

Copie o Event e os três Listeners. Adicione o ->with('sucesso', ...) no store(). Cadastre um usuário e confirme que a faixa verde de sucesso aparece no topo da página de lista, além dos e-mails/WhatsApp chegando (ou dos logs, se você ainda não conectou o evolution-api de verdade).

O que não mudaCache::remember(...) e Cache::forget(...) continuam exatamente iguais — cache é uma otimização do lado do servidor, invisível pro Vue. A única diferença é que, em vez de passar $contagemPorPapel pra uma view Blade via compact(), você passa como mais uma chave no array de props do Inertia::render().
Controller
return Inertia::render('Usuarios/Index', [
    'usuarios' => User::with('criador')->get(),
    'contagemPorPapel' => Cache::remember('contagem_usuarios_por_papel', now()->addMinutes(10), function () {
        return [
            'admin' => User::admins()->count(),
            'gerente' => User::gerentes()->count(),
            'comum' => User::where('papel', 'comum')->count(),
        ];
    }),
]);
template Vue
<p class="text-sm text-gray-500 mb-4">
    Admins: {{ contagemPorPapel.admin }} · Gerentes: {{ contagemPorPapel.gerente }}
</p>

Mão na massa

Adicione a prop contagemPorPapel no Controller e mostre no topo do Index.vue. Cadastre um usuário e confirme que o Cache::forget (já no Listener, da trilha anterior) mantém o número atualizado.

O UsuarioResource que você criou continua funcionando exatamente igual — a única diferença é que, com Inertia, você o usa dentro do array de props em vez de retorná-lo como resposta de uma rota de API separada:

Controller
use App\Http\Resources\UsuarioResource;

public function index(Request $request)
{
    return Inertia::render('Usuarios/Index', [
        'usuarios' => UsuarioResource::collection(User::with('criador')->get()),
    ]);
}
RepareO $this->when($request->user()->can('cadastrar-usuarios'), ...) dentro do Resource já resolve, no servidor, a mesma coisa que fazíamos manualmente com v-if="$page.props.can.cadastrarUsuarios" no Vue — só que aplicado por campo, antes mesmo do dado sair do Laravel. Pra esse projeto, como só temos duas telas simples, usar o Resource é opcional (as duas abordagens resolvem o mesmo problema); ele vale mais a pena quando o mesmo Model é exposto em vários lugares diferentes e você não quer repetir a lógica de "o que cada papel pode ver" em cada um.

Mão na massa

Copie o UsuarioResource. Troque 'usuarios' => User::all() por 'usuarios' => UsuarioResource::collection(...) no index(). Confirme que a tela continua funcionando igual pra admin e pra usuário comum.

O que não muda — nadaPromoverUsuarioCommand é copiado sem alterar nada. Comandos Artisan rodam fora do navegador inteiramente — php artisan usuarios:promover continua funcionando exatamente igual.
O que não mudaCadastrarUsuarioAction é copiada sem alterar nada — ela já não sabia (nem devia saber) se quem a chama é um Controller Blade ou um Controller Inertia. É exatamente esse desacoplamento que a Action te deu de graça desde a trilha anterior.
app/Http/Controllers/UsuarioController.php
public function store(Request $request, CadastrarUsuarioAction $action)
{
    Gate::authorize('cadastrar-usuarios');

    $validado = $request->validate([ /* ... */ ]);

    $novoUsuario = $action->executar($validado, Auth::user());

    return redirect('/usuarios')->with('sucesso', "Usuário {$novoUsuario->name} cadastrado!");
}

Ferramentas do dia a dia

Aulas 19–27 · equivalentes à trilha Blade
O que não muda — nadaAdminSeeder é copiado sem alterar nada. Seeder popula banco de dados; não tem relação nenhuma com a camada de tela.

Mão na massa

Copie o Seeder e rode php artisan migrate:fresh --seed no projeto novo. Faça login com admin@painel.com.

O que não muda — nadaconfig/services.php, config/usuarios.php e as variáveis no .env são copiados sem alterar nada. Config é lido inteiramente no servidor, antes de qualquer prop ser montada.
O que não muda — nadaBoasVindasMail, NovoUsuarioCriadoMail, FelizAniversarioMail e suas views Blade (sim, views de e-mail continuam sendo Blade mesmo num projeto Inertia — o Inertia só troca a camada de tela do navegador, e-mail é outra coisa, renderizado no servidor e enviado por SMTP) são copiados sem alterar nada.

Mão na massa

Copie as três Mailables e suas views. Cadastre um usuário como gerente e confira os e-mails em storage/logs/laravel.log, como antes.

O que não mudaA classe NovoUsuarioNotification, a migration do canal database, e o $admin->notify(...) dentro do Listener são copiados sem alterar nada.

Compartilhando a contagem de notificações via HandleInertiaRequests

Assim como fizemos com auth e can, a contagem de notificações não lidas precisa estar disponível em toda página, já que o sino fica sempre visível no layout — então ela entra no share():

app/Http/Middleware/HandleInertiaRequests.php
'notificacoes' => fn () => $request->user()
    ? $request->user()->unreadNotifications->map(fn ($n) => $n->data['mensagem'])
    : [],

O sino, como componente Vue

resources/js/Components/SinoNotificacoes.vue
<script setup>
import { usePage } from '@inertiajs/vue3';

const page = usePage();
</script>

<template>
    <div class="relative">
        🔔
        <span
            v-if="page.props.notificacoes.length > 0"
            class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center"
        >
            {{ page.props.notificacoes.length }}
        </span>
    </div>
</template>

Usando o componente dentro do AppLayout.vue (Aula F5), na navbar:

resources/js/Layouts/AppLayout.vue (trecho)
<script setup>
import SinoNotificacoes from '@/Components/SinoNotificacoes.vue';
</script>

<template>
    <nav class="bg-gray-900 text-white px-6 py-4 flex justify-between items-center">
        <span class="font-bold text-lg">Painel de Usuários</span>
        <SinoNotificacoes />
    </nav>
    ...
</template>

Como page.props.notificacoes vem do share(), esse componente já funciona em qualquer página do sistema, sem precisar repetir a prop em cada Inertia::render() individual.

Mão na massa

Adicione notificacoes no share(). Crie SinoNotificacoes.vue e adicione no layout. Cadastre um usuário como gerente e, logado como admin, confirme que o número no sino aumenta (numa nova navegação — sem tempo real ainda, isso vem na Aula 26).

O que não muda — nadaO método enviarMensagem() do EvolutionApiService, com Http::withHeaders(...)->post(...), é copiado sem alterar nada. Essa chamada acontece dentro do EnviarBoasVindasWhatsappListener, que roda em fila, no servidor — o navegador do usuário nunca fala diretamente com a evolution-api.
O que não mudaA migration da coluna foto, o $foto->store('fotos-usuarios', 'public') dentro da Action, e o Storage::url(...) pra exibir são copiados sem alterar nada.

O que muda: como o arquivo sai do navegador

No Blade, bastava enctype="multipart/form-data" no <form>. Com useForm, o Inertia detecta sozinho quando algum campo é um arquivo (um objeto File do navegador) e já monta a requisição no formato certo — você só precisa capturar o arquivo escolhido:

resources/js/Pages/Usuarios/Criar.vue (trecho adicionado)
<script setup>
const form = useForm({
    // ...campos existentes...
    foto: null,
});

function selecionarFoto(evento) {
    form.foto = evento.target.files[0];
}
</script>

<template>
    <div>
        <label class="block text-sm font-medium text-gray-700">Foto de perfil</label>
        <input type="file" @input="selecionarFoto" class="w-full">
    </div>
</template>

evento.target.files[0] pega o primeiro arquivo escolhido pelo usuário no seletor nativo do navegador. Repare que usamos @input (não v-model) — inputs de arquivo não suportam v-model por uma limitação de segurança do próprio navegador (nenhum JavaScript pode "definir" o valor de um campo de arquivo diretamente, só ler o que o usuário escolheu).

Mão na massa

Adicione a coluna foto, o campo de upload no formulário Vue, e ajuste a Action pra receber e salvar o arquivo (o Controller passa $request->file('foto') igual antes). Mostre a foto na lista de usuários quando existir.

O que não muda — nadaA consulta DB::table('users')->selectRaw(...)->groupBy(...) é copiada sem alterar nada — só troca de compact('porMes') pra virar mais uma chave dentro do array de props do Inertia::render().
O que não mudaO Event implements ShouldBroadcast, o broadcastOn(), o routes/channels.php — tudo copiado sem alterar nada. Reverb continua sendo o mesmo servidor WebSocket, rodando do mesmo jeito.

Escutando o canal de dentro de um componente Vue

Na trilha em Blade, o Echo.private(...).listen(...) ficava solto num arquivo app.js, escutando pra sempre enquanto a página estivesse aberta. Num componente Vue, o jeito correto é usar os lifecycle hooks (funções que rodam em momentos específicos da vida do componente): começar a escutar quando o componente é montado na tela, e parar de escutar quando ele é desmontado — importante porque, como o Inertia troca de página sem recarregar o navegador, um listener "esquecido" continuaria rodando mesmo depois do usuário sair da tela, se você não limpar ele.

resources/js/Components/SinoNotificacoes.vue (versão em tempo real)
<script setup>
import { usePage } from '@inertiajs/vue3';
import { onMounted, onUnmounted, ref } from 'vue';

const page = usePage();
const notificacoesAoVivo = ref([]);

onMounted(() => {
    if (page.props.auth.user?.papel !== 'admin') return;

    Echo.private('admins').listen('UsuarioCadastrado', (evento) => {
        notificacoesAoVivo.value.push(evento.mensagem);
    });
});

onUnmounted(() => {
    Echo.leave('admins');
});
</script>

onMounted(() => {...}) roda assim que o componente aparece na tela — é onde começamos a "ouvir" o canal. onUnmounted(() => {...}) roda quando o componente sai da tela (o usuário navegou pra outro lugar) — chamamos Echo.leave('admins') pra encerrar a escuta e evitar vazamento de memória (o componente continuar processando eventos mesmo depois de já ter sumido da tela).

Mão na massa

Adicione ShouldBroadcast no Event (se ainda não tiver feito). Ajuste SinoNotificacoes.vue pra escutar o canal com onMounted/onUnmounted. Abra duas abas logadas como admin — cadastre um usuário como gerente numa aba e veja o contador do sino mudar sozinho na outra.

Diferente da trilha em Blade — onde precisamos de uma "Aula bônus" separada só pra explicar AJAX manual — aqui não tem bônus nenhum: o comportamento de "atualizar a tela sem recarregar a página inteira" já é o padrão do Inertia, o tempo todo, em toda navegação. Não existe nada de especial a mais pra ligar.

Instalando o Tailwind (agora via Vite, não CDN)

Diferente da trilha anterior (que usava o script CDN do Tailwind, o "modo rápido"), aqui — já que estamos com um pipeline de build de qualquer forma, por causa do Vue — faz mais sentido instalar o Tailwind de verdade, integrado ao Vite:

terminal
npm install -D tailwindcss @tailwindcss/vite
vite.config.js
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true }),
        tailwindcss(),
        vue(),
    ],
});
resources/css/app.css
@import "tailwindcss";

E no app.blade.php, inclua o CSS junto do @vite:

resources/views/app.blade.php
@vite(['resources/css/app.css', 'resources/js/app.js'])

A tabela — mesmas classes da trilha anterior, agora dentro de um .vue

resources/js/Pages/Usuarios/Index.vue (trecho)
<table class="w-full border-collapse bg-white shadow-sm rounded-lg overflow-hidden">
    <thead class="bg-gray-100 text-left text-sm font-semibold text-gray-600">
        <tr>
            <th class="px-4 py-3">Nome</th>
            <th class="px-4 py-3">Nascimento</th>
            <th v-if="$page.props.can.cadastrarUsuarios" class="px-4 py-3">E-mail</th>
            <th v-if="$page.props.can.cadastrarUsuarios" class="px-4 py-3">Papel</th>
        </tr>
    </thead>
    <tbody class="divide-y divide-gray-100">
        <tr v-for="usuario in usuarios" :key="usuario.id" class="even:bg-gray-50 hover:bg-gray-100">
            <td class="px-4 py-3">{{ usuario.name }}</td>
            <td class="px-4 py-3">{{ usuario.data_nascimento }}</td>
            <td v-if="$page.props.can.cadastrarUsuarios" class="px-4 py-3">{{ usuario.email }}</td>
            <td v-if="$page.props.can.cadastrarUsuarios" class="px-4 py-3">
                <span class="inline-block bg-indigo-100 text-indigo-700 text-xs font-medium px-2.5 py-1 rounded-full">
                    {{ usuario.papel }}
                </span>
            </td>
        </tr>
    </tbody>
</table>

É literalmente a mesma tabela — mesmas classes Tailwind, mesmo raciocínio de even:/hover:/divide-y que você já aprendeu. A única troca é @can('cadastrar-usuarios') (Blade) virando v-if="$page.props.can.cadastrarUsuarios" (Vue) — e, como já vimos, essa decisão continua acontecendo no servidor, não é só um "esconder visualmente".

Mão na massa

Instale o Tailwind via Vite como no exemplo. Troque a lista <ul> do Index.vue pela tabela acima. Teste como comum e como admin, confirmando que as colunas extras aparecem/somem certo.

Debug e Diagnóstico

Aulas DBG1–DBG2 · conteúdo novo

dd() — "dump and die"

Antes de qualquer ferramenta sofisticada, a primeira pergunta que você faz quando algo não funciona é sempre a mesma: "o que exatamente tem dentro dessa variável, nesse ponto do código?" dd() (abreviação de dump and die, "despeja e morre") resolve isso — imprime o conteúdo de uma variável de forma legível, formatada, e interrompe a execução ali mesmo, sem rodar mais nada depois.

app/Http/Controllers/UsuarioController.php
public function store(Request $request)
{
    $validado = $request->validate([ /* ... */ ]);

    dd($validado); // a execução para bem aqui — o resto do método nem roda

    $novoUsuario = $action->executar($validado, Auth::user());
    return redirect('/usuarios');
}

Acessando a rota no navegador, em vez da tela normal, você vê uma página formatada mostrando exatamente o array $validado — cada chave, cada valor, com cores e identação. Muito mais legível que a saída crua do var_dump() nativo do PHP (o dd() usa por baixo dos panos o componente VarDumper, do Symfony, que o Laravel já traz pronto).

dd() aceita quantas variáveis você quiser de uma vez, e funciona com qualquer coisa — array, model do Eloquent, Collection, string, objeto:

exemplo
dd($usuario, $usuario->papel, $request->all());

dump() — a versão que não interrompe nada

Às vezes você quer só espiar um valor no meio do caminho, sem parar o resto da execução — por exemplo, dentro de um foreach, pra ver o que acontece em cada volta, sem que a primeira já pare tudo. É pra isso que serve dump(): imprime a variável, exatamente com a mesma formatação bonita do dd(), mas deixa o código continuar rodando depois.

exemplo
foreach ($usuarios as $usuario) {
    dump($usuario->name, $usuario->idade); // imprime e segue pro próximo do loop
}

Dentro de uma view Blade, existe o equivalente @dump($variavel), útil quando você quer conferir o que está chegando na tela sem sair do arquivo .blade.php.

logger() e o arquivo de log

Nem todo problema você quer ver "ao vivo" na tela — às vezes o comportamento só acontece em produção, ou dentro de um Job rodando em fila (onde não tem tela nenhuma pra mostrar um dd()). Pra esses casos, você já usou logger()->info(...) algumas vezes ao longo da trilha, como placeholder temporário nos Listeners (Aula 13) — chegou a hora de entender essa ferramenta de verdade.

exemplo
logger()->info('Usuário cadastrado', ['id' => $novoUsuario->id, 'papel' => $novoUsuario->papel]);
logger()->warning('Estoque baixo, atenção');
logger()->error('Falha ao chamar a evolution-api', ['numero' => $numero]);

logger() é um atalho pra Log:: facade — os dois fazem a mesma coisa. O segundo parâmetro (opcional) é um array de "contexto": dados extras que ficam anexados àquela linha do log, sem precisar concatenar tudo numa string só. Cada chamada vai parar em storage/logs/laravel.log, um arquivo de texto simples que cresce a cada entrada.

Os níveis (info, warning, error, entre outros: debug, notice, critical, alert, emergency) indicam a gravidade — não mudam onde a mensagem vai parar, mas ajudam a filtrar depois (a maioria das ferramentas de monitoramento de log deixa você ver só os error pra cima, por exemplo, ignorando o ruído de info).

terminal — acompanhando o log em tempo real
tail -f storage/logs/laravel.log

tail -f é um comando do Linux/Mac que mostra as últimas linhas de um arquivo e continua "grudado" nele, imprimindo linhas novas assim que aparecem — deixe esse comando rodando num terminal enquanto testa o sistema no navegador, numa outra janela, e você vê cada log surgir na hora.

dd()/dump() são só pra desenvolvimentoNunca deixe um dd() esquecido em código que vai pra produção — ele trava a requisição inteira ali, então qualquer usuário real que passar por aquele trecho vê a tela de debug em vez do sistema funcionando. É fácil esquecer um depois de uma sessão de investigação — vale o hábito de dar um Ctrl+F por "dd(" antes de cada commit.

E do lado do Vue?

Tudo que vimos aqui roda no servidor — o dd()/dump() não existe no navegador, porque é PHP puro. Do lado do componente .vue, a ferramenta equivalente é o bom e velho console.log() do JavaScript, mais o painel do navegador (F12 → aba Console). Pra inspecionar o estado reativo de um componente de forma mais visual, existe também a extensão Vue Devtools (disponível pra Chrome/Firefox) — ela mostra a árvore de componentes, as props que cada um recebeu, e os valores reativos ao vivo, parecido com o que o Telescope faz do lado do servidor (Aula DBG2).

Mão na massa

No método store() do UsuarioController, adicione temporariamente dd($validado) logo depois da validação, e confirme que a página para ali, mostrando o array formatado. Remova o dd() e troque por logger()->info('Usuário cadastrado', ['email' => $validado['email']]);. Abra um terminal com tail -f storage/logs/laravel.log rodando, cadastre um usuário pelo formulário, e veja a linha aparecer ao vivo.

Vendo cada query que o Eloquent dispara

Às vezes o problema não é "o que tem numa variável", é "quantas consultas o Eloquent está fazendo no banco, e quais são elas". O facade DB (Aula 25) tem duas ferramentas pra isso.

Registrando tudo, de forma global — útil pra deixar ligado durante uma sessão inteira de investigação:

app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\DB;

public function boot(): void
{
    DB::listen(function ($query) {
        logger()->info($query->sql, [
            'bindings' => $query->bindings,
            'tempo_ms' => $query->time,
        ]);
    });
}

DB::listen() registra uma função que roda a cada query executada, em qualquer lugar do sistema — cada uma vai parar no storage/logs/laravel.log, junto com o tempo que levou. Deixe isso só em ambiente local (nunca em produção — gera log demais e deixa tudo mais lento).

Inspecionando um trecho pontual — quando você já sabe onde olhar:

exemplo
DB::enableQueryLog();

$usuarios = User::with('criador')->get();

dd(DB::getQueryLog()); // mostra as queries rodadas até aqui, com tempo, e já para a execução

Detectando N+1 automaticamente

Lembra do problema N+1 da Aula 6 — uma query pra buscar os usuários, mais uma pra cada relacionamento acessado sem eager loading? O Eloquent tem um interruptor que transforma esse problema num erro imediato em desenvolvimento, em vez de deixar passar silenciosamente até virar lentidão em produção:

app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

preventLazyLoading() faz o Eloquent lançar uma exceção (LazyLoadingViolationException) assim que algum código acessar um relacionamento sem ele ter sido carregado antes via with() — em vez de simplesmente disparar a query extra calada, como faria por padrão. ! $this->app->isProduction() garante que essa trava só fica ativa fora de produção — você quer descobrir o problema enquanto desenvolve, não quebrar o sistema pros seus usuários reais se algum caso passar despercebido.

Laravel Telescope — um painel pra tudo isso de uma vez

Ao invés de espalhar dd() e conferir o arquivo de log manualmente, o Telescope (pacote oficial, mantido pelo próprio time do Laravel) dá um painel visual completo: toda requisição, toda query com seu tempo, todo Job disparado, todo e-mail enviado, toda exceção — tudo navegável pelo navegador.

terminal
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

--dev marca o pacote como dependência só de desenvolvimento (lembra do --no-dev na Aula D1? é exatamente esse mecanismo — o Telescope nunca deveria ir pra produção). telescope:install publica os arquivos de configuração e assets; migrate cria as tabelas onde ele guarda o que captura. Depois disso, acesse /telescope no navegador — cada aba (Requests, Queries, Jobs, Mail, Exceptions...) mostra exatamente o que aconteceu, em ordem cronológica, sem você precisar instrumentar nada manualmente no seu próprio código.

Existe também o Laravel Debugbar (pacote de terceiros, não oficial, mas extremamente popular), que mostra uma barra fixa no rodapé de cada página com um resumo rápido — queries, tempo de resposta, memória usada. É mais leve que o Telescope pra uma checada rápida; o Telescope é melhor quando você precisa investigar algo que aconteceu em background (um Job, um e-mail), fora do ciclo normal de tela.

A tela de erro em desenvolvimento também é uma ferramenta

Lembra do aviso na Aula D1 sobre nunca deixar APP_DEBUG=true em produção? Em desenvolvimento, é exatamente o oposto — deixe sempre ligado. Com ele ativo, qualquer exceção não tratada mostra uma página detalhada (o Laravel usa o pacote Ignition pra isso) com o trecho de código exato onde quebrou, a pilha de chamadas completa, e os dados da requisição — geralmente é a primeira coisa que aparece quando algo dá errado, antes mesmo de você precisar sair caçando com dd().

Mão na massa

Adicione o DB::listen() no AppServiceProvider e acesse /usuarios — confira no log quantas queries rodaram. Ative o Model::preventLazyLoading() e, em algum ponto do código, force um acesso a um relacionamento sem eager loading (ex: tire o with('criador') do index() por um instante) — confirme que aparece o erro em vez da query silenciosa. Instale o Telescope, acesse /telescope, cadastre um usuário pelo formulário, e explore as abas Requests e Queries pra ver tudo que aconteceu por trás daquele clique.

Validação Avançada

Aulas VAL1–VAL2 · conteúdo novo

Desde a Aula 5 você usa regras como required, email e unique — mas o Laravel tem bem mais de 90 regras prontas. Não vale a pena decorar todas; essa aula é uma referência pra consultar quando precisar de algo que ainda não apareceu na trilha. A lista completa e sempre atualizada mora na documentação oficial (laravel.com/docs/validation); aqui vai a fatia que resolve a grande maioria dos casos do dia a dia.

Presença e obrigatoriedade

RegraO que faz
requiredObriga o campo a vir preenchido (visto na Aula 5)
nullablePermite o campo vir vazio, sem falhar por isso
sometimesSó aplica o resto das regras se o campo vier na requisição — se ele nem existir, ignora
required_if:outro_campo,valorSó obrigatório se outro campo tiver aquele valor específico
required_unless:outro_campo,valorObrigatório, exceto quando outro campo tiver aquele valor
required_with:outro_campoObrigatório só se outro campo também estiver presente
prohibitedO campo não pode vir preenchido de jeito nenhum

Tipo de dado

RegraO que faz
stringPrecisa ser texto
integerPrecisa ser um número inteiro
numericPrecisa ser numérico (aceita decimal)
booleanAceita true/false/1/0
arrayPrecisa ser um array
datePrecisa ser uma data válida (visto na Aula 5)
emailFormato de e-mail válido (Aula 5)
urlFormato de URL válida
uuidFormato de UUID válido
jsonPrecisa ser um JSON válido

Tamanho e quantidade

RegraO que faz
min:6Tamanho mínimo (Aula 5) — pra número, é o valor mínimo, não o tamanho da string
max:255Tamanho/valor máximo (Aula 5)
size:14Tamanho exato — nem mais, nem menos
between:1,100Precisa estar entre dois valores/tamanhos
digits:14Precisa ter exatamente esse número de dígitos

Comparando com outro campo

RegraO que faz
confirmedExige um campo _confirmation junto (ex: password + password_confirmation) e verifica se são iguais
same:outro_campoPrecisa ser idêntico ao valor de outro campo
different:outro_campoPrecisa ser diferente do valor de outro campo
gt:outro_campoPrecisa ser maior que o valor de outro campo (também tem gte, lt, lte)

Formato específico

RegraO que faz
alphaSó letras
alpha_numSó letras e números
alpha_dashLetras, números, hífen e underline
regex:/padrão/Precisa bater com uma expressão regular customizada
date_format:Y-m-dData num formato específico
ipFormato de endereço IP válido

Banco de dados e listas

RegraO que faz
unique:users,emailNão pode já existir na tabela/coluna (Aula 5)
exists:categorias,idO valor precisa existir de verdade naquela tabela/coluna — o oposto do unique
in:admin,gerente,comumSó aceita esses valores exatos (Aula 5)
not_in:valor1,valor2Rejeita esses valores específicos

Arquivos (já vistas na Aula 24)

RegraO que faz
filePrecisa ser um upload de arquivo válido
imagePrecisa ser uma imagem (jpg, png, etc.)
mimes:pdf,docxSó aceita esses tipos de arquivo pela extensão/conteúdo real

Todas essas regras podem ser combinadas livremente, com | ou em array — exatamente como você já faz desde a Aula 5. Mas nem tudo cabe numa regra pronta: o próximo passo é aprender a criar a sua própria.

Quando as regras prontas não bastam

Nenhuma das regras da Aula VAL1 sabe validar um CNPJ de verdade — não é só conferir tamanho ou formato, é calcular dois dígitos verificadores a partir de um algoritmo específico. Esse é exatamente o tipo de situação onde vale criar sua própria regra, reutilizável em qualquer formulário do sistema.

Vamos usar um exemplo bem atual: desde julho de 2026, a Receita Federal passou a emitir CNPJs alfanuméricos (misturando letras e números nas 12 primeiras posições, mantendo só os 2 dígitos verificadores finais numéricos) — uma mudança real (Instrução Normativa RFB nº 2.229/2024), pensada pra não esgotar a quantidade de CNPJs possíveis só com números. Qualquer sistema que valide CNPJ precisou se adaptar.

Opção rápida: uma closure inline

Pra uma validação simples e usada uma vez só, dá pra passar uma função direto no array de regras, sem criar arquivo nenhum:

exemplo
'codigo' => [
    'required',
    function (string $attribute, mixed $value, \Closure $fail) {
        if (strtoupper($value) !== $value) {
            $fail('O :attribute precisa estar em maiúsculas.');
        }
    },
],

$fail(...) é como você avisa que a validação falhou — chamar ela marca o campo como inválido e usa o texto passado como mensagem de erro (:attribute é substituído automaticamente pelo nome do campo). Se $fail nunca for chamado, a validação passa.

Isso funciona, mas fica difícil de reaproveitar em outro formulário, e complicado de testar isoladamente. Pra uma regra de verdade — como validar CNPJ, algo que provavelmente vai aparecer em vários formulários do sistema — vale criar uma classe própria.

Gerando a classe da regra

terminal
php artisan make:rule Cnpj

Isso cria app/Rules/Cnpj.php, já implementando a interface ValidationRule — a forma atual do Laravel de definir regras customizadas (versões mais antigas usavam uma interface diferente, chamada só Rule, com métodos separados passes()/message(); isso está obsoleto, então se você encontrar exemplos assim por aí, são de um Laravel mais antigo).

O algoritmo do CNPJ alfanumérico

Cada caractere (dígito ou letra) vira um número através do código ASCII menos 48 — os dígitos 0-9 continuam valendo 0-9 (compatível com o CNPJ antigo, só numérico), e as letras A-Z passam a valer de 17 a 42. Os dois dígitos verificadores continuam sendo calculados por módulo 11, com pesos fixos — o mesmo princípio do CNPJ tradicional, só que agora alimentado por esses valores alfanuméricos.

app/Rules/Cnpj.php
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class Cnpj implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $cnpj = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $value));

        if (! preg_match('/^[A-Z0-9]{12}[0-9]{2}$/', $cnpj)) {
            $fail('O :attribute tem um formato inválido.');
            return;
        }

        if (! $this->digitosValidos($cnpj)) {
            $fail('O :attribute informado não é um CNPJ válido.');
        }
    }

    private function digitosValidos(string $cnpj): bool
    {
        $pesosPrimeiroDigito = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
        $pesosSegundoDigito = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];

        $base = substr($cnpj, 0, 12);
        $primeiroDigito = $this->calcularDigito($base, $pesosPrimeiroDigito);
        $segundoDigito = $this->calcularDigito($base . $primeiroDigito, $pesosSegundoDigito);

        return $cnpj === $base . $primeiroDigito . $segundoDigito;
    }

    private function calcularDigito(string $base, array $pesos): int
    {
        $soma = 0;

        foreach (str_split($base) as $indice => $caractere) {
            $valor = ord($caractere) - 48; // dígitos: 0-9 | letras A-Z: 17-42
            $soma += $valor * $pesos[$indice];
        }

        $resto = $soma % 11;

        return $resto < 2 ? 0 : 11 - $resto;
    }
}

Vamos por partes: preg_replace('/[^A-Z0-9]/i', '', $value) remove qualquer máscara (pontos, barra, hífen) que o usuário tenha digitado, deixando só os 14 caracteres que importam. O primeiro preg_match confere o formato básico — 12 caracteres alfanuméricos seguidos de 2 dígitos numéricos — antes de gastar processamento calculando dígito verificador de algo obviamente errado. digitosValidos() calcula os dois dígitos esperados e compara com os que realmente vieram no valor. ord($caractere) - 48 é a conversão ASCII descrita acima — repare que essa única linha já resolve tanto dígito quanto letra, sem precisar de um if separado pra cada caso. $resto < 2 ? 0 : 11 - $resto é a regra especial do módulo 11: se o resto da divisão for 0 ou 1, o dígito verificador é 0; caso contrário, é 11 menos o resto.

Usando a regra

exemplo
use App\Rules\Cnpj;

$request->validate([
    'cnpj_empresa' => ['required', new Cnpj],
]);

new Cnpj dentro do array de regras funciona exatamente como 'required' ou 'email' — só que agora é a sua própria classe controlando a lógica, testável isoladamente e reaproveitável em quantos formulários precisar, sem copiar e colar esse cálculo em cada um.

CNPJs antigos continuam válidosA Receita Federal deixou claro que CNPJs já existentes (só numéricos) continuam válidos indefinidamente — os dois formatos convivem. Repare que o algoritmo acima já cobre isso automaticamente: como ord('0') - 48 = 0 até ord('9') - 48 = 9, um CNPJ 100% numérico passa pelo mesmo cálculo e dá o resultado certo, sem precisar de nenhum caminho especial pro formato antigo.

Mão na massa

Esse exemplo é standalone — não faz parte do Painel de Usuários (que não tem campo de CNPJ), é só pra você ter a técnica pronta pro dia que precisar. Crie a regra Cnpj como no exemplo. Pelo Tinker, teste ela isolada: app(Illuminate\Validation\Factory::class)->make(['x' => '12ABC34501DE35'], ['x' => [new App\Rules\Cnpj]])->fails() — ajuste o valor de teste e confirme que CNPJs com dígito verificador errado falham, e um com o cálculo certo passa.

Build e Deploy

Aulas D1–D2 · conteúdo novo, essencial pro Apache

O ponto central: Apache serve arquivos estáticos, não roda JavaScript

Isso é fundamental de entender antes de colocar esse projeto no ar: o Apache (assim como qualquer servidor web tradicional) sabe servir arquivos — HTML, CSS, imagens, e também arquivos .js prontos. Ele não sabe executar Vue, não entende arquivos .vue, e não roda um processo Node.js permanentemente escutando requisições (diferente de, por exemplo, uma aplicação Node/Next.js rodando em produção). Por isso, antes de qualquer coisa, o Vue precisa ser compilado — transformado de vários arquivos .vue em código JavaScript puro, otimizado, dentro de poucos arquivos .js/.css — e é só esse resultado final que vai pro servidor.

Os dois modos que você já usou, formalizados

ComandoQuando usarO que acontece
npm run devSó na sua máquina, enquanto desenvolveSobe um servidor local do Vite, que recompila sozinho a cada salvamento (HMR) — nunca use isso em produção
npm run buildAntes de publicarGera os arquivos finais, otimizados e com nome de arquivo único (hash), dentro de public/build/

Rodando o build

terminal
npm run build

Isso cria uma pasta public/build/ com o seguinte formato:

public/build/ (depois do npm run build)
public/build/
  manifest.json
  assets/
    app-a1b2c3d4.js       ← todo o Vue + Inertia + suas Pages/Components, empacotados
    app-e5f6g7h8.css      ← todo o Tailwind já processado, só as classes usadas de verdade
    Login-f3a9c2e1.js     ← "code splitting": páginas menos acessadas viram arquivo separado,
                             carregado só quando o usuário navega até lá

O trecho de letras/números aleatório no nome de cada arquivo é o hash — uma "impressão digital" do conteúdo, que muda toda vez que o código muda. Isso garante que o navegador do usuário nunca sirva uma versão em cache desatualizada: se o CSS mudou, o nome do arquivo muda junto, forçando o navegador a baixar o novo.

manifest.json é o "mapa" que conecta os nomes originais (resources/js/app.js) aos nomes finais com hash — é esse arquivo que a diretiva @vite lê pra saber qual <script> imprimir.

Nada vem de CDN nesse projetoRepare que tanto o Vue quanto o Tailwind (Aula 27) foram instalados via npm e entram nesse mesmo processo de build — diferente da trilha em Blade, que carregava o Tailwind direto de um link externo. Aqui, tudo que o navegador carrega vem do seu próprio servidor. Isso tem duas vantagens práticas: o site não depende de nenhum serviço de terceiro estar no ar pra funcionar, e — o que importa especialmente pro suporte offline (Aula O1) — o Service Worker consegue cachear esses arquivos sem nenhuma complicação de CORS, já que eles são "same-origin" (vêm do mesmo domínio da aplicação).

Como a diretiva @vite sabe qual dos dois modos usar

A boa notícia: você não precisa mudar nenhum código entre desenvolvimento e produção. A diretiva @vite(...) que você já colocou em resources/views/app.blade.php (Aula F2) detecta sozinha o ambiente:

  • Enquanto npm run dev está rodando, ele cria um arquivo temporário chamado public/hot. O @vite vê esse arquivo e sabe que deve apontar pro servidor local do Vite (ex: http://localhost:5173).
  • Depois de npm run build, esse arquivo public/hot não existe mais — o @vite então lê o manifest.json e imprime uma tag <script> apontando pro arquivo já compilado, com o hash certo.
CuidadoNunca suba o arquivo public/hot pro servidor de produção (ele normalmente já vem no .gitignore padrão do Laravel, então isso raramente é um problema — mas vale saber que, se esse arquivo existir por engano em produção, o site tentaria carregar o JavaScript de localhost:5173, que não existe lá, e a página ficaria em branco).

Node.js não precisa existir no servidor

Depois do npm run build, a pasta node_modules/ e o próprio Node.js não precisam mais existir no servidor — só os arquivos finais dentro de public/build/ importam a partir daqui. Se o seu Apache não tiver Node.js instalado (comum em hospedagens compartilhadas mais simples), o jeito mais comum é rodar npm run build numa outra máquina (a sua, ou um servidor de CI/CD) e só enviar a pasta public/build/ já pronta, junto do resto do projeto. Vamos ver o checklist completo de deploy, incluindo esse detalhe, na próxima aula.

Conferindo que o document root continua sendo public/

Vale relembrar a Aula 1 da trilha em Blade: o Apache deve apontar pra pasta public/, nunca pra raiz do projeto. Isso não muda nada com Vue — pelo contrário, fica ainda mais importante, porque é dentro de public/build/ que os arquivos compilados vivem, e é o index.php dentro de public/ que continua sendo a única porta de entrada de tudo, Vue incluso.

Links/assets saindo em http:// numa página https://?Dependendo do servidor/painel, o Laravel pode não detectar sozinho que a conexão é HTTPS — os arquivos que o @vite referencia (e qualquer URL do Laravel) saem como http://, e o navegador bloqueia isso como "conteúdo misto". A trilha em Blade (Aula D1) mostra a correção direta — URL::forceHttps() no AppServiceProvider, condicionado a app()->isProduction().

Mão na massa

Pare o npm run dev, se ele estiver rodando. Rode npm run build e confira que a pasta public/build/ foi criada com arquivos dentro. Acesse o sistema no navegador (com php artisan serve ligado, mas sem o npm run dev) — tudo deve continuar funcionando normalmente, servido pelos arquivos já compilados. Depois, rode npm run dev de novo e confirme que volta a funcionar em modo desenvolvimento, sem precisar mudar nada no código.

O .gitignore desse projeto tem uma peça a mais

Na trilha em Blade, o .gitignore padrão do Laravel já resolvia praticamente tudo. Aqui, como o projeto ganhou um pipeline de front-end inteiro (Vue + Tailwind + Vite), duas entradas passam a importar de verdade:

.gitignore (trecho relevante desse projeto)
/vendor
/node_modules
.env
/public/hot
/public/build
/public/storage
/storage/*.log

/node_modules segue a mesma lógica do /vendor — reconstruível a partir de package.json/package-lock.json, então não faz sentido versionar centenas de megabytes de dependências. /public/hot é o arquivo temporário que só existe enquanto npm run dev está rodando (Aula D1) — nunca deveria ir pro Git de propósito nenhum. /public/build é o resultado do npm run build — e essa é a única decisão que realmente vale a pena parar pra pensar.

Versionar public/build ou gerar ele no deploy?Existem dois caminhos válidos, e vale escolher conscientemente:
Gitignorar (o padrão do Laravel, e o que recomendamos) — o build é gerado no momento do deploy, seja rodando npm run build direto no servidor, seja numa esteira de CI/CD que builda e envia só o resultado. Mantém o repositório limpo e garante que o build sempre reflete o código-fonte atual.
Versionar public/build — útil só em cenários bem simples, tipo uma hospedagem compartilhada sem Node.js nem CI/CD, onde você builda na sua máquina e sobe o projeto inteiro (build incluso) via FTP/git push direto. Funciona, mas exige lembrar de sempre rodar o build antes de cada commit — fácil de esquecer e publicar uma versão desatualizada do JS.

package-lock.json, ao contrário de node_modules/, deve ser versionado — ele trava as versões exatas de cada dependência JavaScript, exatamente como o composer.lock faz do lado do PHP.

O mapa de pastas — tudo que você construiu, organizado

Depois de F1 até O4, o projeto acumulou bastante estrutura nova dentro de resources/. Vale ver o quadro completo de uma vez:

resources/ (visão geral)
resources/
  css/
    app.css              → só o "@import tailwindcss" (Aula 27)
  js/
    app.js               → ponto de entrada: registra Inertia, Vue e o Service Worker (F2, O1)
    db.js                → configuração do Dexie/IndexedDB (O2)
    sync.js               → funções de sincronização offline (O2, O3, O4)
    Pages/                → uma pasta por recurso, um arquivo por tela — nome bate com Inertia::render()
      Auth/
        Login.vue         (Aula 9)
      Usuarios/
        Index.vue         (Aula 3, 6, 7, 14, 15, 27)
        Criar.vue         (Aula 5, 24)
    Components/           → pedaços reutilizáveis, importados em várias Pages
      Badge.vue           (F4)
      SinoNotificacoes.vue (Aula 22, 26)
      StatusSincronizacao.vue (O4)
    Layouts/               → moldes compartilhados
      AppLayout.vue        (F5)
    composables/           → lógica reativa reutilizável (funções use*)
      useSincronizacao.js  (O4)
  views/
    app.blade.php          → a ÚNICA view Blade do projeto inteiro (F2)

Repare que resources/views/ praticamente esvaziou — sobrou só o app.blade.php, a "casca" que carrega o Vue (Aula F2). Todo o resto que antes seria uma view Blade virou um arquivo .vue dentro de Pages/. Isso é o resumo visual da própria proposta dessa trilha: o Laravel continua dono das pastas app/, routes/, database/ exatamente como na trilha em Blade — só a camada de tela migrou de resources/views/ pra resources/js/Pages/.

O checklist completo de deploy, do zero

Juntando o que a trilha em Blade já ensinou (composer, .env, migrations, cache) com o que é específico desse stack (npm, build):

Precisa instalar o Node.js no servidor? Use o NVMDiferente do PHP, que a maioria dos servidores já vem com alguma versão instalada, o Node.js muitas vezes precisa ser instalado do zero — e a versão que vem por padrão em pacotes do sistema (apt install nodejs, por exemplo) costuma ser antiga demais pras dependências desse projeto (Vite, Tailwind via Vite, vite-plugin-pwa). O jeito mais confiável de instalar — e trocar de versão depois, se precisar — é o NVM (Node Version Manager):
terminal (no servidor, ou na máquina onde for rodar o build)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install 22
curl ... | bash baixa e roda o instalador do NVM, que se registra no seu shell. source ~/.bashrc recarrega o arquivo de configuração do terminal na sessão atual, sem precisar desconectar e reconectar — só depois disso o comando nvm passa a existir. nvm install 22 instala o Node.js 22 (recente o suficiente pras dependências desse projeto) e já deixa ela ativa na sessão. Feito isso, o npm ci e o npm run build do checklist abaixo já encontram a versão certa.
Erro de "dubious ownership" no git?Se um git clone/git pull no servidor recusar rodar citando dubious ownership, é uma proteção do Git contra pastas cujo dono não é o mesmo usuário rodando o comando — comum em painéis (aaPanel, cPanel, Plesk). Resolve com git config --global --add safe.directory /caminho/do/projeto, uma vez por servidor. A trilha em Blade (Aula D1) detalha esse cenário e também o que fazer quando o git pull reclama de mudanças locais no servidor.
terminal — primeiro deploy, na ordem
git clone https://github.com/sua-conta/painel-usuarios.git
cd painel-usuarios

composer install --optimize-autoloader --no-dev
npm ci

cp .env.example .env
php artisan key:generate
# editar .env: banco de dados, EVOLUTION_API_*, etc.

npm run build

php artisan storage:link
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache

php artisan migrate --force --seed

php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache

npm ci (diferente de npm install) instala exatamente as versões travadas no package-lock.json — mais previsível que npm install pra um ambiente de produção, que não deveria decidir versões novas sozinho. npm install continua tendo seu lugar: use ele (em vez do npm ci) quando o package.json mudou e o package-lock.json precisa ser atualizado também — pro fluxo normal de deploy, com o lock file já commitado, npm ci é a escolha mais segura. Repare a ordem: o .env precisa existir antes do npm run build, porque variáveis com prefixo VITE_ (como as do Reverb, na Aula 26) são lidas do .env no momento do build, não em tempo de execução — se você buildar antes de configurar o .env, essas variáveis ficam vazias no JavaScript compilado, e é preciso buildar de novo depois de corrigir. optimize:clear, logo antes dos três comandos de cache, limpa qualquer cache antigo que possa ter sobrado de um deploy anterior — evita "cache em cima de cache" com valor desatualizado.

Se o servidor for gerenciado por um painel tipo aaPanel/cPanel/Plesk, os comandos php artisan ... desse checklist também podem precisar do caminho completo até o binário do PHP (algo como /www/server/php/83/bin/php artisan migrate) — a trilha em Blade (Aula D1) explica o motivo com mais detalhe.

Um detalhe de sintaxe que vale notar em scripts de deploy por aí: às vezes você vê comandos encadeados com ;, tipo npm install ; npm run build. Repare que ; roda o próximo comando não importa o que aconteceu com o anterior — diferente de && (o que usamos nos exemplos dessa aula), que só segue pro próximo comando se o anterior tiver dado certo. Pra deploy, && costuma ser mais seguro: se o npm install falhar (dependência quebrada, rede caiu no meio), você não quer que o npm run build rode em cima de um node_modules incompleto, só pra falhar de um jeito mais confuso mais adiante.

Pra uma atualização normal, num servidor que já está no ar, o checklist encurta bastante — mesma lógica da trilha em Blade, só acrescentando o build:

terminal — atualização
git pull origin main
composer install --optimize-autoloader --no-dev
npm ci
npm run build
php artisan migrate --force
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache

Mão na massa

Confira o .gitignore do seu projeto e confirme que as seis linhas do exemplo estão lá (o Laravel já inclui a maioria por padrão desde a instalação — só vale checar). Simule um primeiro deploy: faça backup do seu .env, apague vendor/, node_modules/ e public/build/, e siga o checklist completo do zero. Confirme, no final, que o sistema volta a funcionar — incluindo o suporte offline (Aula O1), já que ele depende do Service Worker gerado pelo build.

Suporte Offline

Aulas O1–O4 · conteúdo novo

O que é um Service Worker

Service Worker é um script que o navegador roda em segundo plano, separado da página, capaz de interceptar toda requisição de rede que o app faz — inclusive pra decidir "devolve isso do cache, nem tenta ir na internet". É essa peça que torna possível um site funcionar de verdade sem conexão nenhuma, não só "carregar mais devagar".

Sem Service Worker, se a internet cair no meio de uma navegação, o navegador simplesmente não consegue buscar o próximo arquivo .js/.css e a tela quebra. Com ele, esses arquivos (o "esqueleto" do app) já ficam guardados localmente desde a primeira visita.

Instalando

terminal
npm install -D vite-plugin-pwa
vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true }),
        vue(),
        VitePWA({
            registerType: 'autoUpdate',
            manifest: {
                name: 'Painel de Usuários',
                short_name: 'Painel',
                start_url: '/',
                display: 'standalone',
                background_color: '#ffffff',
                theme_color: '#4f46e5',
            },
        }),
    ],
});

registerType: 'autoUpdate' faz o Service Worker se atualizar sozinho quando você publica uma versão nova, sem depender do usuário limpar cache manualmente. O bloco manifest é o que torna o app "instalável" (o navegador oferece "Adicionar à tela inicial") — name/short_name são os nomes exibidos, display: 'standalone' faz abrir sem a barra de endereço do navegador, parecendo um app nativo.

Ativando no ponto de entrada

resources/js/app.js
import { registerSW } from 'virtual:pwa-register';

registerSW({ immediate: true });

virtual:pwa-register é um módulo virtual — um arquivo que não existe de verdade no seu disco, gerado na hora pelo próprio plugin durante o build. registerSW({ immediate: true }) registra o Service Worker assim que o app carrega.

Importante pra testarO comportamento completo de cache offline só aparece de verdade depois de um build de produção (Aula D1). Pra testar localmente sem precisar publicar, rode npm run build seguido de npm run preview — isso sobe um servidor local servindo os arquivos já compilados, exatamente como ficariam em produção.

Mão na massa

Instale e configure o vite-plugin-pwa como no exemplo. Rode npm run build && npm run preview, abra o app, e depois desligue o Wi-Fi/internet da sua máquina. Recarregue a página — ela deve continuar abrindo (mesmo que, por enquanto, sem dados novos, já que ainda não temos IndexedDB).

Por que IndexedDB, e não LocalStorage

LocalStorage (que você talvez já tenha usado em outros projetos JS) guarda só texto simples, tem limite de uns 5-10MB, e é síncrono — trava a página enquanto lê/escreve. IndexedDB é um banco de dados de verdade dentro do navegador: assíncrono (não trava nada), aguenta bem mais dado, e permite consultar por índice (tipo um WHERE). É a ferramenta certa pra guardar um catálogo inteiro de usuários/produtos offline.

A API nativa do IndexedDB é bem verbosa e chata de usar direto. Dexie.js é uma biblioteca que embrulha ela numa API bem mais simples — parecido com o que o Eloquent faz em cima de SQL puro.

Instalando e definindo o "schema"

terminal
npm install dexie
resources/js/db.js
import Dexie from 'dexie';

export const db = new Dexie('painelUsuarios');

db.version(1).stores({
    usuarios: 'id, name, papel',
    filaPedidos: '++localId, status',
});

db.version(1).stores({...}) é o equivalente Dexie de uma migration — só que versionado direto no código, sem arquivo separado. Cada chave (usuarios, filaPedidos) vira uma "tabela" (tecnicamente chamada de object store); a string na frente lista quais campos ficam indexados (buscáveis rapidamente). id como primeiro campo de usuarios é a chave primária, vinda do servidor. Já em filaPedidos, o ++localId diz "gere um ID automático, só local, incremental" — porque um pedido criado offline ainda não tem ID nenhum do banco de verdade.

Um endpoint só de dados, pra sincronizar o catálogo

Sincronizar o catálogo não é uma "página" — é só buscar dados brutos. Pra isso, vale a pena ter uma rota simples devolvendo JSON puro, separada das rotas Inertia:

routes/web.php
use App\Http\Resources\UsuarioResource;
use App\Models\User;

Route::middleware('auth')->get('/api/usuarios/sincronizar', function () {
    return UsuarioResource::collection(User::all());
});
resources/js/sync.js
import { db } from '@/db';

export async function sincronizarCatalogo() {
    const resposta = await fetch('/api/usuarios/sincronizar');
    const usuarios = await resposta.json();

    await db.usuarios.clear();
    await db.usuarios.bulkAdd(usuarios.data);
}

fetch(...) é a função nativa do navegador pra fazer requisições HTTP — mais crua que o useForm do Inertia, mas apropriada aqui porque não estamos navegando pra uma página, só buscando dados. db.usuarios.clear() apaga o que tinha antes, e bulkAdd(...) insere a lista inteira de uma vez (mais rápido que inserir um por um).

Lendo do IndexedDB dentro de um componente Vue

exemplo
<script setup>
import { ref, onMounted } from 'vue';
import { db } from '@/db';

const usuariosOffline = ref([]);

onMounted(async () => {
    usuariosOffline.value = await db.usuarios.toArray();
});
</script>

db.usuarios.toArray() devolve uma Promise (uma "promessa" de valor que chega depois — por isso o await) com tudo que está guardado localmente. Isso permite montar a tela de lista mesmo sem nenhuma requisição de rede acontecer.

Mão na massa

Crie db.js com o schema acima. Crie a rota de sincronização e a função sincronizarCatalogo(). Chame ela uma vez (num botão temporário, ou direto no onMounted da página de login) enquanto online. Depois, desligue a internet e confirme, via db.usuarios.toArray() no console do navegador, que os dados continuam lá.

Repare que isso aqui é genérico — o mesmo padrão que serviria pra um pedido de venda offline num PDV de verdade. Vamos ilustrar com uma ação simples, já que nosso projeto de estudo é o cadastro de usuários; a ideia se aplica igual pra qualquer registro que precise sobreviver sem internet.

Salvando localmente, com status "pendente"

resources/js/sync.js
import { db } from '@/db';

export async function salvarNaFila(dados) {
    await db.filaPedidos.add({
        dados,
        status: 'pendente',
        criadoEm: new Date().toISOString(),
    });
}

Repare que guardamos o objeto dados inteiro dentro de um campo — não precisamos "achatar" cada propriedade em colunas separadas, porque IndexedDB (diferente de uma tabela SQL) aceita guardar objetos JavaScript completos, aninhados, sem problema.

Mostrando quantos itens estão pendentes

exemplo
<script setup>
import { ref, onMounted } from 'vue';
import { db } from '@/db';

const pendentes = ref(0);

async function atualizarContagem() {
    pendentes.value = await db.filaPedidos.where('status').equals('pendente').count();
}

onMounted(atualizarContagem);
</script>

<template>
    <p v-if="pendentes > 0" class="text-amber-600 text-sm">
        {{ pendentes }} registro(s) aguardando sincronização
    </p>
</template>

db.filaPedidos.where('status').equals('pendente').count() é uma consulta Dexie — o mesmo raciocínio de where() que você já conhece do Eloquent, só que rodando dentro do navegador, sem tocar o servidor.

Mão na massa

Crie a função salvarNaFila. Adicione o indicador de "X pendentes" em algum lugar visível do layout. Desligue a internet, tente "salvar" algo qualquer chamando essa função pelo console do navegador, e confirme que o contador sobe.

Detectando online/offline — a base, funciona em qualquer navegador

O navegador expõe navigator.onLine (um booleano com o estado atual) e dispara os eventos online/offline na window sempre que a conexão muda. Isso funciona em absolutamente qualquer navegador, sem exceção — é a base de tudo que vem a seguir.

Empacotando isso num composable

Composable é o nome que o Vue dá pra uma função reutilizável que empacota estado reativo + lógica — por convenção, o nome começa com use. É parecido com extrair uma regra repetida pra um Trait no PHP, só que pro lado reativo do front-end.

resources/js/composables/useSincronizacao.js
import { ref, onMounted, onUnmounted } from 'vue';
import { db } from '@/db';

export function useSincronizacao() {
    const online = ref(navigator.onLine);
    const sincronizando = ref(false);
    const ultimaSincronizacao = ref(localStorage.getItem('ultimaSincronizacao'));

    async function sincronizarAgora() {
        if (!online.value || sincronizando.value) return;

        sincronizando.value = true;
        try {
            const pendentes = await db.filaPedidos.where('status').equals('pendente').toArray();

            for (const item of pendentes) {
                await fetch('/api/pedidos', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(item.dados),
                });
                await db.filaPedidos.delete(item.localId);
            }

            ultimaSincronizacao.value = new Date().toISOString();
            localStorage.setItem('ultimaSincronizacao', ultimaSincronizacao.value);
        } finally {
            sincronizando.value = false;
        }
    }

    function aoFicarOnline() {
        online.value = true;
        sincronizarAgora();
    }

    function aoFicarOffline() {
        online.value = false;
    }

    onMounted(() => {
        window.addEventListener('online', aoFicarOnline);
        window.addEventListener('offline', aoFicarOffline);
    });

    onUnmounted(() => {
        window.removeEventListener('online', aoFicarOnline);
        window.removeEventListener('offline', aoFicarOffline);
    });

    return { online, sincronizando, ultimaSincronizacao, sincronizarAgora };
}

Repare no try/finally: mesmo que algum fetch falhe no meio do loop, sincronizando.value = false sempre roda no final, evitando que o botão fique travado em "Sincronizando..." pra sempre. Itens que falharem simplesmente continuam na fila (não chamamos delete pra eles), prontos pra tentar de novo na próxima chamada.

O componente visual — status + botão

resources/js/Components/StatusSincronizacao.vue
<script setup>
import { useSincronizacao } from '@/composables/useSincronizacao';

const { online, sincronizando, ultimaSincronizacao, sincronizarAgora } = useSincronizacao();
</script>

<template>
    <div class="flex items-center gap-3 text-sm text-gray-600">
        <span class="flex items-center gap-1.5">
            <span :class="online ? 'bg-green-500' : 'bg-red-500'" class="w-2 h-2 rounded-full"></span>
            {{ online ? 'Online' : 'Offline' }}
        </span>

        <span v-if="ultimaSincronizacao">
            Última sincronização: {{ new Date(ultimaSincronizacao).toLocaleTimeString() }}
        </span>
        <span v-else>Ainda não sincronizado</span>

        <button
            @click="sincronizarAgora"
            :disabled="!online || sincronizando"
            class="bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 disabled:opacity-50"
        >
            {{ sincronizando ? 'Sincronizando...' : 'Sincronizar agora' }}
        </button>
    </div>
</template>

Adicione esse componente no AppLayout.vue (Aula F5), do lado da navbar, e ele fica visível em toda tela do sistema.

Background Sync — um reforço automático, não a base

Existe uma API de navegador chamada Background Sync, que permite registrar um "pedido de sincronização" que o próprio navegador dispara sozinho assim que a conexão volta — mesmo que o app esteja fechado. Do lado do Vue, o registro é simples:

exemplo
if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registro = await navigator.serviceWorker.ready;
    await registro.sync.register('sincronizar-pedidos');
}

Só que pra esse registro de fato fazer alguma coisa, o Service Worker precisa escutar esse evento — o que exige escrever seu próprio arquivo de Service Worker (a estratégia injectManifest do vite-plugin-pwa, em vez do generateSW automático que usamos na Aula O1), algo mais avançado que foge do escopo dessa trilha.

Por que o botão manual não é opcionalBackground Sync não é suportado no Safari nem no Firefox até hoje — só em Chrome, Edge e navegadores baseados em Chromium. Isso significa que, se seu sistema for usado em iPhone/iPad (Safari é obrigatório lá), essa API simplesmente não existe. É exatamente por isso que o botão "Sincronizar agora" + o auto-disparo no evento online (que já implementamos no composable, e que funciona em qualquer navegador) precisam ser a base confiável do sistema — Background Sync é, na melhor das hipóteses, um reforço a mais, nunca a única linha de defesa.

Mão na massa

Crie o composable useSincronizacao e o componente StatusSincronizacao.vue, adicionando ele no layout. Teste o ciclo completo: fique offline, salve algo na fila (Aula O3), confirme que o indicador mostra "Offline" e o botão fica desabilitado; volte a ficar online e confirme que a sincronização dispara sozinha (pelo evento online) e o "Última sincronização" atualiza. Force um teste do botão manual também, ficando online e clicando nele diretamente.

Referência Rápida

Aula 28 · resumo

Igual fizemos na trilha em Blade: aqui vale a mesma distinção entre convenção mecânica (o Inertia/Vue literalmente usa aquele nome pra achar o arquivo — fugir quebra) e estilo de comunidade (o código funciona do mesmo jeito, mas seguir o padrão poupa confusão).

Páginas Inertia (mecânico)

O quêConvençãoExemplo
Nome no Inertia::render()PascalCase, barra = subpastaInertia::render('Usuarios/Index')
Arquivo correspondenteMesmo caminho, dentro de resources/js/Pages/resources/js/Pages/Usuarios/Index.vue

Componentes Vue (estilo de comunidade, mas quase universal)

O quêConvençãoExemplo
Nome do arquivoPascalCaseSinoNotificacoes.vue
Uso no templatePascalCase (mesmo nome da importação)<SinoNotificacoes />
Onde guardarPages/ pra telas completas, Components/ pra pedaços reutilizáveis, Layouts/ pra moldes

Props: snake_case ou camelCase? (decisão de projeto)

O Laravel usa snake_case nativamente (data_nascimento), enquanto a convenção do JavaScript é camelCase (dataNascimento). Ao longo dessa trilha, optamos por manter o snake_case vindo direto do Laravel nas props, sem nenhuma camada de tradução — mais simples, menos código, e você sempre sabe que o nome da prop é idêntico ao nome da coluna no banco. Alguns times preferem converter tudo pra camelCase no front-end por consistência com o resto do ecossistema JavaScript; isso é só estilo, escolha o que sua equipe preferir e mantenha consistente.

<script setup> e as macros do compilador (mecânico)

O quêConvenção
defineProps({...})Declara as props que o componente recebe — não precisa de import, é uma macro especial do compilador
defineOptions({ layout: ... })Define opções do componente (como o Layout) dentro de <script setup>
ref() / computed()Precisam de import { ref, computed } from 'vue' — essas não são macros, são funções normais

Usamos <script setup> em toda a trilha por ser a forma mais moderna e concisa de escrever Composition API — existe uma forma mais antiga e verbosa (Options API, com blocos data(), methods: {}), que você pode encontrar em projetos ou tutoriais mais antigos, mas não é o que recomendamos pra projetos novos.

O sistema completo, de relance

Se você fez todos os exercícios, o Painel de Usuários agora roda inteiro sobre Inertia + Vue: rotas e regras de negócio 100% em Laravel (idênticas à trilha anterior), telas em componentes .vue reativos, formulários com useForm substituindo o @csrf/old()/@error do Blade, dados globais (usuário logado, permissões, notificações, mensagens de sucesso) compartilhados automaticamente via HandleInertiaRequests, sino de notificação em tempo real com Echo dentro do ciclo de vida do Vue, catálogo e fila de pendências sobrevivendo sem internet via Service Worker + IndexedDB, e tudo compilado com npm run build pra rodar como arquivo estático no seu Apache. Mesma aplicação, mesmas regras — camada de tela completamente nova, e agora resiliente a quedas de conexão.

nenhuma aula encontrada