@aw-studio/nuxt-laravel
v1.0.0
Published
Sanctum auth, a typed HTTP client, filterable model indexes and Zod-validated forms for consuming a Laravel API from Nuxt 3 or 4.
Maintainers
Readme
Nuxt Laravel
An opinionated Nuxt 3/4 toolkit for consuming a Laravel API: Sanctum (cookie/SPA) authentication, a typed HTTP client, stateful & filterable model indexes, Zod-validated forms with Laravel error mapping, and CRUD helpers — all auto-imported.
It pairs with the Laravel Model Index package on the backend for the filtering/sorting/pagination API.
- Features
- Setup
- Backend contract
- Authentication (Sanctum)
- HTTP Client —
useLaravelApi - Fetching a single model —
useLaravelGet - Model Index —
useLaravelIndex - Forms —
useLaravelForm - CRUD —
useLaravelCrud - Example
- Contribution
Features
- 🔐 Sanctum SPA (cookie) auth with automatic
XSRF-TOKENhandling - 🌐 Typed HTTP client with credentialed requests
- 📑 Stateful, filterable, paginated model indexes — complex filtering, search, sorting, pagination, infinite scroll, and optional URL sync
- ✅ Zod-validated forms that map Laravel
422validation errors back onto fields - ♻️ CRUD helpers that compose index/get/form into a single resource API
- 🧩 SSR-friendly data fetching
Setup
1. Install
npm i @aw-studio/nuxt-laravelRequirements
| Dependency | Supported |
| ---------- | -------------------- |
| Nuxt | 3.17+ and 4.x |
| zod | ^3.24.0 \|\| ^4.0.0 |
| aw-studio/laravel-model-index | 1.x |
The backend package is released as a pair with this one. Because the coupling is an untyped query string, mixing majors is not supported.
zod is a peer dependency: you install it, and your version is the one the
module uses. This matters because you pass your own zod schemas into
useLaravelForm / useLaravelCrud — if the module bundled its own copy you
could end up with two zod instances, where schemas built by one are not
recognised by the other.
npm i zodNote for zod 4 users.
@vee-validate/zod(used internally) still declares azod@^3.24.0peer, so npm may reportERESOLVEwhen you install zod 4 alongside it. zod 4 is tested and works — both majors are covered in CI. Until that peer range is widened upstream, add an override:// package.json "overrides": { "@vee-validate/zod": { "zod": "$zod" } }
2. Configure
Add the module and point it at your Laravel API:
export default defineNuxtConfig({
modules: ['@aw-studio/nuxt-laravel'],
laravel: {
baseUrl: 'https://your-laravel-api.tld', // default: http://localhost:8000
},
})3. Prepare the Laravel backend
Because authentication is cookie/SPA based (Laravel Sanctum), the backend must:
- enable Sanctum's stateful API middleware,
- allow CORS with credentials for your frontend origin, and
- expose
sanctum/csrf-cookie.
See the Laravel Sanctum SPA docs and Laravel Model Index for the index/filter API.
Backend contract
This module and
aw-studio/laravel-model-index
are coupled only by the query string. There is no generated client and no shared
schema, so a mismatch is invisible until runtime.
useLaravelIndex serializes state into exactly this shape:
?filter[price][$lte]=100&filter[name][$containsi]=shirt&sort=-created_at&search=foo&page=2&perPage=25and expects Laravel's standard resource envelope back:
{
"data": [ /* items */ ],
"meta": { "total": 120, "per_page": 25, "current_page": 2, "last_page": 5, "from": 26, "to": 50 },
"links": { "first": "…", "last": "…", "prev": "…", "next": null }
}Two consequences worth knowing:
metaonly exists if the backend actually paginates.Model::index()->get()returns a bare collection unless the request carriespageorperPage, sohasNextPage,loadMore()and the page controls stay inert. Send aperPage, or have the backend callpaginate()explicitly.- Filter and sort fields must be allowlisted server-side. The backend rejects
unknown filter fields, unknown operators, and — once
IndexQueryBuilder::defaultSortable([])is in use — unknown sort fields, with a400-class error rather than silently ignoring them.
[!NOTE] Several operators on one key are ANDed —
{ price: { $gte: 18, $lte: 30 } }becomesprice >= 18 and price <= 30. Inside an$orbranch they take the group's boolean and are ORed instead, so use$betweenfor a range there.
Authentication (Sanctum)
useLaravelSanctum covers the whole SPA cookie flow. This is stateful cookie
auth, not token auth: the browser holds a session cookie, and every request made
through the client sends it along with the X-XSRF-TOKEN header.
const { user, isAuthenticated, login, logout, fetchUser } = useLaravelSanctum<User>()
await login({ email, password }) // primes CSRF, posts, then loads the user
console.log(user.value) // the authenticated user
await logout() // clears the session and the cached user| Returns | Type | |
| --- | --- | --- |
| user | Ref<TUser \| null> | The authenticated user, shared app-wide |
| isAuthenticated | ComputedRef<boolean> | Whether user is set |
| loading | Ref<boolean> | True while fetchUser is in flight |
| login(credentials) | Promise | Primes CSRF, posts, then loads the user |
| logout() | Promise | Clears the session and the cached user |
| fetchUser() | Promise<TUser \| null> | Loads the user, or null for a guest |
| csrf() | Promise | Primes the XSRF cookie by itself |
fetchUser() resolves to null on a 401 or 419 rather than throwing — a
guest is an expected answer, not an error, so asking "is anyone logged in" does
not need a try/catch. Genuine failures still throw.
Restore the session on app start with a plugin or middleware:
export default defineNuxtRouteMiddleware(async () => {
const { isAuthenticated, fetchUser } = useLaravelSanctum()
if (!isAuthenticated.value) {
await fetchUser()
}
if (!isAuthenticated.value) {
return navigateTo('/login')
}
})Multiple guards
Point the composable at different routes and give it its own state key to run more than one guard side by side:
export const useMemberAuth = () =>
useLaravelSanctum<Member>({
key: 'member',
loginEndpoint: '/member/login',
logoutEndpoint: '/member/logout',
userEndpoint: '/api/member',
})HTTP Client — useLaravelApi
The foundation all other composables build on. Each request sends
Accept: application/json, credentials: 'include', and the X-XSRF-TOKEN
header read from the XSRF-TOKEN cookie. URLs are resolved against the
configured baseUrl.
const { get, post, put, patch, destroy, getApiUrl } = useLaravelApi()
await get('/api/products')
await post('/api/products', { title: 'New' })
await put('/api/products/1', { title: 'Updated' })
await patch('/api/products/1', { title: 'Patched' })
await destroy('/api/products/1', {})If you register a custom
$apiFetch(anofetchinstance) on the Nuxt app, it is used automatically; otherwise the global$fetchis used.
Fetching a single model — useLaravelGet
Wraps useAsyncData (SSR-friendly) and unwraps Laravel's { data: T } resource
envelope.
const { data, error, refresh } = await useLaravelGet<User>('/api/user')
// With query params
const { data: product } = await useLaravelGet<Product>('/api/products/1', {
query: { with: 'variants' },
})Model Index — useLaravelIndex
The useLaravelIndex composable provides a stateful, filterable Laravel API
index. Pass the relative endpoint as the first argument — it also serves as the
default state key, so keep it unique (or set key).
[!IMPORTANT] State is stored in
useState(key ?? \index-${endpoint}`), so **every call site using the same endpoint shares one state object**. That is what makes the index work across components without a store — but two independent lists over the same endpoint (say a sidebar preview and a full table) will fight over page and filters. Give one of them its ownkey`.
State is shared per key, which is the point: a page can load() while child
components read items from the same composable without a store. The flip side
is that two unrelated lists over one endpoint will fight over page and filters
— give one of them its own key.
Only the first caller's options seed the state, since the initialiser runs once
per key. Configure the index where you load() it, not in every consumer.
It is typically wrapped in a reusable composable:
import type { LaravelIndexOptions } from '@aw-studio/nuxt-laravel'
import type { Product } from '@/types'
export const useProducts = (options?: LaravelIndexOptions) =>
useLaravelIndex<Product>('/api/products', options)Configuration
Configure the index when initializing:
const { items } = await useProducts({
perPage: 6,
syncUrl: true,
sort: 'title',
ssr: true,
})| Option | Type | Default | Description |
| ------------ | ------------------------- | ----------- | --------------------------------------------------------------------- |
| perPage | number | — | Items per page. Preserved across sort/search/filter changes. |
| syncUrl | boolean | true | Mirror state to the query string and hydrate from it on init. |
| sort | string | — | Initial sort. |
| key | string | endpoint | Override the shared state key. |
| historyMode | 'replace' \| 'push' | 'replace' | Whether URL sync adds browser history entries. |
| paginationMode | 'page' \| 'cursor' | 'page' | Which backend paginator to pair with (see below). |
| urlFilters | string[] | undefined | Allowlist of query keys to hydrate into the filter (see below). |
| onError | (error) => void | — | Called when a request fails. |
| onSuccess | (response) => void | — | Called on a successful response. |
You can also update the configuration at runtime:
const { setConfig, setPerPage, setSyncUrl } = await useProducts()
setConfig({ perPage: 6, syncUrl: true })
setPerPage(10)
setSyncUrl(false)SSR
Await load() in setup. The request runs on the server and the state is
transferred to the client through Nuxt's payload, so the list renders with data
and does not refetch on hydration:
const { items, load } = useProducts({ perPage: 6 })
await load()State is keyed per endpoint (see below), which is what makes that transfer work.
Data Fetching
const { load, loadAll, loadMore, nextPage, prevPage, setPage } = await useProducts()
await load() // load the first (or URL) page
await load(6) // load a specific page
await nextPage() // next page
await prevPage() // previous page
await loadAll() // load every item, unpaginated
// a later sort/search/filter change reloads page 1
// paginated, rather than re-fetching everything
await loadMore() // append the next page (infinite scrolling)Searching
const { setSearch } = await useProducts()
setSearch('Foo')Filtering
Filters are serialized into URL-encoded query params for the backend.
const { setFilter } = await useProducts()
// Basic filter
setFilter({ size: 'M', color: 'blue' })
// Operators
setFilter({ price: { $lt: 100 } })
// Complex conditions
setFilter({
$and: [
{ $or: [{ title: { $contains: 'John' } }, { title: { $contains: 'Paul' } }] },
{ price: { $lt: 100 } },
{ size: { $in: ['S', 'M'] } },
],
})Available Operators
| Operator | Description |
| --------------- | ---------------------------- |
| $eq | Equal to |
| $eqi | Equal to (case-insensitive) |
| $ne | Not equal to |
| $nei | Not equal (case-insensitive) |
| $lt | Less than |
| $lte | Less than or equal to |
| $gt | Greater than |
| $gte | Greater than or equal to |
| $in | In array |
| $notIn | Not in array |
| $contains | Contains |
| $notContains | Does not contain |
| $containsi | Contains (case-insensitive) |
| $notContainsi | Not contains (case-insens.) |
| $between | Between two values |
| $startsWith | Starts with |
| $endsWith | Ends with |
| $null | Is null |
| $notNull | Is not null |
Combine groups with $and / $or.
This table must stay in sync with the operators implemented in
aw-studio/laravel-model-index. The two packages are coupled only by the query string, so an operator listed here but missing there is rejected at runtime with anUnsupported operatorerror.
The i-suffixed operators compare case-insensitively. Note that MySQL's default
collation already ignores case, so on MySQL they behave identically to their
plain counterparts; the difference shows up on PostgreSQL and SQLite.
URL sync & urlFilters
When syncUrl is enabled, the index mirrors page, sort, search, and filters
into the query string and hydrates them back on init — so a shared/bookmarked
URL restores the list state.
Writes use router.replace by default, so stepping through pages or adjusting
filters does not fill the history stack — the back button leaves the list rather
than walking back through every intermediate state. Pass
historyMode: 'push' if each page should be its own history entry.
By default, every unknown query param is hydrated as a filter. This can leak
state between pages: e.g. one index sets ?is_active=1, the user navigates to an
unrelated index, and that index hydrates is_active and sends it to a backend
whose filter allowlist rejects the field.
Use urlFilters to restrict which keys may be hydrated from the URL. When set,
only the listed keys are read back; when omitted, behavior is unchanged.
export const useVehicleGroups = (options?: LaravelIndexOptions) =>
useLaravelIndex<VehicleGroup>('/api/vehicle-groups', {
syncUrl: true,
// A stale `?is_active=1` left by another index is ignored instead of
// being sent to the backend.
urlFilters: ['name'],
...options,
})Cursor pagination
For large lists, pair with the backend's cursorPaginate() instead. Cursor
pagination is keyset-based: no COUNT over the full result set, and no OFFSET
degradation on deep pages.
const { items, load, loadMore, hasNextPage } = useLaravelIndex<Product>(
'/api/products',
{ paginationMode: 'cursor', perPage: 25 }
)
await load() // first page
loadMore() // follows next_cursor, appendingThe trade-off is that a cursor paginator gives up total and last_page, so:
hasNextPagecomes from the cursor rather than a page counthasPrevPage,nextPage,prevPageandsetPagedo not applymeta.totalandmeta.last_pageare absent, so a numbered pager cannot be rendered — this mode suits infinite scroll
load() always restarts from the top; loadMore() is what carries the cursor
forward. Changing sort, search or a filter resets the cursor, since a stale one
points into the previous result set.
Sorting
const { setSort } = await useProducts()
setSort('title') // ascending
setSort('-title') // descending
setSort('title:desc') // descending (alternate syntax, backend dependent)Meta / Loading State
Pagination metadata, loading flag, and error are reactive:
<template>
<div>
<Spinner v-if="loading" />
<p v-if="error">{{ error.message }}</p>
Page: {{ meta?.current_page }} / {{ meta?.last_page }}
</div>
</template>
<script setup lang="ts">
const { meta, loading, error } = await useProducts()
</script>Mutating State
Patch a single item in place (e.g. after an optimistic update) or reset the index:
const { mutateStateItem, reset } = await useProducts()
mutateStateItem(productId, { title: 'Renamed' })
reset()Forms — useLaravelForm
A thin wrapper around vee-validate with a
Zod schema. It exposes per-field bindings and a submit()
that posts to Laravel and maps 422 validation errors ({ errors: { field:
[...] } }) back onto the corresponding fields.
<template>
<form @submit.prevent="form.submit">
<input v-model="form.fields.email" v-bind="form.fieldProps.email" />
<span v-if="form.fieldMeta.email.touched">{{ form.errors.value.email }}</span>
<input v-model="form.fields.password" type="password" />
<button :disabled="form.isSubmitting.value">Log in</button>
</form>
</template>
<script setup lang="ts">
import * as z from 'zod'
type LoginForm = { email: string; password: string }
const form = useLaravelForm<LoginForm>({
submitUrl: '/login',
method: 'POST', // POST | PUT | PATCH | DELETE
initialValues: { email: '', password: '' },
schema: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
onSubmitSuccess: () => navigateTo('/dashboard'),
onSubmitError: error => console.error(error),
})
</script>form also exposes everything returned by vee-validate's useForm (values,
errors, isSubmitting, setFieldError, …).
CRUD — useLaravelCrud
Composes index, get and form helpers into one resource API. Every operation resolves to a URL and a verb:
| Operation | Verb | URL |
| --- | --- | --- |
| index() | GET | {urlPrefix}/{endpoint} |
| show(id) | GET | {urlPrefix}/{endpoint}/{id} |
| create() | POST | {urlPrefix}/{endpoint} |
| update(model) | PUT | {urlPrefix}/{endpoint}/{id} |
| destroy(id) | DELETE | {urlPrefix}/{endpoint}/{id} |
import { z } from 'zod'
export const useProducts = () =>
useLaravelCrud<Product, ProductForm>({
endpoint: '/api/products',
schema: z.object({ name: z.string().min(1) }),
initialValues: { name: '' },
})<script setup lang="ts">
const { index, create, update, destroy } = useProducts()
const { items, load } = index({ perPage: 25 })
await load()
const form = create()
await form.submit()
</script>Per-operation configuration
Any operation can override the endpoint, schema, initial values or callbacks.
An endpoint containing :id has it substituted:
useLaravelCrud<Product, ProductForm>({
endpoint: '/api/products',
schema,
initialValues,
// applied to every operation unless overridden per call
urlPrefix: '/admin',
create: { onSuccess: () => refresh() },
update: { schema: updateSchema },
destroy: { endpoint: '/api/products/:id/archive' },
index: { options: { perPage: 25, syncUrl: true } },
show: { options: { query: { with: 'variants' } } },
// merged into the returned object
methods: {
publish: (id: number) => useLaravelApi().post(`/api/products/${id}/publish`, {}),
},
})destroy deliberately does not inherit the resource schema — a DELETE
sends no meaningful body, and validating an empty payload against the create
schema would reject every delete. Give it its own schema if you do need one.
A urlPrefix can also be passed per call, which is useful when the same
resource is reachable under more than one prefix:
const { create } = useProducts()
create({ urlPrefix: '/admin' }) // POST /admin/api/productsExample
// composables/useProducts.ts
import type { LaravelIndexOptions } from '@aw-studio/nuxt-laravel'
import type { Product } from '@/types'
export const useProducts = (options?: LaravelIndexOptions) =>
useLaravelIndex<Product>('/api/products', options)<template>
<div>
<input v-model="searchTerm" type="search" placeholder="Search" />
<ProductCard v-for="item in items" :key="item.id" :product="item" />
<button :disabled="!hasPrevPage" @click="prevPage">Previous</button>
<button :disabled="!hasNextPage" @click="nextPage">Next</button>
<div>Page: {{ meta?.current_page }} / {{ meta?.last_page }}</div>
</div>
</template>
<script setup lang="ts">
const { items, meta, hasNextPage, hasPrevPage, nextPage, prevPage, setSearch, load } =
await useProducts({ perPage: 6, syncUrl: true })
const searchTerm = ref('')
watch(searchTerm, () => setSearch(searchTerm.value))
onMounted(load)
</script>Contribution
# Install dependencies
npm install
# Generate type stubs
npm run dev:prepare
# Develop with the playground
npm run dev
# Build the playground
npm run dev:build
# Run ESLint
npm run lint
# Run Vitest
npm run test
npm run test:watch
# Release a new version
npm run release