Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

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

1 · Vue islands 2 · Inertia.js 3 · SPA + API Laravel route returns a Blade view Blade HTML page Vue app Vue app Server-rendered pages, interactive widgets. This tutorial site works this way. Controller Inertia::render('Tasks', $props) Pages/Tasks.vue receives props; links fetch the next page as JSON SPA feel, no separate API. Laravel routing, auth, validation. page props (JSON) Vue SPA Vue Router + Pinia Laravel JSON API /api/v1/projects API Resources Sanctum Fully decoupled front end; the same API serves mobile apps.
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 dev starts the Vite server. @vite injects the Vite client, which gives you hot module replacement: edit a .vue file and the component updates in place without losing state.
  • In production, npm run build writes hashed files and a manifest.json to public/build/. @vite reads 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 });
}
  1. GET /sanctum/csrf-cookie sets an XSRF-TOKEN cookie.
  2. Axios reads that cookie and sends it back as the X-XSRF-TOKEN header on every state-changing request. Laravel's CSRF middleware checks it.
  3. POST /login authenticates against the web guard and starts a session.
  4. Requests to auth:sanctum routes 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') and defineAsyncComponent keep 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() or markRaw() 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-visualizer shows 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 not public/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.html for client-side routes, and set SANCTUM_STATEFUL_DOMAINS, SESSION_DOMAIN and CORS (config/cors.php with supports_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 + @vite gives 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 sending X-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.