TypeScript & Vue 3: End-to-End Type Safety with Laravel
Learn how to achieve full-stack type safety by connecting Laravel APIs to Vue 3 frontends using TypeScript, generated types, and modern tooling.
TypeScript is no longer optional for professional web development. According to the Stack Overflow Developer Survey 2025, 67.1% of professional developers now use TypeScript, with an 84.1% satisfaction rate. GitHub Octoverse 2025 revealed that TypeScript surpassed both Python and JavaScript to become the most used language by monthly contributors. For full-stack developers working with Laravel backends and Vue 3 frontends, the question is no longer whether to adopt TypeScript, but how to maximize its benefits across the entire stack.
This guide walks you through achieving end-to-end type safety between Laravel and Vue 3. We will cover generating TypeScript types from your Laravel API, building type-safe API clients, and handling validation errors with proper typing. By the end, you will have a development workflow where your IDE catches type mismatches before they reach production.
Why End-to-End Type Safety Matters
Traditional full-stack development involves a disconnect between backend and frontend types. You define a User model in Laravel with specific attributes, then manually create a matching TypeScript interface in Vue. When the backend changes, the frontend types become stale, leading to runtime errors that TypeScript was supposed to prevent. This challenge becomes even more critical in micro-frontend architectures where multiple teams work on different parts of the application.
End-to-end type safety eliminates this gap. Changes to your Laravel Data objects automatically propagate to TypeScript definitions. Your IDE immediately flags incompatibilities. Refactoring becomes safer because the type system spans both codebases.
The business impact is significant:
- Reduced bugs: A 2025 academic study found that 94% of LLM-generated compilation errors were type-check failures, demonstrating how typed systems catch errors earlier in development
- Faster development: Autocomplete and type hints eliminate guesswork about API response shapes
- Safer refactoring: Rename a field in Laravel, and TypeScript immediately highlights every affected component
- Better documentation: Types serve as living documentation that stays in sync with your code
Setting Up Vue 3 with TypeScript
Modern Vue 3 projects use Vite for fast development builds and native TypeScript support. If you are starting a new project, use the official scaffolding:
npm create vue@latest my-app
Select TypeScript when prompted. For existing projects, ensure your tsconfig.json includes Vue-specific settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Typing Vue Components with Composition API
Vue 3.3 and later versions provide excellent TypeScript support through the Composition API and <script setup> syntax. Here is a properly typed component:
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { User } from '@/types/models'
import { useUserApi } from '@/composables/useUserApi'
// Props with TypeScript
interface Props {
userId: number
showDetails?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showDetails: false
})
// Emits with TypeScript
interface Emits {
(e: 'update', user: User): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// Reactive state with type inference
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Composable for API calls
const { fetchUser, updateUser } = useUserApi()
// Computed properties maintain type inference
const displayName = computed(() => {
if (!user.value) return ''
return `${user.value.firstName} ${user.value.lastName}`
})
// Async function with proper error handling
async function loadUser(): Promise<void> {
loading.value = true
error.value = null
try {
user.value = await fetchUser(props.userId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load user'
} finally {
loading.value = false
}
}
async function handleUpdate(data: Partial<User>): Promise<void> {
if (!user.value) return
const updated = await updateUser(user.value.id, data)
user.value = updated
emit('update', updated)
}
onMounted(loadUser)
</script>
<template>
<div class="user-card">
<div v-if="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="user">
<h2>{{ displayName }}</h2>
<p v-if="showDetails">{{ user.email }}</p>
<button @click="handleUpdate({ status: 'active' })">
Activate
</button>
</div>
</div>
</template>
The lang="ts" attribute tells Vue to process the script block as TypeScript. Props, emits, and reactive references all benefit from type checking and IDE autocomplete.
Generating TypeScript Types from Laravel
The key to end-to-end type safety is automatically generating TypeScript definitions from your Laravel codebase. Two primary approaches exist: using Spatie's Laravel Data package or the new Laravel Wayfinder tool.
Option 1: Spatie Laravel Data with TypeScript Transformer
Laravel Data is a mature package that lets you define data structures once and use them for DTOs, form request validation, API resources, and TypeScript generation. Install the required packages:
composer require spatie/laravel-data
composer require spatie/laravel-typescript-transformer
php artisan vendor:publish --tag=typescript-transformer-config
Create a Data object that represents your API response:
<?php
namespace App\Data;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
use Carbon\Carbon;
#[TypeScript]
class UserData extends Data
{
public function __construct(
public int $id,
public string $firstName,
public string $lastName,
public string $email,
public ?string $avatarUrl,
public string $status,
public Carbon $createdAt,
public ?Carbon $emailVerifiedAt,
) {}
public static function fromModel(User $user): self
{
return new self(
id: $user->id,
firstName: $user->first_name,
lastName: $user->last_name,
email: $user->email,
avatarUrl: $user->avatar_url,
status: $user->status,
createdAt: $user->created_at,
emailVerifiedAt: $user->email_verified_at,
);
}
}
Configure the TypeScript transformer in config/typescript-transformer.php:
<?php
return [
'collectors' => [
Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptCollector::class,
],
'transformers' => [
Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptTransformer::class,
Spatie\TypeScriptTransformer\Transformers\EnumTransformer::class,
],
'output_file' => resource_path('js/types/generated.d.ts'),
'writer' => Spatie\TypeScriptTransformer\Writers\TypeDefinitionWriter::class,
];
Generate TypeScript definitions:
php artisan typescript:transform
This produces a generated.d.ts file:
// This file is auto-generated. Do not edit manually.
declare namespace App.Data {
export interface UserData {
id: number;
firstName: string;
lastName: string;
email: string;
avatarUrl: string | null;
status: string;
createdAt: string;
emailVerifiedAt: string | null;
}
}
Option 2: Laravel Wayfinder (Official Tool)
Laravel Wayfinder, announced at Laracon US 2025 and released in beta on April 2, 2025, provides deeper integration. It generates TypeScript functions for your routes and controllers, not just types:
composer require laravel/wayfinder
npm install @laravel/wayfinder
Wayfinder analyzes your Laravel application and generates TypeScript equivalents for routes, validation rules, models, enums, broadcast events, and environment variables. A controller like this:
<?php
namespace App\Http\Controllers\Api;
use App\Data\UserData;
use App\Http\Requests\UpdateUserRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
class UserController extends Controller
{
public function show(User $user): UserData
{
return UserData::from($user);
}
public function update(UpdateUserRequest $request, User $user): UserData
{
$user->update($request->validated());
return UserData::from($user->fresh());
}
}
Generates importable TypeScript functions:
import { users } from '@/wayfinder'
// Fully typed - IDE knows the return type is UserData
const user = await users.show({ user: 1 })
// TypeScript enforces the request body matches UpdateUserRequest rules
await users.update({ user: 1 }, {
firstName: 'John',
lastName: 'Doe'
})
Wayfinder also generates types from your Form Request validation rules. A Form Request like:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUserRequest extends FormRequest
{
public function rules(): array
{
return [
'firstName' => ['sometimes', 'string', 'max:255'],
'lastName' => ['sometimes', 'string', 'max:255'],
'email' => ['sometimes', 'email', 'unique:users,email,' . $this->user->id],
'status' => ['sometimes', 'in:active,inactive,pending'],
];
}
}
Produces TypeScript types that match your validation:
interface UpdateUserRequest {
firstName?: string;
lastName?: string;
email?: string;
status?: 'active' | 'inactive' | 'pending';
}
Building a Type-Safe API Client
With generated types in place, you need an API client that enforces them. Here is a robust pattern using Axios that provides type safety, error handling, and request configuration. For security considerations around API clients, see my guide on contact form security best practices:
// src/lib/api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiError, ValidationErrors } from '@/types/api'
// Custom error class for API errors
export class ApiRequestError extends Error {
constructor(
message: string,
public status: number,
public errors?: ValidationErrors
) {
super(message)
this.name = 'ApiRequestError'
}
}
// Create configured Axios instance
function createApiClient(): AxiosInstance {
const client = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
withCredentials: true, // Required for Sanctum cookie authentication
})
// Request interceptor for auth tokens
client.interceptors.request.use((config) => {
const token = localStorage.getItem('api_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor for error handling
client.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response) {
const { status, data } = error.response
// Handle validation errors (422)
if (status === 422) {
throw new ApiRequestError(
data.message || 'Validation failed',
status,
data.errors
)
}
// Handle authentication errors (401)
if (status === 401) {
// Redirect to login or refresh token
window.location.href = '/login'
}
throw new ApiRequestError(
data.message || 'An error occurred',
status
)
}
throw error
}
)
return client
}
const apiClient = createApiClient()
// Type-safe request wrapper
export async function apiRequest<T>(
config: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await apiClient.request(config)
return response.data
}
// Convenience methods with full type inference
export const api = {
get: <T>(url: string, config?: AxiosRequestConfig) =>
apiRequest<T>({ ...config, method: 'GET', url }),
post: <T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) =>
apiRequest<T>({ ...config, method: 'POST', url, data }),
put: <T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) =>
apiRequest<T>({ ...config, method: 'PUT', url, data }),
patch: <T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) =>
apiRequest<T>({ ...config, method: 'PATCH', url, data }),
delete: <T>(url: string, config?: AxiosRequestConfig) =>
apiRequest<T>({ ...config, method: 'DELETE', url }),
}
Creating Type-Safe Service Modules
Organize API calls into domain-specific modules that leverage your generated types:
// src/services/userService.ts
import { api } from '@/lib/api/client'
import type { UserData } from '@/types/generated'
// Request types (generated or manual)
interface CreateUserRequest {
firstName: string
lastName: string
email: string
password: string
}
interface UpdateUserRequest {
firstName?: string
lastName?: string
email?: string
status?: 'active' | 'inactive' | 'pending'
}
// Paginated response wrapper
interface PaginatedResponse<T> {
data: T[]
meta: {
currentPage: number
lastPage: number
perPage: number
total: number
}
links: {
first: string
last: string
prev: string | null
next: string | null
}
}
interface UserFilters {
status?: string
search?: string
page?: number
perPage?: number
}
export const userService = {
async list(filters?: UserFilters): Promise<PaginatedResponse<UserData>> {
return api.get('/v1/users', { params: filters })
},
async get(id: number): Promise<UserData> {
return api.get(`/v1/users/${id}`)
},
async create(data: CreateUserRequest): Promise<UserData> {
return api.post('/v1/users', data)
},
async update(id: number, data: UpdateUserRequest): Promise<UserData> {
return api.patch(`/v1/users/${id}`, data)
},
async delete(id: number): Promise<void> {
return api.delete(`/v1/users/${id}`)
},
}
Vue Composable for API State Management
Wrap your service in a composable that handles loading states, errors, and caching:
// src/composables/useUserApi.ts
import { ref, shallowRef } from 'vue'
import { userService } from '@/services/userService'
import { ApiRequestError } from '@/lib/api/client'
import type { UserData } from '@/types/generated'
import type { ValidationErrors } from '@/types/api'
export function useUserApi() {
const user = shallowRef<UserData | null>(null)
const users = shallowRef<UserData[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const validationErrors = ref<ValidationErrors | null>(null)
function clearErrors() {
error.value = null
validationErrors.value = null
}
async function fetchUser(id: number): Promise<UserData | null> {
loading.value = true
clearErrors()
try {
user.value = await userService.get(id)
return user.value
} catch (e) {
if (e instanceof ApiRequestError) {
error.value = e.message
validationErrors.value = e.errors ?? null
} else {
error.value = 'An unexpected error occurred'
}
return null
} finally {
loading.value = false
}
}
async function fetchUsers(filters?: Parameters<typeof userService.list>[0]) {
loading.value = true
clearErrors()
try {
const response = await userService.list(filters)
users.value = response.data
return response
} catch (e) {
if (e instanceof ApiRequestError) {
error.value = e.message
} else {
error.value = 'An unexpected error occurred'
}
return null
} finally {
loading.value = false
}
}
async function updateUser(
id: number,
data: Parameters<typeof userService.update>[1]
): Promise<UserData | null> {
loading.value = true
clearErrors()
try {
const updated = await userService.update(id, data)
user.value = updated
return updated
} catch (e) {
if (e instanceof ApiRequestError) {
error.value = e.message
validationErrors.value = e.errors ?? null
} else {
error.value = 'An unexpected error occurred'
}
return null
} finally {
loading.value = false
}
}
return {
// State
user,
users,
loading,
error,
validationErrors,
// Actions
fetchUser,
fetchUsers,
updateUser,
clearErrors,
}
}
Handling Laravel Validation Errors with Proper Typing
Laravel returns validation errors in a specific format. Properly typing these errors enables intelligent form handling in Vue:
// src/types/api.ts
// Laravel's standard validation error response (422)
export interface ValidationErrorResponse {
message: string
errors: ValidationErrors
}
// Field-specific errors
export interface ValidationErrors {
[field: string]: string[]
}
// Generic API error response
export interface ApiErrorResponse {
error: {
code: string
message: string
details?: ValidationErrors
}
}
// Type guard for validation errors
export function isValidationError(error: unknown): error is ApiRequestError {
return error instanceof ApiRequestError && error.status === 422
}
Form Component with Validation Error Display
Here is a complete form component that displays Laravel validation errors alongside form fields:
<script setup lang="ts">
import { reactive, computed } from 'vue'
import { useUserApi } from '@/composables/useUserApi'
import type { ValidationErrors } from '@/types/api'
interface FormData {
firstName: string
lastName: string
email: string
status: 'active' | 'inactive' | 'pending'
}
const props = defineProps<{
userId: number
}>()
const emit = defineEmits<{
(e: 'saved'): void
}>()
const { updateUser, loading, validationErrors, clearErrors } = useUserApi()
const form = reactive<FormData>({
firstName: '',
lastName: '',
email: '',
status: 'active',
})
// Helper to get field-specific errors
function getFieldErrors(field: keyof FormData): string[] {
return validationErrors.value?.[field] ?? []
}
// Check if a field has errors
function hasError(field: keyof FormData): boolean {
return getFieldErrors(field).length > 0
}
// Clear field error when user starts typing
function onInput(field: keyof FormData) {
if (validationErrors.value?.[field]) {
delete validationErrors.value[field]
}
}
async function handleSubmit() {
clearErrors()
const result = await updateUser(props.userId, {
firstName: form.firstName || undefined,
lastName: form.lastName || undefined,
email: form.email || undefined,
status: form.status,
})
if (result) {
emit('saved')
}
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label for="firstName" class="block text-sm font-medium">
First Name
</label>
<input
id="firstName"
v-model="form.firstName"
type="text"
:class="{ 'border-red-500': hasError('firstName') }"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
@input="onInput('firstName')"
/>
<p
v-for="error in getFieldErrors('firstName')"
:key="error"
class="mt-1 text-sm text-red-600"
>
{{ error }}
</p>
</div>
<div>
<label for="lastName" class="block text-sm font-medium">
Last Name
</label>
<input
id="lastName"
v-model="form.lastName"
type="text"
:class="{ 'border-red-500': hasError('lastName') }"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
@input="onInput('lastName')"
/>
<p
v-for="error in getFieldErrors('lastName')"
:key="error"
class="mt-1 text-sm text-red-600"
>
{{ error }}
</p>
</div>
<div>
<label for="email" class="block text-sm font-medium">
Email
</label>
<input
id="email"
v-model="form.email"
type="email"
:class="{ 'border-red-500': hasError('email') }"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
@input="onInput('email')"
/>
<p
v-for="error in getFieldErrors('email')"
:key="error"
class="mt-1 text-sm text-red-600"
>
{{ error }}
</p>
</div>
<div>
<label for="status" class="block text-sm font-medium">
Status
</label>
<select
id="status"
v-model="form.status"
:class="{ 'border-red-500': hasError('status') }"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="pending">Pending</option>
</select>
<p
v-for="error in getFieldErrors('status')"
:key="error"
class="mt-1 text-sm text-red-600"
>
{{ error }}
</p>
</div>
<button
type="submit"
:disabled="loading"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{{ loading ? 'Saving...' : 'Save Changes' }}
</button>
</form>
</template>
Automating Type Generation in Development
Integrate type generation into your development workflow so types stay synchronized with your Laravel codebase.
NPM Scripts for Type Generation
Add scripts to your package.json:
{
"scripts": {
"dev": "vite",
"build": "npm run types && vue-tsc --noEmit && vite build",
"types": "php artisan typescript:transform",
"types:watch": "nodemon --watch 'app/Data/**/*.php' --ext php --exec 'npm run types'"
}
}
Git Hooks with Husky
Ensure types are regenerated before commits:
npm install husky --save-dev
npx husky init
Create .husky/pre-commit:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Regenerate TypeScript types
npm run types
# Add generated types to commit
git add resources/js/types/generated.d.ts
CI/CD Integration
Add type checking to your GitHub Actions workflow:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
run: composer install --no-progress
- name: Install Node dependencies
run: npm ci
- name: Generate TypeScript types
run: php artisan typescript:transform
- name: Type check
run: npx vue-tsc --noEmit
- name: Build
run: npm run build
Benefits of End-to-End Type Safety
After implementing this architecture across multiple Laravel and Vue projects, the benefits become clear:
Compile-time error detection: TypeScript catches mismatches between your API contract and frontend usage before deployment. If you rename a field from firstName to first_name in Laravel Data, your Vue components immediately show errors.
Improved developer experience: IDE autocomplete works across the entire stack. When you type user., your editor suggests firstName, lastName, email - the exact fields your Laravel API returns.
Safer refactoring: Changing API response shapes is no longer scary. TypeScript traces every usage and flags incompatibilities. You can refactor with confidence.
Living documentation: Generated types serve as documentation that never goes stale. New team members understand API shapes by reading type definitions.
Reduced testing burden: Type safety eliminates an entire category of bugs. While you still need integration tests, you no longer need tests that verify "does this field exist in the response."
Key Takeaways
Achieving end-to-end type safety between Laravel and Vue 3 requires investment in tooling and workflow, but the payoff is substantial. The key principles:
- Generate types, do not write them manually: Use Laravel Data with TypeScript Transformer or Laravel Wayfinder to keep types synchronized automatically
- Type your API client: Build a strongly-typed HTTP client that enforces request and response types
- Handle validation errors properly: Laravel's 422 responses have a specific structure; type them correctly for intelligent form handling
- Automate type generation: Integrate type generation into your development workflow and CI/CD pipeline
- Use Vue composables for state management: Wrap API calls in composables that handle loading states and errors consistently
With TypeScript adoption now at 67% among professional developers and growing, these patterns position your codebase for long-term maintainability. The initial setup effort pays dividends every time you refactor, onboard a new developer, or catch a bug before it reaches production.
Building a full-stack application with Laravel and Vue? I specialize in Laravel API development and modern frontend architectures. My API Development service includes type-safe API design, frontend integration, and CI/CD setup. Schedule a free consultation to discuss your project requirements.
Related Reading:
- Laravel API Development Best Practices: A Complete Guide
- Building Micro-Frontends with Vue and Laravel as a Backend
- Contact Form Security: Best Practices for Spam Protection
- Implementing Dark Mode with Tailwind CSS
External Resources:
- Vue TypeScript with Composition API - Official Vue.js Documentation
- Laravel Data Documentation - Spatie Package Documentation
- Laravel Wayfinder - Official Laravel Type Safety Tool
- TypeScript Transformer - Spatie TypeScript Generation

Richard Joseph Porter
Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.
Need Help Upgrading Your Laravel App?
I specialize in modernizing legacy Laravel applications with zero downtime. Get a free codebase audit and upgrade roadmap.
Related Articles
Building Micro-Frontends with Vue and Laravel as a Backend
Master micro-frontends with Vue.js and Laravel backend. Complete guide to module federation, deployment strategies, and cross-MFE communication.
Laravel API Development Best Practices Guide
Master REST API development with Laravel. Learn authentication, versioning, error handling, rate limiting, and performance optimization from 14 years of PHP experience.
AI-Assisted Code Review: Claude for Laravel Quality
Use Claude for Laravel code reviews. Catch security flaws, N+1 queries, and anti-patterns with proven prompts and CI/CD integration. Boost code quality.