Laravel + Vue Full-Stack Patterns That Scale
Building a Laravel API with a Vue frontend sounds straightforward until you have three developers, two deployment targets, and a client asking for features every sprint. Without conventions, you get inconsistent endpoints, duplicated validation, and Vue components that fetch data six different ways.
These are the patterns I rely on to keep full-stack projects maintainable.
Separate concerns at the boundary
The API is a contract. Vue should never know about your database schema, and Laravel should never know about Vue component structure.
Vue (presentation) → API Resources (serialization) → Services (business logic) → Models (persistence)
// ❌ Business logic in controller
public function store(Request $request)
{
$user = User::create($request->all());
if ($user->role === 'admin') {
Mail::to($user)->send(new AdminWelcome());
}
Cache::forget('users.count');
return $user;
}
// ✅ Thin controller, service handles logic
public function store(StoreUserRequest $request)
{
$user = $this->userService->create($request->validated());
return new UserResource($user);
}
class UserService
{
public function create(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create($data);
if ($user->role === UserRole::Admin) {
SendAdminWelcomeEmail::dispatch($user);
}
Cache::forget('users.count');
return $user;
});
}
}
Consistent API response shapes
Every endpoint should return predictable JSON:
{
"data": { "id": 1, "name": "Laxman" },
"meta": {}
}
Errors follow Laravel’s validation format:
{
"message": "The given data was invalid.",
"errors": {
"email": ["The email has already been taken."]
}
}
Handle this once in Vue:
// composables/useApiError.ts
export function useApiError() {
const message = ref('')
const fieldErrors = ref<Record<string, string[]>>({})
function capture(error: unknown) {
fieldErrors.value = {}
message.value = 'Something went wrong.'
if (isFetchError(error) && error.statusCode === 422) {
message.value = error.data.message ?? message.value
fieldErrors.value = error.data.errors ?? {}
}
}
return { message, fieldErrors, capture }
}
Route organization
Group API routes by resource and version:
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::apiResource('users', UserController::class);
Route::apiResource('projects', ProjectController::class);
Route::post('projects/{project}/archive', [ProjectController::class, 'archive']);
});
Mirror structure in Vue:
pages/
users/
index.vue
[id].vue
create.vue
projects/
index.vue
[id].vue
composables/
useUsers.ts
useProjects.ts
types/
user.ts
project.ts
TypeScript types from the API
Keep frontend types aligned with API Resources. I generate OpenAPI specs from Laravel routes using my openapi-docs-generator project, then generate TypeScript interfaces.
Manual approach for smaller projects:
// types/user.ts
export interface User {
id: number
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
created_at: string
}
export interface PaginatedResponse<T> {
data: T[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
links: {
first: string
last: string
prev: string | null
next: string | null
}
}
State management split
| State type | Tool | Example |
|---|---|---|
| Global, persistent | Pinia | Auth user, theme, sidebar |
| Feature-scoped | Composables | Form state, modal open/close |
| Server state | Composables + fetch | User list, project details |
| URL state | Route query params | Pagination, filters, search |
Do not put API response data in Pinia unless multiple unrelated components need it simultaneously.
// ✅ Feature composable — state resets on unmount
export function useProject(id: MaybeRefOrGetter<number>) {
const { data: project, loading, refresh } = useFetch<Project>(
() => `/api/v1/projects/${toValue(id)}`,
)
async function archive() {
await $fetch(`/api/v1/projects/${toValue(id)}/archive`, { method: 'POST' })
await refresh()
}
return { project, loading, archive }
}
Real-time updates
For dashboards and notifications, pair Laravel with broadcasting:
// Event
class OrderStatusUpdated implements ShouldBroadcast
{
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('orders.' . $this->order->id)];
}
}
// Vue with Laravel Echo
Echo.private(`orders.${orderId}`)
.listen('OrderStatusUpdated', (event: { order: Order }) => {
order.value = event.order
})
For simpler cases, polling with setInterval or useIntervalFn from VueUse works fine.
Environment and deployment
Keep frontend and backend env vars separate but documented:
# Laravel .env
APP_URL=https://api.myapp.com
FRONTEND_URL=https://app.myapp.com
SANCTUM_STATEFUL_DOMAINS=app.myapp.com
# Nuxt/Vue .env
NUXT_PUBLIC_API_URL=https://api.myapp.com
Deploy API and frontend independently—they scale differently. A Vue SPA on Netlify/Vercel talking to a Laravel API on Forge/Render is a common, effective split.
Testing across the stack
Backend feature tests:
it('creates a user via API', function () {
actingAs($admin = User::factory()->admin()->create())
->postJson('/api/v1/users', [
'name' => 'New User',
'email' => '[email protected]',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'role' => 'editor',
])
->assertCreated()
->assertJsonPath('data.email', '[email protected]');
});
Frontend component tests with mocked API:
vi.mock('@/composables/useFetch', () => ({
useFetch: () => ({
data: ref(mockUser),
loading: ref(false),
error: ref(null),
}),
}))
Team alignment practices
- API-first for new features — agree on endpoints and response shapes before building UI
- Shared naming —
UserResourcefields match TypeScriptUserinterface exactly - PR conventions — backend PRs include example JSON responses; frontend PRs link to the endpoint they consume
- Changelog — document breaking API changes in a
CHANGELOG.mdor versioned routes
The patterns that matter most
If you adopt only five things from this post:
- Form Requests + API Resources on every endpoint
- Service classes for business logic
- Composables for Vue feature logic, Pinia only for global state
- Sanctum cookie auth for first-party SPAs
- Consistent error handling on both sides
These conventions do not add ceremony—they remove the daily friction of full-stack development. The Laravel and Vue ecosystems give you the tools; structure is what makes them scale.