Back to Blog
·18 min read

Laravel + Vue Full-Stack Patterns That Scale

LaravelVue.jsPHP

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 namingUserResource fields match TypeScript User interface 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.md or versioned routes

The patterns that matter most

If you adopt only five things from this post:

  1. Form Requests + API Resources on every endpoint
  2. Service classes for business logic
  3. Composables for Vue feature logic, Pinia only for global state
  4. Sanctum cookie auth for first-party SPAs
  5. 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.