npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

Nuxt Laravel

npm version npm downloads CI License Nuxt

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

  • 🔐 Sanctum SPA (cookie) auth with automatic XSRF-TOKEN handling
  • 🌐 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 422 validation 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-laravel

Requirements

| 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 zod

Note for zod 4 users. @vee-validate/zod (used internally) still declares a zod@^3.24.0 peer, so npm may report ERESOLVE when 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=25

and 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:

  • meta only exists if the backend actually paginates. Model::index()->get() returns a bare collection unless the request carries page or perPage, so hasNextPage, loadMore() and the page controls stay inert. Send a perPage, or have the backend call paginate() 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 a 400-class error rather than silently ignoring them.

[!NOTE] Several operators on one key are ANDed — { price: { $gte: 18, $lte: 30 } } becomes price >= 18 and price <= 30. Inside an $or branch they take the group's boolean and are ORed instead, so use $between for 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 (an ofetch instance) on the Nuxt app, it is used automatically; otherwise the global $fetch is 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 own key`.

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 an Unsupported operator error.

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, appending

The trade-off is that a cursor paginator gives up total and last_page, so:

  • hasNextPage comes from the cursor rather than a page count
  • hasPrevPage, nextPage, prevPage and setPage do not apply
  • meta.total and meta.last_page are 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/products

Example

// 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