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.
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>
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
}
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>
: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>
@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">
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>
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 }
}
useMoney() em vez de reescrever a divisão/multiplicação em cada tela.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
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>
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>
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>
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,
})
})
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 })
},
},
})
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.
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,useForme 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.