Vue.js + Laravel via Inertia Trilha 2 de 2 · sequência da trilha Laravel 13 · 26 tópicos

Depois do Laravel, entra a view

Vue entra como camada de tela. O Laravel continua dono de tudo.

Rotas, autenticação, validação e regra de negócio seguem 100% no Laravel — como já estão hoje no seu ERP. O Inertia só troca o "return view()" do Blade por um "Inertia::render()" que devolve um componente Vue. Não existe API REST no meio, não existe Vue Router: a URL de cada tela continua sendo uma rota do routes/web.php.

A meta desta trilha é a mesma da anterior: terminar e reescrever uma parte do seu ERP — cadastros, PDV, vendas — usando Vue no lugar do Blade puro.

01

Fundamentos do Vue 3

Do zero: reatividade, template, componentes. Sem Laravel ainda — só Vue puro.

1Por que Vue 3 + Composition API (<script setup>)

Vue é um framework reativo baseado em componentes: você descreve o estado (dados) e o template reage sozinho quando esse estado muda, sem manipular o DOM na mão como em jQuery. Existem duas formas de escrever um componente — a Options API antiga (data(), methods, computed como blocos separados) e a Composition API com <script setup>, que é o padrão atual. Você vai ver Options API em tutoriais mais antigos; nesta trilha usamos só Composition API porque facilita reaproveitar lógica entre componentes (composables, tópico 10).

<script setup>
import { ref } from 'vue'

const contador = ref(0)
</script>

<template>
  <button @click="contador++">Cliques: {{ contador }}</button>
</template>

<style scoped>
button { padding: .5rem 1rem; }
</style>
Convenção: arquivos de componente usam PascalCase (ProdutoForm.vue). No template, referencie também em PascalCase (<ProdutoForm />) — mesma disciplina de nomes que você já usa nas classes do Laravel.
2Reatividade: ref e reactive

ref() embrulha um valor primitivo (string, number, boolean) e precisa de .value dentro do <script> — no template o Vue desembrulha sozinho. reactive() é pra objetos e não usa .value, mas perde a reatividade se você desestruturar as propriedades soltas.

import { ref, reactive } from 'vue'

const nome = ref('')          // primitivo -> precisa de .value no <script>
const produto = reactive({    // objeto -> não usa .value
  nome: '',
  precoCentavos: 0,
})

function atualizarNome(valor) {
  nome.value = valor          // aqui usa .value
  produto.nome = valor        // aqui não usa
}
Cuidado: const { nome } = produto quebra a reatividade daquele campo. Se precisar desestruturar um reactive, use toRefs(produto) antes.
3Interpolação e v-bind

{{ }} imprime texto; :atributo (forma curta de v-bind:atributo) liga um atributo HTML a uma variável reativa — inclusive classes e estilos condicionais, o que combina direto com utilitários do Tailwind.

<template>
  <h1>{{ produto.nome }}</h1>
  <img :src="produto.imagemUrl" :alt="produto.nome">
  <span :class="{ 'text-red-600': produto.estoque === 0, 'text-green-600': produto.estoque > 0 }">
    {{ produto.estoque }} em estoque
  </span>
</template>
4Condicionais: v-if, v-show, v-else

v-if remove/recria o elemento do DOM (mais caro, mas correto pra conteúdo que raramente alterna). v-show só alterna display: none via CSS — mais barato pra algo que abre e fecha com frequência, como um menu dropdown.

<p v-if="produtos.length === 0">Nenhum produto cadastrado.</p>
<ul v-else>
  <li v-for="p in produtos" :key="p.id">{{ p.nome }}</li>
</ul>

<div v-show="menuAberto" class="dropdown">...</div>
5Listas: v-for e :key

:key precisa ser um identificador estável — o Vue usa isso pra saber qual elemento do DOM corresponde a qual item quando a lista muda.

<tr v-for="venda in vendas" :key="venda.id">
  <td>{{ venda.id }}</td>
  <td>{{ venda.total_formatado }}</td>
</tr>
Atenção: nunca use o índice do array como :key se a lista pode ser reordenada, filtrada ou tiver itens removidos do meio (resultado de busca, por exemplo) — o DOM reaproveita a linha errada e o estado de inputs internos bugam.
6Eventos: v-on e modificadores

@evento é a forma curta de v-on:evento. Modificadores como .prevent e .stop evitam escrever event.preventDefault()/stopPropagation() na mão.

<form @submit.prevent="salvar">
  <input @keyup.enter="buscar" v-model="termo">
  <button @click.stop="remover(produto.id)">Remover</button>
</form>
Vai usar muito: @submit.prevent é obrigatório em praticamente todo formulário com Inertia (tópico 16) — sem ele o navegador tenta fazer um submit HTML normal e recarrega a página inteira.
7Formulários: v-model

v-model faz o binding nos dois sentidos (estado → input e input → estado) pra inputs de texto, select, checkbox e radio.

<input v-model="produto.nome" type="text">
<select v-model="produto.categoriaId">
  <option v-for="c in categorias" :key="c.id" :value="c.id">{{ c.nome }}</option>
</select>
<input v-model.number="produto.quantidade" type="number">
<input v-model="produto.ativo" type="checkbox">
No seu ERP: o valor monetário fica salvo como inteiro na escala 10000 no SQLite, mas o formulário deve trabalhar em reais (número "normal", tipo 19.90). Não faça v-model direto no valor bruto — converta na hora de montar o payload de envio (tópico 10, composable useMoney).
8computed e watch

computed é um valor derivado e cacheado — só recalcula quando uma dependência muda. watch é pra efeito colateral: reagir a uma mudança executando alguma ação (buscar dado, chamar API, etc).

const subtotal = computed(() =>
  itensCarrinho.value.reduce((soma, item) => soma + item.preco * item.quantidade, 0)
)

watch(() => produto.categoriaId, (novaCategoria) => {
  carregarSubcategorias(novaCategoria)
})
9Componentes: props, emits e slots

Um componente recebe dados de fora via props e avisa o pai de eventos via emits — nunca altera uma prop diretamente. <slot> deixa o pai injetar conteúdo dentro do componente filho.

<!-- ProdutoCard.vue -->
<script setup>
defineProps({
  produto: { type: Object, required: true }
})
defineEmits(['remover'])
</script>

<template>
  <div class="card">
    <h3>{{ produto.nome }}</h3>
    <slot />
    <button @click="$emit('remover', produto.id)">Remover</button>
  </div>
</template>
<!-- Uso no componente pai -->
<ProdutoCard
  v-for="p in produtos"
  :key="p.id"
  :produto="p"
  @remover="excluirProduto"
>
  <span class="badge">Em estoque</span>
</ProdutoCard>
Convenção: defina a prop em camelCase (categoriaId) — entre componentes .vue isso funciona direto em :categoria-id ou :categoriaId, o Vue aceita as duas formas no template.
10Ciclo de vida e composables

Hooks como onMounted/onUnmounted rodam em pontos específicos da vida do componente. Um composable é só uma function que agrupa lógica reativa reutilizável — o equivalente Vue de extrair um trait ou um service no Laravel.

import { onMounted, onUnmounted } from 'vue'

onMounted(() => console.log('componente montado'))
onUnmounted(() => console.log('componente desmontado'))
// resources/js/composables/useMoney.js
export function useMoney() {
  function paraReais(valorInteiro) {
    return (valorInteiro / 10000).toLocaleString('pt-BR', {
      style: 'currency',
      currency: 'BRL',
    })
  }

  function paraInteiro(valorReais) {
    return Math.round(valorReais * 10000)
  }

  return { paraReais, paraInteiro }
}
No seu ERP: esse é o lugar certo pra centralizar a conversão da escala 10000 — todo componente que exibe ou envia valor monetário importa useMoney() em vez de reescrever a divisão/multiplicação em cada tela.
02

A ponte: Inertia + Laravel

Onde o Vue passa a conversar com o seu backend — sem API REST, sem Vue Router.

11O que o Inertia resolve

Numa SPA "tradicional" você precisaria de uma API JSON separada, autenticação via token, e um roteador no front (Vue Router) duplicando as rotas que já existem no Laravel. O Inertia elimina essa duplicação: o controller continua recebendo a requisição normal, validando e autorizando como sempre — só troca o retorno de uma view Blade por um componente Vue. Rota, sessão e middleware continuam 100% Laravel.

// Antes (Blade)
return view('produtos.index', ['produtos' => $produtos]);

// Com Inertia
return Inertia::render('Produtos/Index', ['produtos' => $produtos]);
12Instalando (Breeze Vue ou manual)

O caminho mais rápido é o Breeze com stack Vue: já sai com login/registro prontos, Tailwind configurado e a estrutura de pastas certa.

composer require laravel/breeze --dev
php artisan breeze:install vue
npm install
npm run dev

Instalação manual (se preferir montar peça por peça): composer require inertiajs/inertia-laravel, npm install @inertiajs/vue3 @vitejs/plugin-vue, configurar um app.blade.php raiz com a diretiva @inertia e o resources/js/app.js chamando createInertiaApp.

13Estrutura de pastas
resources/js/
  Pages/
    Produtos/
      Index.vue
      Create.vue
      Edit.vue
    Dashboard.vue
  Layouts/
    AppLayout.vue
  Components/
    ProdutoCard.vue
  composables/
    useMoney.js
  app.js
Pegadinha clássica: o nome do arquivo em Pages/ precisa bater exatamente, letra por letra, com a string passada em Inertia::render('Produtos/Index', ...). No Windows local isso funciona mesmo com case errado; no Apache/Linux de produção, quebra — o sistema de arquivos lá é case-sensitive.
14Controller → Página Vue (props)

O que o controller passa como segundo argumento de Inertia::render() chega como props no componente — sem fetch manual, sem loading state pro dado inicial da tela.

// ProdutoController.php
public function index()
{
    return Inertia::render('Produtos/Index', [
        'produtos' => Produto::query()
            ->select('id', 'nome', 'preco_centavos', 'estoque')
            ->paginate(20),
    ]);
}
<!-- resources/js/Pages/Produtos/Index.vue -->
<script setup>
defineProps({
  produtos: Object,
})
</script>
15Navegação com <Link>

Troque toda <a href> interna por <Link> do Inertia: por baixo dos panos ele intercepta o clique e faz a navegação via requisição assíncrona, sem recarregar a página inteira.

<script setup>
import { Link } from '@inertiajs/vue3'
</script>

<template>
  <Link href="/produtos/criar" class="btn">Novo produto</Link>
  <Link :href="`/produtos/${produto.id}`" method="delete" as="button">
    Excluir
  </Link>
</template>
16useForm(): envio e validação

useForm() cria um objeto reativo com os campos, mais errors, processing e métodos post/put/delete já prontos. Os erros vêm automáticos da resposta 422 do FormRequest do Laravel — não precisa tratar manualmente.

<script setup>
import { useForm } from '@inertiajs/vue3'

const form = useForm({
  nome: '',
  precoReais: 0,
  categoriaId: null,
})

function salvar() {
  form.post('/produtos', {
    onSuccess: () => form.reset(),
  })
}
</script>

<template>
  <form @submit.prevent="salvar">
    <input v-model="form.nome">
    <span v-if="form.errors.nome" class="text-red-600">{{ form.errors.nome }}</span>
    <button :disabled="form.processing">Salvar</button>
  </form>
</template>
Convenção: nomeie os campos do form igual ao FormRequest valida (mesmo nome no rules()) — assim form.errors.nome_do_campo casa automaticamente sem mapeamento manual.
17Dados globais e flash messages

O middleware HandleInertiaRequests define o que fica disponível em toda página, sem precisar passar manualmente em cada controller — ótimo lugar pro usuário logado, tenant atual e mensagens flash.

// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
    return array_merge(parent::share($request), [
        'auth' => [
            'user' => $request->user(),
        ],
        'tenant' => fn () => tenant(),
        'flash' => [
            'success' => fn () => $request->session()->get('success'),
        ],
    ]);
}
<script setup>
import { usePage } from '@inertiajs/vue3'
const page = usePage()
</script>
<template>
  <div v-if="page.props.flash.success" class="alert-success">
    {{ page.props.flash.success }}
  </div>
</template>
No seu ERP: é aqui que o tenant resolvido na sessão (CNPJ/CPF + email) vira disponível em toda tela Vue, sem repetir a resolução em cada controller.
18Layouts persistentes

Sem layout persistente, sidebar e topbar remontariam do zero a cada navegação, perdendo estado (menu aberto, scroll). Com defineOptions({ layout }), o layout continua vivo entre páginas.

<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'

defineOptions({ layout: AppLayout })
</script>
03

Padrões práticos pro ERP

O que você vai usar em quase toda tela real: listas, modais, upload, estado.

19Tabelas com paginação, busca e ordenação (server-side)

Busca e ordenação continuam resolvidas no Laravel via query string — o Vue só dispara a navegação e mantém o input local.

public function index(Request $request)
{
    return Inertia::render('Produtos/Index', [
        'produtos' => Produto::query()
            ->when($request->busca, fn ($q) => $q->where('nome', 'like', "%{$request->busca}%"))
            ->orderBy($request->get('ordenar', 'nome'))
            ->paginate(20)
            ->withQueryString(),
        'filtros' => $request->only(['busca', 'ordenar']),
    ]);
}
import { router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'

const props = defineProps({ filtros: Object })
const busca = ref(props.filtros.busca ?? '')

watch(busca, (valor) => {
  router.get('/produtos', { busca: valor }, {
    preserveState: true,
    replace: true,
  })
})
preserveState mantém o estado local de outros campos do componente entre buscas; replace evita empilhar uma entrada no histórico do navegador a cada letra digitada.
20Modal de criar/editar (CRUD sem sair da página)

Padrão comum: o estado do modal (aberto/fechado, item em edição) fica no componente pai da listagem; o formulário dentro do modal usa useForm normalmente e, no sucesso, fecha o modal — o Inertia já devolve os dados atualizados.

import { ref } from 'vue'

const modalAberto = ref(false)
const produtoEditando = ref(null)

function editar(produto) {
  produtoEditando.value = produto
  modalAberto.value = true
}
21Upload de arquivo

Um campo File dentro do useForm funciona, mas force o envio como multipart/form-data pra não ter surpresa.

const form = useForm({
  nome: '',
  logo: null,
})

function selecionarArquivo(e) {
  form.logo = e.target.files[0]
}

function salvar() {
  form.post('/empresa/logo', {
    forceFormData: true,
  })
}
22Requisições parciais (only, reload, preserveState)

Pra atualizar só um pedaço da página — um contador de vendas do dia, por exemplo — sem rebuscar tudo, incluindo dados pesados que não mudaram.

router.reload({ only: ['produtos'] })

router.get('/dashboard', {}, {
  only: ['vendasHoje'],
  preserveScroll: true,
})
23Componentizando UI com Tailwind

Quando a mesma pilha de classes Tailwind se repete em várias telas (botão, badge de status, input), vira componente — assim uma mudança visual acontece em um lugar só, não em quinze arquivos.

<!-- Components/BaseButton.vue -->
<script setup>
defineProps({
  variant: { type: String, default: 'primary' },
})
</script>

<template>
  <button
    class="px-4 py-2 rounded-md font-medium transition"
    :class="{
      'bg-emerald-600 text-white hover:bg-emerald-700': variant === 'primary',
      'bg-slate-100 text-slate-700 hover:bg-slate-200': variant === 'secondary',
    }"
  >
    <slot />
  </button>
</template>
24Estado local complexo: quando considerar Pinia

Inertia resolve o estado de página. O PDV offline-first é diferente: carrinho e fila de vendas pendentes de sincronização precisam sobreviver a troca de tela — e, se for pra sobreviver a um reload da página também, isso já sai do território do Inertia (vai pra IndexedDB/localStorage do navegador). Pinia entra como o lugar central pra esse estado client-only, no lugar de ficar passando prop de componente em componente.

// stores/carrinho.js
import { defineStore } from 'pinia'

export const useCarrinhoStore = defineStore('carrinho', {
  state: () => ({
    itens: [],
  }),
  getters: {
    totalCentavos: (state) =>
      state.itens.reduce((soma, i) => soma + i.precoCentavos * i.quantidade, 0),
  },
  actions: {
    adicionar(produto) {
      this.itens.push({ ...produto, quantidade: 1 })
    },
  },
})
Regra prática: essa é a exceção, não o padrão do ERP inteiro. Não crie uma store Pinia por tela — use só onde o estado é genuinamente client-side e precisa sobreviver à navegação. O carrinho do PDV é o caso clássico; uma lista de produtos normal não precisa disso.
04

Build e deploy no Apache

O ponto que você marcou como importante: nada de Node rodando em produção.

25Vite: dev vs build de produção

npm run dev sobe um servidor local com hot reload — só existe na sua máquina, nunca em produção. npm run build compila tudo pra arquivos estáticos versionados (hash no nome) dentro de public/build, mais um manifest.json que a diretiva Blade @vite lê pra saber qual arquivo carregar.

// 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(),
  ],
})
26Checklist de deploy no Apache
npm run build
composer install --optimize-autoloader --no-dev
php artisan config:cache
php artisan route:cache
php artisan migrate --force

O Apache não precisa de nenhuma configuração especial pra Vue: só o DocumentRoot apontando pra public/ e o mod_rewrite que o Laravel já usa hoje. A pasta public/build (gerada pelo npm run build) precisa estar presente no servidor — rode o build localmente/CI e suba a pasta, ou rode o npm run build direto no servidor se tiver Node disponível ali só pra esse passo.

Ponto chave: nenhum processo Node fica rodando em produção — diferente de SSR (Server-Side Rendering), que exigiria um processo Node permanente. Aqui é só HTML/CSS/JS estático servido pelo Apache/PHP normal, exatamente como você já roda o Laravel hoje.

Próximos passos: reescrevendo seu ERP

Depois de fechar os 26 tópicos, a ideia é pegar módulos reais do ERP e reescrever com Vue + Inertia. Um roteiro possível:

  • Cadastros (produtos, clientes) — tópicos 7, 9, 16, 19: formulário com v-model, componente de card, useForm e listagem paginada.
  • PDV — tópicos 5, 6, 10, 24: lista reativa de itens, atalhos de teclado, formatação de moeda via composable, carrinho em Pinia.
  • Vendas — tópicos 16, 17, 19, 20: fluxo de criação com validação, tenant/flash compartilhados, listagem com filtro, modal de edição.
  • Catálogo digital / agendamento público — tópicos 9, 15, 23: telas mais simples, boas pra treinar componentização com Tailwind sem a complexidade de auth por trás.

Sugestão: comece pelo módulo de cadastros — é o mais simples de isolar e cobre os tópicos 1 a 20 sem precisar mexer em Pinia ainda.