Vue + Laravel in Production
Vite, Inertia vs API, testing and performance.
This final lesson connects the two tracks. The Task board you've built in Vue gets wired to the Projects & Tasks API from the Laravel track. Along the way it covers the key architectural decisions: how to put Vue and Laravel together, how to authenticate, and how to test, optimise and ship it.
Three ways to combine Vue and Laravel
| Vue islands | Inertia.js | SPA + API | |
|---|---|---|---|
| Routing | Laravel | Laravel | Vue Router |
| Data in | Props from Blade / fetch | Controller props | JSON API |
| Auth | Session | Session | Sanctum (cookie or token) |
| Needs an API? | Optional | No | Yes |
| Best for | Mostly-static sites with interactive parts | Admin panels, SaaS dashboards, one team owning both sides | Multiple clients (web + mobile), separate front-end team |
A sensible default. For a product where one team owns both sides, use Inertia: you get an SPA experience while keeping Laravel's routing, validation and auth, and you skip building and versioning an API. If a mobile app or a third party consumes the data, build a versioned JSON API (with Sanctum) and a standalone SPA.
Vite and the Laravel plugin
Laravel ships with Vite and the official laravel-vite-plugin. Add Vue's plugin and you're ready to go:
npm install vue
npm install --save-dev @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, // full reload when Blade files change
}),
vue({
template: {
transformAssetUrls: {
base: null, // let the Laravel plugin rewrite asset URLs
includeAbsolute: false,
},
},
}),
],
});
In your Blade layout, the @vite directive does the rest:
<!DOCTYPE html>
<html>
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<div id="task-board"></div>
</body>
</html>
- During development,
npm run devstarts the Vite server.@viteinjects the Vite client, which gives you hot module replacement: edit a.vuefile and the component updates in place without losing state. - In production,
npm run buildwrites hashed files and amanifest.jsontopublic/build/.@vitereads the manifest and outputs<script>and<link>tags pointing at the versioned files, so cache-busting is automatic.
Option 1: Vue islands with props from Blade
Mount a Vue app on a specific element and pass server data in via data- attributes. This is the simplest way to add Vue to an existing Laravel site:
{{-- resources/views/projects/show.blade.php --}}
<div id="task-board"
data-project-id="{{ $project->id }}"
data-tasks='@json(\App\Http\Resources\TaskResource::collection($project->tasks))'>
</div>
// resources/js/app.js
import { createApp } from 'vue';
import TaskBoard from './components/TaskBoard.vue';
const el = document.getElementById('task-board');
if (el) {
createApp(TaskBoard, {
projectId: Number(el.dataset.projectId),
initialTasks: JSON.parse(el.dataset.tasks), // resource collection → plain array
}).mount(el);
}
Escaping: use @json(...) (or Js::from(...) inside a <script>), never {!! json_encode(...) !!}. Laravel's helpers escape the output correctly for HTML attributes and script contexts, which prevents XSS through user-supplied task titles.
Option 2: Inertia.js
Inertia replaces Blade views with Vue page components while keeping server-side routing. The controller returns props instead of a view:
// app/Http/Controllers/ProjectController.php
use Inertia\Inertia;
use Inertia\Response;
public function show(Project $project): Response
{
$this->authorize('view', $project);
return Inertia::render('Projects/Show', [
'project' => new ProjectResource($project),
'tasks' => TaskResource::collection($project->tasks()->latest()->get()),
]);
}
<!-- resources/js/Pages/Projects/Show.vue -->
<script setup>
import { useForm, Link } from '@inertiajs/vue3';
const props = defineProps({ project: Object, tasks: Object });
const form = useForm({ title: '', priority: 'medium' });
function addTask() {
form.post(`/projects/${props.project.data.id}/tasks`, {
preserveScroll: true,
onSuccess: () => form.reset('title'),
});
}
</script>
<template>
<h1>{{ project.data.name }}</h1>
<form @submit.prevent="addTask">
<input v-model="form.title">
<p v-if="form.errors.title" class="error">{{ form.errors.title }}</p>
<button :disabled="form.processing">Add</button>
</form>
<Link href="/projects">← All projects</Link>
</template>
On the first visit the server returns full HTML with the page props embedded. After that, <Link> clicks and form.post() send XHR requests with an X-Inertia header. Laravel returns just the JSON page object, and Inertia swaps the page component. Validation errors from a normal Form Request automatically appear in form.errors. You don't build an API, and you don't write auth twice.
Click through the simulated app below and compare what crosses the wire on the first visit with what crosses on later Inertia visits:
Option 3: a standalone SPA with a Sanctum-protected API
For a production SPA you'd design a versioned, authenticated API like the one in the Laravel track's Building APIs lesson. The table and http.js below are illustrative (/api/v1, Sanctum). The live demo further down uses the public, read-only /api/demo endpoints that actually exist in this app.
| Vue action | Request (illustrative) | Laravel |
|---|---|---|
| Load tasks | GET /api/v1/projects/{project}/tasks |
TaskController@index → TaskResource::collection |
| Add task | POST /api/v1/projects/{project}/tasks |
StoreTaskRequest → 201 |
| Toggle task | PATCH /api/v1/tasks/{task} { status } |
UpdateTaskRequest + policy |
| Complete task | POST /api/v1/tasks/{task}/complete |
custom action |
SPA authentication with Sanctum (cookies, not tokens)
For your own first-party SPA on the same top-level domain, Laravel's docs recommend Sanctum's cookie-based SPA authentication, not API tokens. It uses the normal session, so nothing sensitive sits in localStorage where XSS could steal it.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi(); // let first-party SPA requests use session cookies
})
# .env: domains whose requests are treated as "stateful"
SANCTUM_STATEFUL_DOMAINS=app.example.com,localhost:5173
SESSION_DOMAIN=.example.com
The flow in the SPA:
// src/api/http.js
import axios from 'axios';
export const http = axios.create({
baseURL: '/api/v1',
withCredentials: true, // send the session cookie
withXSRFToken: true, // send X-XSRF-TOKEN on cross-subdomain requests too
headers: { Accept: 'application/json' },
});
export async function login(email, password) {
await axios.get('/sanctum/csrf-cookie', { withCredentials: true }); // sets XSRF-TOKEN cookie
await axios.post('/login', { email, password }, { withCredentials: true, withXSRFToken: true });
}
GET /sanctum/csrf-cookiesets anXSRF-TOKENcookie.- Axios reads that cookie and sends it back as the
X-XSRF-TOKENheader on every state-changing request. Laravel's CSRF middleware checks it. POST /loginauthenticates against thewebguard and starts a session.- Requests to
auth:sanctumroutes are now authenticated by the session cookie.
If the session expires, the API returns 401 (unauthenticated) or 419 (CSRF token mismatch). Handle both centrally with an interceptor:
http.interceptors.response.use(null, (error) => {
if ([401, 419].includes(error.response?.status)) {
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } });
}
return Promise.reject(error);
});
Tokens are for third parties. Sanctum personal access tokens ($user->createToken('mobile'), sent as Authorization: Bearer …) are the right choice for mobile apps, CLIs and integrations. Don't use them to authenticate your own SPA.
Handling Laravel validation errors in Vue
A failed Form Request returns 422 with an errors object keyed by field. Map it onto your form (shown with the illustrative http client above; the live demo below does the same with fetch):
const errors = ref({});
async function addTask() {
errors.value = {};
try {
const { data } = await http.post(`/projects/${projectId}/tasks`, form);
tasks.value.unshift(data.data);
} catch (e) {
if (e.response?.status === 422) errors.value = e.response.data.errors;
else throw e;
}
}
Live: a Vue board on a real Laravel API
The demo below isn't simulated. It calls a real, read-only Laravel API running in this app under /api/demo (same origin, no auth, rate-limited). The task list comes from GET /api/demo/tasks, a paginated TaskResource collection with data, links and meta, filtered by priority and status. The form posts to POST /api/demo/tasks/validate, which runs the real StoreTaskRequest rules without saving anything. On failure it returns Laravel's standard 422 payload. Try an empty title, a two-character title, the "urgent" priority, a past due date or "(no project)".
The Laravel side (the actual files in this app)
routes/api.php
<?php
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\QueryCountController;
use App\Http\Controllers\Api\TaskController;
use App\Http\Middleware\AddQueryCountHeader;
use Illuminate\Support\Facades\Route;
// Public, read-only "Projects & Tasks" API used by the tutorials' live demos
Route::prefix('demo')
->name('api.demo.')
->middleware(['throttle:demo', AddQueryCountHeader::class])
->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::get('/tasks', [TaskController::class, 'index'])->name('tasks.index');
Route::post('/tasks/validate', [TaskController::class, 'validateOnly'])->name('tasks.validate');
Route::get('/query-count', QueryCountController::class)->name('query-count');
});
app/Http/Controllers/Api/TaskController.php
<?php
namespace App\Http\Controllers\Api;
use App\Enums\TaskPriority;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
class TaskController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate([
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'status' => ['nullable', Rule::in(['open', 'completed'])],
'project' => ['nullable', 'string', 'exists:projects,slug'],
'search' => ['nullable', 'string', 'max:100'],
]);
$tasks = Task::query()
->with(['project', 'assignee', 'tags'])
->when($filters['priority'] ?? null, fn ($query, string $priority) => $query->priority(TaskPriority::from($priority)))
->when(($filters['status'] ?? null) === 'open', fn ($query) => $query->open())
->when(($filters['status'] ?? null) === 'completed', fn ($query) => $query->completed())
->when($filters['project'] ?? null, fn ($query, string $slug) => $query->whereRelation('project', 'slug', $slug))
->when($filters['search'] ?? null, fn ($query, string $search) => $query->whereLike('title', "%{$search}%"))
->orderBy('id')
->paginate(10)
->withQueryString();
return TaskResource::collection($tasks);
}
/**
* Run the real StoreTaskRequest rules without saving anything.
*/
public function validateOnly(StoreTaskRequest $request): JsonResponse
{
return response()->json([
'valid' => true,
'data' => $request->validated(),
]);
}
}
app/Http/Resources/TaskResource.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'priority' => $this->priority->value,
'priority_label' => $this->priority->label(),
'due_on' => $this->due_on?->toDateString(),
'completed' => $this->isComplete(),
'completed_at' => $this->completed_at?->toIso8601String(),
'assignee' => $this->whenLoaded('assignee', fn () => $this->assignee?->only(['id', 'name'])),
'project' => $this->whenLoaded('project', fn () => $this->project->only(['id', 'name', 'slug'])),
'tags' => TagResource::collection($this->whenLoaded('tags')),
];
}
}
app/Http/Requests/StoreTaskRequest.php
<?php
namespace App\Http\Requests;
use App\Enums\TaskPriority;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskRequest extends FormRequest
{
/**
* The demo endpoint is public and never persists, so anyone may validate.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'project_id' => ['required', 'integer', 'exists:projects,id'],
'title' => ['required', 'string', 'min:3', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'priority' => ['required', Rule::enum(TaskPriority::class)],
'due_on' => ['nullable', 'date', 'after_or_equal:today'],
'tag_ids' => ['array'],
'tag_ids.*' => ['integer', 'exists:tags,id'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'project_id.exists' => 'Pick one of the existing projects.',
'due_on.after_or_equal' => 'The due date cannot be in the past.',
];
}
}
bootstrap/app.php makes every api/* route answer with JSON, so validation failures come back as a 422 JSON body rather than a redirect, even if a client forgets the Accept header:
bootstrap/app.php (excerpt)
->withExceptions(function (Exceptions $exceptions): void {
// API routes always answer with JSON (422 errors, 404s), even without an Accept header
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson()
);
})->create();
The Vue side (the code the demo runs)
public/tutorial-assets/vue/vue-with-laravel.js (excerpt)
async function api(path, options = {}) {
const res = await fetch(`/api/demo${path}`, {
...options,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(options.headers || {}) },
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw { status: res.status, body };
return body;
}
async function loadTasks(_new, _old, onCleanup) {
let stale = false;
if (onCleanup) onCleanup(() => { stale = true; });
const query = new URLSearchParams({ status: status.value });
if (priority.value) query.set('priority', priority.value);
loading.value = true;
loadError.value = null;
write(`GET /api/demo/tasks?${query}`);
try {
const res = await api(`/tasks?${query}`);
if (stale) return;
tasks.value = res.data;
meta.value = res.meta;
write(`← 200 (${res.meta ? res.meta.total : res.data.length} tasks)`);
} catch (e) {
if (stale) return;
loadError.value = e.status ? `The API returned ${e.status}.` : 'Network error: the API is unreachable.';
write(`← ${e.status || 'network error'}`);
} finally {
if (!stale) loading.value = false;
}
}
Vue.watch([priority, status], loadTasks, { immediate: true });
async function validate() {
errors.value = {};
result.value = null;
saving.value = true;
write('POST /api/demo/tasks/validate');
try {
const res = await api('/tasks/validate', {
method: 'POST',
body: JSON.stringify({ ...form, project_id: form.project_id || null, due_on: form.due_on || null }),
});
result.value = `Valid. Laravel would create "${res.data.title}".`;
write('← 200 valid');
} catch (e) {
if (e.status === 422) {
errors.value = e.body.errors || {};
write(`← 422 ${e.body.message}`);
} else {
result.value = e.status ? `Unexpected ${e.status} response.` : 'Network error. Please try again.';
write(`← ${e.status || 'network error'}`);
}
} finally {
saving.value = false;
}
}
Testing the Vue side
Vitest (Vite-native and Jest-compatible) plus Vue Test Utils is the standard combination:
// tests/TaskBoard.spec.js
import { mount, flushPromises } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import TaskBoard from '@/components/TaskBoard.vue';
import { http } from '@/api/http';
vi.mock('@/api/http', () => ({ http: { get: vi.fn(), post: vi.fn() } }));
describe('TaskBoard', () => {
it('renders tasks from the API', async () => {
http.get.mockResolvedValue({ data: { data: [{ id: 1, title: 'Write tests', status: 'todo' }] } });
const wrapper = mount(TaskBoard, { props: { projectId: 7 } });
await flushPromises();
expect(http.get).toHaveBeenCalledWith('/projects/7/tasks');
expect(wrapper.text()).toContain('Write tests');
});
it('shows validation errors from a 422', async () => {
http.get.mockResolvedValue({ data: { data: [] } });
http.post.mockRejectedValue({ response: { status: 422, data: { errors: { title: ['The title field is required.'] } } } });
const wrapper = mount(TaskBoard, { props: { projectId: 7 } });
await wrapper.find('form').trigger('submit');
await flushPromises();
expect(wrapper.text()).toContain('The title field is required.');
});
});
- Laravel feature tests prove the API contract: status codes, JSON shape, authorisation.
- Vitest component tests prove the UI reacts correctly to that contract.
- A few end-to-end tests (Playwright, or Laravel Dusk) cover the critical journeys across both.
Performance checklist
- Lazy-load routes and heavy components.
() => import('./Reports.vue')anddefineAsyncComponentkeep the initial bundle small. - Paginate on the server (
->paginate()) and render long lists with virtual scrolling when needed. - Eager-load in Laravel (
with('assignee')) so the JSON the Vue app needs doesn't cost N+1 queries. - Avoid deep reactivity for large read-only data.
shallowRef()ormarkRaw()stop Vue from proxying thousands of nested objects. - Cache with
<KeepAlive>for tabs the user switches between frequently. - Debounce search inputs and abort stale requests (see the composables lesson).
- Analyse the bundle:
npx vite-bundle-visualizershows what's actually shipped.
Building and deploying
# CI / Docker build stage
composer install --no-dev --optimize-autoloader
npm ci
npm run build # → public/build/manifest.json + hashed assets
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force
- Commit
package-lock.json, but notpublic/build. Build it in CI or in a Docker build stage so the manifest always matches the code. - Serve
public/build/*with long-lived cache headers. The filenames are content-hashed, so a new deploy produces new URLs. - For a separate SPA, configure the web server to fall back to
index.htmlfor client-side routes, and setSANCTUM_STATEFUL_DOMAINS,SESSION_DOMAINand CORS (config/cors.phpwithsupports_credentials => true) for the SPA's domain.
Key takeaways
- There are three integration styles: Vue islands in Blade, Inertia (Laravel routing, Vue pages, no API) and SPA + JSON API. Choose based on who consumes the data and who owns the front end.
laravel-vite-plugin+@vitegives you HMR in dev and hashed, manifest-driven assets in production.- Pass server data to islands with
@json/Js::from. Never echo unescaped JSON. - For a first-party SPA, use Sanctum cookie auth:
/sanctum/csrf-cookie, then a session login, with Axios sendingX-XSRF-TOKEN. Use tokens only for third-party and mobile clients. - Handle 401/419 globally, map 422 errors onto form fields, and ignore stale responses.
- Test both sides: Laravel feature tests for the contract, Vitest + Vue Test Utils for components. Build assets in CI, not in git.