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

@diephp/vue-model

v1.0.5

Published

Small Vue 3 fetch-based API model helper for forms, collections, and validation errors.

Readme

@diephp/vue-model

Small Vue 3 API model helper around native fetch.

The package is intended for ordinary API-backed Vue forms, tables, selectors, and uploads. It keeps the API surface compact:

  • Vue 3 reactivity;
  • native fetch;
  • stable model.data;
  • request helpers;
  • validation errors;
  • dirty/original snapshot;
  • optional auto-send watcher;
  • FormData upload helper;
  • global processing counter.

Runtime dependency: Vue 3 only.

Installation

npm install @diephp/vue-model

Vue is a peer dependency:

npm install vue

Build

Build the package into dist:

npm run build

Run only JavaScript bundling:

npm run build:js

Run only type declaration generation:

npm run build:types

When the package is packed or published, prepack runs the build automatically:

npm pack

Quick Start

Create a configured API instance once:

// api.ts
import { createApix } from '@diephp/vue-model'

const apix = createApix()

apix.setBaseUrl('/api')
apix.setHeaders({
  'x-app': 'warehouse-panel',
})

apix.transformResponse((ctx) => {
  const body = ctx.body

  if (!body?.message && Array.isArray(body?.messages)) {
    body.message = body.messages[0]?.message ?? body.messages[0] ?? null
  }

  return body
})

export default apix

Use it in a component:

<script setup lang="ts">
import apix from './api'

interface ProductForm {
  name: string | null
  sku: string | null
  price: number | null
}

const product = apix.create<ProductForm>('/products/42', {
  default: () => ({
    name: null,
    sku: null,
    price: null,
  }),
})
  
</script>

<template>
  <form @submit.prevent="product.post()">
    <input v-model="product.data.name">
    <div v-if="product.error('name')">{{ product.error('name') }}</div>

    <button :disabled="product.processing">
      Save
    </button>
  </form>
</template>

create() immediately returns a stable reactive model. model.data starts from default and is replaced/merged after the server response arrives.

Core Idea

@diephp/vue-model treats an API resource as a small reactive model:

const model = apix.create('/orders/1001', {
  default: () => ({
    status: null,
    customer_id: null,
    items: [],
  }),
})

You read and edit:

model.data.status = 'confirmed'

You can reload or change the resource:

await model.reload()
await model.post('/orders')
await model.put({ omit: ['items'] })
await model.patch({ only: ['status'] })

send() is the same as refresh(). For a JSON model it uses the model method, and the default model method is GET. That makes send() convenient for search/filter models, but for changing a resource it is usually clearer to call post, put, or patch.

You show errors:

<el-form-item :error="model.error('status')">
  <el-select v-model="model.data.status" />
</el-form-item>

<ul v-if="model.errors('status').length">
  <li v-for="error in model.errors('status')" :key="error">
    {{ error }}
  </li>
</ul>

You check dirty state:

model.isDirty()
model.isDirty('status')
model.getDirty('status')
model.reset()

There is no proxy shortcut like model.name. Use model.data.name.

Creating An Instance

import { createApix } from '@diephp/vue-model'

const apix = createApix()

apix.setBaseUrl(url)

Sets the base URL for relative model URLs.

apix.setBaseUrl('/admin/api')

const profile = apix.create('/profile')
// requests /admin/api/profile

If the model URL starts with ?, it is appended to the base URL:

apix.setBaseUrl('/admin/products.php')

const products = apix.create('?action=list_products')
// requests /admin/products.php?action=list_products

Absolute URLs are used as-is:

const external = apix.create('https://example.com/api/status')

apix.setHeaders(headers)

Replaces global headers:

apix.setHeaders({
  'x-app': 'inventory',
  authorization: `Bearer ${token}`,
})

apix.setHeader(name, value)

Sets one global header:

apix.setHeader('authorization', `Bearer ${token}`)

apix.setHeader(headers)

Merges several global headers:

apix.setHeader({
  'x-locale': 'en',
  'x-timezone': 'Europe/Madrid',
})

apix.transformResponse(transformer)

Adds a global response transformer. It runs for successful and failed HTTP responses.

apix.transformResponse((ctx) => {
  const body = ctx.body

  if (!body?.message && Array.isArray(body?.messages)) {
    body.message = body.messages[0]?.message ?? body.messages[0] ?? null
  }

  return body
})

The transformer receives:

{
  body,
  ok,
  status,
  statusText,
  headers,
  url,
  method,
  response,
}

Transformers can be async:

apix.transformResponse(async (ctx) => {
  return ctx.body
})

Global Processing

Each createApix() instance tracks all active requests:

apix.activeRequests // number
apix.processing // boolean

Example global spinner:

<template>
  <main v-loading="apix.processing">
    <RouterView />
  </main>
</template>

apix.processing is true while at least one model created by that apix instance is making a request.

Direct Requests

You can make one-off requests without creating a model.

await apix.get('/profile')
await apix.post('/signup')
await apix.put('/settings')
await apix.patch('/profile')
await apix.delete('/sessions/current')

Direct requests use the same base URL, headers, fetch, timeout, response transform, and error handling as models.

Params

For GET, params become query params:

const countries = await apix.get('/countries', {
  params: {
    active: true,
    locale: 'en',
  },
})

Request:

GET /countries?active=true&locale=en

For body methods, params become JSON body:

await apix.post('/newsletter/subscribe', {
  params: {
    email: '[email protected]',
  },
})

Request body:

{
  "email": "[email protected]"
}

Body

Use body when you want full control over the request body:

await apix.post('/auth/login', {
  body: {
    email: '[email protected]',
    password: 'secret',
  },
})

FormData also works:

const formData = new FormData()
formData.append('file', file)

await apix.post('/files/upload', {
  body: formData,
})

Callbacks

Direct requests support the same callbacks:

await apix.post('/signup', {
  params: {
    email: '[email protected]',
  },
  onSuccess: (body) => {
    console.log(body)
  },
  onError: (error) => {
    console.log(error.message)
  },
})

Return Value

Direct requests return a promise with the response data:

const profile = await apix.get<{ id: number; name: string }>('/profile')

Failed requests reject with ApixError:

try {
  await apix.post('/signup', {
    params: {
      email: '',
    },
  })
} catch (error) {
  if (error instanceof ApixError) {
    console.log(error.error('email'))
  }
}

Factory Methods

apix.create<T>(url, options?)

Creates a JSON model.

const invoice = apix.create('/invoices/77', {
  default: () => ({
    number: null,
    amount: null,
    status: null,
  }),
})

apix.createModel<T>(url, options?)

Alias to apix.create().

const invoice = apix.createModel('/invoices/77')

apix.form<T>(defaults?, options?)

Creates a local JSON form model without an initial request and without a URL.

Use this for registration, login, signup, filters, modal forms, and any form where you already know the initial fields and only need to submit later.

const form = apix.form({
  first_name: '',
  last_name: '',
  email: '',
  phone: '',
  country: '',
  password: '',
  password_confirmation: '',
  terms: false,
})

This is equivalent to a JSON model with:

{
  default: () => defaults,
  immediate: false,
}

Because there is no URL, pass the URL when you submit:

await form.post('/signup')

Or:

await form.post('/signup', {
  only: ['email', 'password', 'password_confirmation', 'terms'],
})

Empty form:

const subscription = apix.form({})

With options:

const login = apix.form(
  {
    email: '',
    password: '',
  },
  {
    timeout: 5000,
    onError: (error) => {
      console.log(error.message)
    },
  },
)

apix.form() is for JSON data. For upload forms with FormData, use apix.createForm().

apix.createCollection<TItem>(url, options?)

Sugar for list/selector data.

const cityOptions = apix.createCollection<{ id: number; name: string }>(
  '?action=get_cities',
)

Internally this is equivalent to:

apix.create(url, {
  default: () => [],
  snapshot: false,
  ...options,
})

Use it when you need data and processing, but do not need dirty/reset/original tracking.

apix.createForm(url, options?)

Creates a FormData model for uploads.

const uploadForm = apix.createForm('/documents/upload')

uploadForm.data.append('meta', JSON.stringify({ folder_id: 10 }))
uploadForm.data.append('file', file)

await uploadForm.send()

createForm() does not mix JSON and FormData. If you need JSON metadata with a file, put the metadata into the form:

uploadForm.data.append('meta', JSON.stringify({
  title: 'Monthly report',
  category: 'finance',
}))

uploadForm.data.append('file', file)

For FormData, the package does not set content-type. The browser sets multipart/form-data with the correct boundary.

The JSON response from an upload is not merged into form.data, because form.data must remain FormData. Use onSuccess when you need the upload response body.

const uploadForm = apix.createForm('/documents/upload', {
  onSuccess: (body) => {
    console.log(body)
  },
})

await uploadForm.send()

Model State

Every model exposes:

model.data
model.processing
model.httpCode
model.message

model.data

Reactive model data.

model.data.name = 'Express scanner'

For JSON models, data starts from default.

For createForm, data is a FormData object.

model.processing

true while this model has an active request.

<el-button :loading="product.processing">
  Save
</el-button>

model.httpCode

Last HTTP status code.

model.httpCode // 200, 422, 500, null

model.message

Last error message, if any.

<el-alert v-if="model.message" :title="model.message" />

Successful requests clear message.

Model Options

apix.create('/products/42', {
  params: { locale: 'en' },
  path: 'item',
  headers: { 'x-screen': 'product-edit' },
  immediate: true,
  method: 'POST',
  default: () => ({ name: null }),
  only: ['name'],
  omit: ['local_state'],
  response: { omit: ['name'] },
  watch: { only: ['pagination.page'], debounce: 300 },
  timeout: 5000,
  abort: true,
  locked: false,
  snapshot: true,
  clearErrorOnChange: true,
  before: (payload) => payload,
  after: (body) => body,
  onSuccess: (body, model) => {},
  onError: (error, model) => {},
  onFinish: (model, error) => {},
  onProcessing: (active, model) => {},
})

default

Object or callback used as initial data.

default: () => ({
  name: null,
  status: 'draft',
  items: [],
})

If not provided, data starts as null.

default is used:

  • when the model is created;
  • when model.default() is called;
  • as the base for reload() merge.

immediate

Controls whether the model sends the first request immediately.

immediate: true // default for JSON models
immediate: false

Use immediate: false for create forms or when you want to wait for a user action.

const search = apix.create('/parcels/search', {
  immediate: false,
  default: () => ({
    tracking_number: null,
    parcel: null,
  }),
})

await search.send()

createForm() defaults to immediate: false.

params

Extra request params. Can be an object or callback.

params: {
  warehouse_id: 15,
}

For GET and HEAD, params are added to the query string.

For body methods, params are merged into the payload.

Callbacks run right before the request:

params: () => ({
  route_id: route.query.id,
  locale: i18n.locale.value,
})

headers

Request headers for this model/request.

headers: {
  'x-module': 'scanner-editor',
}

Can be a callback:

headers: () => ({
  authorization: `Bearer ${auth.token}`,
})

Request headers override global headers with the same name.

method

Default method for requests made by request, send, refresh, and reload.

For JSON models, the default is GET.

For createForm(), the default is POST.

method: 'PATCH'

Explicit method helpers override the default:

await model.post() // POST
await model.put() // PUT
await model.patch() // PATCH

Request options can override even a helper method:

await model.post({ method: 'GET' })

The request option has priority.

path

Path inside the response body where model data lives.

const product = apix.create('/products/42', {
  path: 'item',
})

If the response is:

{
  "item": {
    "id": 42,
    "name": "Label printer"
  }
}

then model.data receives:

{
  id: 42,
  name: 'Label printer',
}

path is not the request URL. The request URL is the first argument passed to create, request, get, post, etc.

only

Filters the payload sent to the server.

only: ['name', 'pagination.page', 'pagination.onPage']

If you send:

await model.send()

only those fields are included in the request body.

If you specify an object path:

only: ['pagination']

the whole pagination object is sent.

only does not filter the response.

omit

Excludes fields from the payload sent to the server.

omit: ['items', 'local_state']

Fields in omit are also excluded from the original snapshot. That means:

  • isDirty('items') is always false;
  • getOriginal('items') is undefined;
  • reset() does not change items.

Use snapshot: false when you want to disable snapshot tracking completely.

response.only and response.omit

Filters successful response data before it is applied to model.data.

Default: successful response data is applied completely.

Example: send filters, but do not let the response overwrite a text input:

await model.send({
  only: ['name', 'pagination'],
  response: {
    omit: ['name'],
  },
})

Example: only apply table data from the response:

await model.send({
  response: {
    only: ['items', 'pagination'],
  },
})

Error responses do not apply to model.data.

For validation endpoints, send only the fields needed for validation and apply only the status fields returned by the server.

watch(
  () => [form.data.slug, form.data.domain],
  debounce(() => {
    if (!form.data.slug || !form.data.domain) return

    form.post('/validate-domain', {
      only: ['slug', 'domain'],
      response: {
        only: ['message', 'status'],
      },
    }).catch(() => undefined)
  }, 1000),
)

This keeps slug and domain as user-controlled fields, while message and status can be shown in the UI.

watch

Automatically sends the model when watched fields change.

watch: true
watch: {
  debounce: 300,
}

By default, watch uses the model payload filter:

only: ['name', 'pagination.page'],
watch: true

This watches name and pagination.page.

Override watched fields:

only: ['name', 'pagination.page', 'pagination.onPage'],
watch: {
  only: ['pagination.page', 'pagination.onPage'],
}

Now name is still sent when the request runs, but changing name does not start the request.

Use watch.omit:

watch: {
  omit: ['name', 'items'],
}

watch.only: [] means watch nothing.

When the package applies a response, reset, or default, the watcher is muted to avoid request loops.

timeout

Aborts a request after the given number of milliseconds.

timeout: 5000

Timeout errors reject with ApixError:

try {
  await model.send()
} catch (error) {
  if (error.timeout) {
    console.log('Request timed out')
  }
}

abort

Default: true.

abort: true

When a new request starts, the previous active request for this model is aborted.

locked

Default: false.

locked: true

If a request is already active, a new request does not start. The current promise is returned instead.

Priority:

  • locked: true blocks new requests while processing;
  • locked: false, abort: true aborts the previous request;
  • locked: false, abort: false allows concurrent requests, but only the latest response applies to the model.

snapshot

Default for JSON models: true.

Default for createCollection and createForm: false.

snapshot: false

Disables original copy storage and dirty/reset tracking.

Useful for selector options:

const countryOptions = apix.createCollection('/countries/options')

or:

const countryOptions = apix.create('/countries/options', {
  default: () => [],
  snapshot: false,
})

clearErrorOnChange

Default: true.

When a field changes, the matching field error is cleared.

clearErrorOnChange: true

Example:

model.error('email') // "Email is required"

model.data.email = '[email protected]'

model.error('email') // null

This works independently of watch.

before

Transforms payload before sending it. It does not mutate model.data unless you do so manually.

before: (payload) => {
  return {
    ...payload,
    normalized_name: payload.name?.trim(),
  }
}

With context:

before: (payload, ctx) => {
  console.log(ctx.url, ctx.method)
  return payload
}

after

Transforms successful response data before it is applied to model.data.

after: (body) => {
  return {
    ...body,
    loaded_at: Date.now(),
  }
}

after runs after path is applied and before response.only/omit.

onSuccess

Called after a successful response.

onSuccess: (body, model) => {
  console.log('Saved', body)
}

onError

Called after a failed response.

onError: (error, model) => {
  console.log(error.message)
  console.log(error.error('name'))
}

onFinish

Called after success or failure.

onFinish: (model, error) => {
  console.log(error ? 'failed' : 'finished')
}

onProcessing

Called when model processing starts and stops.

onProcessing: (active) => {
  progressBar.toggle(active)
}

Request Methods

All request methods return Promise<TData>.

await model.request()
await model.get()
await model.post()
await model.put()
await model.patch()
await model.delete()
await model.reload()
await model.refresh()
await model.send()

send() and refresh()

send() is exactly the same method as refresh().

model.send === model.refresh // true

Both methods:

  • send current model.data;
  • use the model method;
  • use GET by default for JSON models;
  • apply only / omit to the request payload;
  • merge a successful response into current model.data;
  • update the original snapshot after success.

Use whichever name reads better:

await filterModel.refresh()
await searchModel.send()

For changing a resource, prefer explicit methods:

await productForm.post()
await productForm.put()
await productForm.patch({ only: ['status'] })

Argument Forms

Request methods accept either a URL, options, or both:

await model.post()
await model.post('/products')
await model.post({ only: ['name'] })
await model.post('/products', { only: ['name'] })

You can also pass the URL inside options:

await model.post({
  url: '/products',
  only: ['name'],
})

Options passed to a request override model options for that one request:

const product = apix.create('/products/42', {
  only: ['name', 'price'],
})

await product.patch({
  only: ['status'],
})

In this request only status is sent.

Method Override

The method helpers are convenient defaults. options.method has the highest priority:

await model.post({
  method: 'GET',
  only: ['name'],
})

This sends a GET request, not POST.

This is mostly useful when a model has a configured method and you want to override it once:

const report = apix.create('/reports/export', {
  method: 'POST',
})

await report.send() // POST
await report.send({ method: 'GET' }) // GET

Payload Rules

get, post, put, patch, delete, request, send, and refresh are send-style methods. They use current model.data as payload.

For GET and HEAD, the payload becomes query params:

model.data.name = 'phone'
model.data.pagination.page = 2

await model.get({
  only: ['name', 'pagination.page'],
})

// GET /...?name=phone&pagination.page=2

For body methods, the payload is JSON:

await model.patch({
  only: ['name', 'price'],
})

The body will be:

{
  "name": "Desk",
  "price": 120
}

body overrides automatic payload building:

await model.patch({
  body: model.getDirty(),
})

When body is passed, only, omit, and before are not used to build the payload.

reload() Is Different

reload() means "load model data again". It does not send current model.data.

await model.reload()

If you need params for reload, use params:

await model.reload({
  params: {
    page: 2,
    onPage: 50,
  },
})

For reload(), the successful response is merged with fresh default() data:

data = merge(default(), responseData)

For send() / refresh() / post() / patch() / etc., the successful response is merged with current data:

data = merge(currentData, responseData)

model.request(url?, options?)

Generic request method.

await model.request('/products/search', {
  method: 'POST',
  only: ['name'],
})

model.get(url?, options?)

Sends a GET request.

await model.get('/products/42')

For GET, payload and params are converted to query params.

Examples:

await model.get({
  only: ['name', 'pagination.page'],
})
await model.get('/products/search', {
  params: {
    warehouse_id: 10,
  },
  only: ['name'],
})

If model.data.name is "phone", this builds a query similar to:

/products/search?name=phone&warehouse_id=10

model.post(url?, options?)

Sends a POST request.

await model.post('/products')

Examples:

await model.post({
  only: ['name', 'sku', 'price'],
})
await model.post('/products/create', {
  omit: ['items', 'local_state'],
})
await model.post({
  url: '/products/create',
  response: {
    omit: ['name'],
  },
})

model.put(url?, options?)

Sends a PUT request.

await model.put('/products/42')

Examples:

await model.put({
  only: ['name', 'status', 'settings'],
})
await model.put('/products/42', {
  omit: ['items', 'logs'],
})

model.patch(url?, options?)

Sends a PATCH request.

await model.patch('/products/42', {
  only: ['name', 'price'],
})

Patch with only dirty data:

await model.patch({
  body: model.getDirty(),
})

Patch one nested value:

await model.patch({
  only: ['settings.resource.limit'],
})

model.delete(url?, options?)

Sends a DELETE request.

await model.delete('/products/42')

Examples:

await model.delete()
await model.delete('/products/42', {
  params: {
    force: true,
  },
})
await model.delete({
  url: '/products/42',
  only: ['reason'],
})

model.reload(options?)

Loads data from the server.

Default method: GET.

Merge rule:

model.data = merge(default(), responseData)

Example:

await product.reload()

Useful when you want the server to become the source of truth again.

Examples:

await product.reload({
  params: {
    include: 'items,logs',
  },
})
await product.reload({
  response: {
    only: ['name', 'status', 'items'],
  },
})

reload() does not send current model.data. Use params for extra request data.

model.refresh(options?)

Sends current model data to the server.

Default method for JSON models: GET.

Merge rule:

model.data = merge(currentData, responseData)

Example:

await searchModel.refresh()

Examples:

await searchModel.refresh({
  only: ['name', 'price'],
})

This sends name and price as query params unless the model or request sets another method.

await productForm.refresh({
  method: 'PATCH',
  omit: ['items', 'logs'],
  response: {
    omit: ['name'],
  },
})

model.send(options?)

Alias to refresh().

await searchModel.send()

This often reads better for search/filter models.

Examples:

await search.send({
  only: ['name', 'price'],
})
await productForm.send({
  method: 'PATCH',
  body: productForm.getDirty(),
})

Data Methods

model.setData(data)

Replaces model.data.

model.setData({
  name: 'New scanner',
  status: 'active',
})

Use this when you want to replace the whole model manually:

model.setData({
  name: null,
  items: [],
  pagination: {
    page: 1,
    onPage: 50,
    total: 0,
  },
})

model.push(data)

For objects, deep-merges data into model.data.

model.push({
  status: 'active',
})

Nested object example:

model.push({
  pagination: {
    page: 1,
  },
})

Only pagination.page changes. Other pagination fields stay as they are.

For arrays, pushes one item.

const options = apix.createCollection('/tag-options')

options.push({
  id: 10,
  label: 'Fragile',
})

model.reset(options?)

Resets data to the latest successful original snapshot.

model.reset()

Reset one field:

model.reset({
  only: ['name'],
})

Reset nested fields:

model.reset({
  only: ['settings.resource.limit', 'pagination.page'],
})

Reset everything except one field:

model.reset({
  omit: ['pagination.page'],
})

This is useful when a table should reset filters but keep the current page.

Fields excluded by the model-level omit are not stored in the snapshot and are not changed by reset().

model.default(options?)

Resets data to default.

model.default()

Reset selected fields to default:

model.default({
  only: ['status', 'type'],
})

Reset one nested field to default:

model.default({
  only: ['pagination.page'],
})

Reset defaults but keep local UI data:

model.default({
  omit: ['items', 'local_state'],
})

Dirty And Original Snapshot

Successful requests update the original snapshot:

await model.send()
model.isDirty() // false

Failed requests do not update the snapshot:

try {
  await model.send()
} catch {}

model.isDirty() // still true if data changed

model.isDirty(key?)

Checks whether data differs from the latest snapshot.

model.isDirty()
model.isDirty('name')
model.isDirty('settings.resource.limit')

Example:

if (model.isDirty()) {
  await model.send()
}

Field example:

if (model.isDirty('pagination.page')) {
  await model.get({
    only: ['pagination.page'],
  })
}

model.getOriginal(key?)

Returns the latest snapshot.

model.getOriginal()
model.getOriginal('name')

Example:

const was = model.getOriginal('status')
const now = model.data.status

No default values are silently mixed into getOriginal.

If snapshot: false, this returns null without a key and undefined with a key.

model.getDirty(key?)

Returns changed current values.

model.getDirty()
model.getDirty('name')

Example return value:

model.data = {
  name: 'Scanner B',
  status: 'active',
  pagination: {
    page: 2,
    onPage: 50,
  },
}

model.getDirty()

Possible result:

{
  "name": "Scanner B",
  "pagination": {
    "page": 2
  }
}

If the field is not dirty:

model.getDirty('name') // undefined

Errors

Error responses are not merged into model.data.

If the server returns:

{
  "message": "Validation failed",
  "errors": {
    "name": [
      "Name is required"
    ],
    "settings.resource.limit": [
      "Limit is too high"
    ]
  }
}

then:

model.message // "Validation failed"
model.error('name') // "Name is required"
model.errors('name') // ["Name is required"]
model.error(['name', 'settings.resource.limit']) // "Name is required"
model.errors(['name', 'settings.resource.limit']) // ["Name is required", "Limit is too high"]
model.errorKeys() // ["name", "settings.resource.limit"]

model.error(key)

Returns the first error string for a field or null.

<el-form-item :error="product.error('name')">

Nested key:

<el-form-item :error="product.error('settings.resource.limit')">

List of keys:

<el-form-item :error="product.error(['slug', 'domain'])">

This returns the first error found in the same order as the keys.

model.errors(key)

Returns all error strings for a field.

model.errors('name')

You can pass several keys. The result is one flat array:

model.errors(['slug', 'domain'])

Useful when one UI input visually represents more than one API field:

<form-input
  v-model="resourceForm.data.slug"
  :disabled="resourceForm.processing"
  required
  :errors="resourceForm.errors(['slug', 'domain'])"
  label="Slug"
/>

<form-simple-select
  v-model="resourceForm.data.domain"
  required
  :options="domains.data"
  label="Domain"
/>

Show all field messages:

<ul v-if="model.errors('name').length">
  <li v-for="message in model.errors('name')" :key="message">
    {{ message }}
  </li>
</ul>

If the key does not exist, returns an empty array.

model.errors()

Returns the raw errors object.

model.errors()

model.errorKeys()

Returns error paths.

model.errorKeys()

Useful for forms split into tabs.

const keys = model.errorKeys()

if (keys[0]?.startsWith('settings.')) {
  activeTab.value = 'settings'
}

model.hasErrors(keyOrKeys)

Checks whether one field or any field from a list has an error.

model.hasErrors('name')
model.hasErrors(['name', 'settings.resource.limit'])

Example tab switch:

if (model.hasErrors(['name', 'slug'])) {
  activeTab.value = 'general'
}

if (model.hasErrors(['settings.resource.limit', 'settings.weight.limit'])) {
  activeTab.value = 'limits'
}

This returns true if at least one key from the list has an error.

model.firstErrorKey()

Returns the first error path or null.

const firstKey = model.firstErrorKey()

model.getError()

Returns the latest ApixError or null.

const error = model.getError()

if (error?.has(['name', 'slug'])) {
  activeTab.value = 'general'
}

Useful when you want to inspect the raw response:

const error = model.getError()

console.log(error?.httpCode)
console.log(error?.body)
console.log(error?.keys())

model.clearError(key)

Clears one field error.

model.clearError('name')

Nested key:

model.clearError('settings.resource.limit')

model.clearErrors()

Clears all errors and message.

model.clearErrors()

Useful before closing a modal or changing a large part of a form manually.

clearErrorOnChange

By default, editing a field clears that field error:

model.data.name = 'New name'
model.error('name') // null

This works even when watch is disabled.

ApixError

Requests reject with ApixError.

import { ApixError } from '@diephp/vue-model'

try {
  await model.send()
} catch (error) {
  if (error instanceof ApixError) {
    console.log(error.httpCode)
    console.log(error.message)
  }
}

Properties:

error.httpCode
error.body
error.message
error.aborted
error.timeout
error.cause

Methods:

error.error('name')
error.error(['name', 'slug'])
error.errors('name')
error.errors(['name', 'slug'])
error.errors()
error.keys()
error.has('name')
error.has(['name', 'slug'])
error.firstKey()

Keys And Filters

Many methods and options accept keys:

'name'
'pagination'
'pagination.page'
'settings.resource.limit'

The same key format works in:

  • only;
  • omit;
  • response.only;
  • response.omit;
  • watch.only;
  • watch.omit;
  • error(key);
  • errors(key);
  • hasErrors(keyOrKeys);
  • isDirty(key);
  • getDirty(key);
  • getOriginal(key);
  • reset({ only, omit });
  • default({ only, omit }).

For errors, key can be a single key or a list of keys:

model.error('name')
model.error(['slug', 'domain'])
model.errors('name')
model.errors(['slug', 'domain'])

Plain Key

only: ['name']

Given:

model.data = {
  name: 'Scanner A',
  status: 'active',
}

the request payload is:

{
  "name": "Scanner A"
}

Whole Object Key

only: ['pagination']

Given:

model.data = {
  name: 'Scanner A',
  pagination: {
    page: 2,
    onPage: 50,
    total: 300
  }
}

the request payload is:

{
  "pagination": {
    "page": 2,
    "onPage": 50,
    "total": 300
  }
}

Use this when the server expects the whole object.

Nested Key

only: ['pagination.page']

Given the same data, the request payload is:

{
  "pagination": {
    "page": 2
  }
}

Use this when the server only needs one nested value.

Multiple Keys

await model.send({
  only: [
    'name',
    'category_id',
    'pagination.page',
    'pagination.onPage',
  ],
})

Payload:

{
  "name": "Scanner A",
  "category_id": 7,
  "pagination": {
    "page": 2,
    "onPage": 50
  }
}

Excluding Keys With omit

await model.send({
  omit: ['items', 'debug', 'local_state'],
})

Everything except these keys is sent.

Nested omit:

await model.send({
  omit: ['pagination.total'],
})

This keeps pagination.page and pagination.onPage, but removes pagination.total.

Combining only And omit

only is applied first, then omit.

await model.send({
  only: ['name', 'pagination'],
  omit: ['pagination.total'],
})

Payload:

{
  "name": "Scanner A",
  "pagination": {
    "page": 2,
    "onPage": 50
  }
}

Empty Lists

Empty arrays are meaningful:

only: []

means "send nothing from model.data".

omit: []

means "exclude nothing".

watch: {
  only: [],
}

means "watch nothing".

To avoid storing a snapshot, use:

snapshot: false

Do not use omit: [] for that. Empty omit means no keys are omitted.

Response Keys

only and omit filter request payload.

response.only and response.omit filter successful response data.

await model.send({
  only: ['name', 'pagination'],
  response: {
    omit: ['name'],
  },
})

This sends name and pagination, but the response is not allowed to overwrite name.

await model.send({
  response: {
    only: ['items', 'pagination.total'],
  },
})

This applies only items and pagination.total from the successful response.

Watch Keys

If watch.only and watch.omit are not passed, watch uses the model request filter.

const table = apix.create('/stock/table', {
  only: ['name', 'pagination.page', 'pagination.onPage'],
  watch: true,
})

Changing name, pagination.page, or pagination.onPage starts a request.

Override watched keys:

const table = apix.create('/stock/table', {
  only: ['name', 'pagination.page', 'pagination.onPage'],
  watch: {
    only: ['pagination.page', 'pagination.onPage'],
  },
})

Now name is still sent in the request, but editing name does not start a request.

Use watch.omit when it is easier to exclude a few fields:

watch: {
  omit: ['name', 'items'],
}

Error Keys

Laravel-style errors often use dot paths:

{
  "message": "Validation failed",
  "errors": {
    "settings.resource.limit": [
      "Limit is too high"
    ],
    "items.0.name": [
      "Item name is required"
    ]
  }
}

Read them directly:

model.error('settings.resource.limit')
model.errors('items.0.name')
model.hasErrors(['name', 'settings.resource.limit'])

For tabbed forms:

if (model.hasErrors(['name', 'sku'])) {
  activeTab.value = 'general'
}

if (model.hasErrors(['settings.resource.limit', 'settings.weight.limit'])) {
  activeTab.value = 'limits'
}

if (model.hasErrors(['items'])) {
  activeTab.value = 'items'
}

Dirty Keys

model.isDirty('name')
model.isDirty('pagination.page')

model.getDirty('name')
model.getOriginal('name')

Reset selected values:

model.reset({
  only: ['name', 'pagination.page'],
})

Reset everything except current page:

model.reset({
  omit: ['pagination.page'],
})

Keys That Contain Dots

Keys with dots are supported. Exact keys have priority at each level.

For:

const data = {
  settings: {
    'resource.limit': 10,
  },
}

this key works:

model.isDirty('settings.resource.limit')

The helper first tries:

data['settings.resource.limit']

then:

data.settings['resource.limit']

then:

data.settings.resource.limit

If the server returns both exact-dot keys and nested structures for the same path, the data is ambiguous. The package will still choose a value predictably, but the API response should be fixed.

Arrays In Keys

For error lookup, array-like Laravel keys are fine:

model.error('items.0.name')

For request payload filters, prefer selecting the whole array:

only: ['items']

instead of selecting one array item:

only: ['items.0.name']

The package is optimized for JSON form objects. Sending whole arrays is usually clearer and safer.

Lifecycle And stop()

If a model is created inside a Vue setup() scope, it is automatically stopped when the component is unmounted.

<script setup lang="ts">
const model = apix.create('/profile')
</script>

On unmount:

  • watcher is stopped;
  • debounce timer is cleared;
  • active request is aborted;
  • delayed immediate request will not start.

If you create a model outside a component scope, stop it manually when you no longer need it:

const model = apix.create('/reports/live', {
  watch: true,
})

model.stop()

Calling request methods after stop() rejects with an ApixError where aborted is true.

Examples

Edit Resource Form

<script setup lang="ts">
import apix from './api'

interface DeviceForm {
  name: string | null
  serial_number: string | null
  status: 'active' | 'disabled' | null
}

const deviceForm = apix.create<DeviceForm>('/devices/120', {
  path: 'item',
  default: () => ({
    name: null,
    serial_number: null,
    status: null,
  }),
})

const save = async () => {
  await deviceForm.send({
    only: ['name', 'serial_number', 'status'],
  })
}
</script>

<template>
  <el-form v-loading="deviceForm.processing">
    <el-form-item label="Name" :error="deviceForm.error('name')">
      <el-input v-model="deviceForm.data.name" />
    </el-form-item>

    <el-form-item label="Serial number" :error="deviceForm.error('serial_number')">
      <el-input v-model="deviceForm.data.serial_number" />
    </el-form-item>

    <el-form-item label="Status" :error="deviceForm.error('status')">
      <el-select v-model="deviceForm.data.status">
        <el-option label="Active" value="active" />
        <el-option label="Disabled" value="disabled" />
      </el-select>
    </el-form-item>

    <el-button type="primary" :loading="deviceForm.processing" @click="save">
      Save
    </el-button>
  </el-form>
</template>

Create Form Without Immediate Request

const createWarehouse = apix.create('/warehouses', {
  immediate: false,
  default: () => ({
    name: '',
    country_id: null,
    city_id: null,
  }),
})

await createWarehouse.post()

Local Signup Form

const signupForm = apix.form({
  first_name: '',
  last_name: '',
  email: '',
  phone: '',
  country: '',
  password: '',
  password_confirmation: '',
  terms: false,
})

const submit = async () => {
  await signupForm.post('/auth/signup')
}

Use it in UI like any other model:

<el-form-item label="Email" :error="signupForm.error('email')">
  <el-input v-model="signupForm.data.email" />
</el-form-item>

<el-form-item label="Password" :error="signupForm.error('password')">
  <el-input v-model="signupForm.data.password" type="password" />
</el-form-item>

<el-checkbox v-model="signupForm.data.terms">
  I accept terms
</el-checkbox>

<el-button :loading="signupForm.processing" @click="submit">
  Create account
</el-button>

You can still use request options:

await signupForm.post('/auth/signup', {
  only: [
    'first_name',
    'last_name',
    'email',
    'password',
    'password_confirmation',
    'terms',
  ],
})

Search Form

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import apix from './api'

const route = useRoute()
const router = useRouter()
const initialQuery = String(route.query.query ?? '')

const searchForm = apix.create('/catalog/search', {
  immediate: Boolean(initialQuery),
  default: () => ({
    query: initialQuery,
    product: null,
    history: null,
  }),
  only: ['query'],
  omit: ['product', 'history'],
  params: () => ({
    query: searchForm.data.query || undefined,
  }),
  onError: () => {
    searchForm.push({
      product: null,
      history: null,
    })
  },
  onFinish: () => {
    router.replace({
      query: {
        ...route.query,
        query: searchForm.data.query || undefined,
      },
    })
  },
})
</script>

<template>
  <el-form-item :error="searchForm.error('query')">
    <el-input
      v-model="searchForm.data.query"
      placeholder="Search product"
      clearable
    />
  </el-form-item>

  <el-button
    type="primary"
    :disabled="!searchForm.data.query"
    :loading="searchForm.processing"
    @click="searchForm.send()"
  >
    Search
  </el-button>
</template>

Table With Filters And Pagination

Changing page sends the request automatically. Changing name does not send until the user clicks search.

const tableModel = apix.create('/inventory/table', {
  default: () => ({
    name: null,
    category_id: null,
    items: [],
    pagination: {
      page: 1,
      onPage: 50,
      total: 0,
    },
  }),
  only: ['name', 'category_id', 'pagination.page', 'pagination.onPage'],
  watch: {
    only: ['pagination.page', 'pagination.onPage'],
    debounce: 250,
  },
})

const search = () => {
  tableModel.send()
}

Payload includes name, category_id, pagination.page, and pagination.onPage.

Only pagination.page and pagination.onPage trigger automatic requests.

Filters That Auto-Refresh

const ruleModel = apix.create('/shipping/rules', {
  default: () => ({
    name: null,
    carrier_ids: [],
    country_ids: [],
    service_ids: [],
    items: [],
    pagination: {
      page: 1,
      onPage: 50,
      total: 0,
    },
  }),
  only: [
    'name',
    'carrier_ids',
    'country_ids',
    'service_ids',
    'pagination.page',
    'pagination.onPage',
  ],
  watch: {
    debounce: 300,
  },
})

Because watch.only is not provided, watcher uses the model only list.

Avoid Input Jump From Server Response

If a server returns filter fields back, you may not want to overwrite the current user input.

await tableModel.send({
  only: ['name', 'pagination'],
  response: {
    omit: ['name'],
  },
})

The request sends name and pagination. The response applies everything except name.

Apply Only Table Data From Response

await tableModel.send({
  response: {
    only: ['items', 'pagination'],
  },
})

Useful when the response contains metadata you do not want to store in the model.

Selector Options

<script setup lang="ts">
import apix from './api'

const hubOptions = apix.createCollection<{ id: number; name: string }>(
  '/hubs/options',
)
</script>

<template>
  <el-select
    v-model="scannerForm.data.hub_id"
    v-loading="hubOptions.processing"
    placeholder="Select hub"
  >
    <el-option
      v-for="hub in hubOptions.data"
      :key="hub.id"
      :label="hub.name"
      :value="hub.id"
    />
  </el-select>
</template>

createCollection uses snapshot: false, so it does not store a second copy of the options.

Several Selectors On One Page

const hubOptions = apix.createCollection('/hubs/options')
const roleOptions = apix.createCollection('/roles/options')
const countryOptions = apix.createCollection('/countries/options')
const timezoneOptions = apix.createCollection('/timezones/options')

Use the global spinner if you want to wait for all of them:

<section v-loading="apix.processing">

Or use each model's own spinner:

<el-select v-loading="countryOptions.processing" />

File Upload

<script setup lang="ts">
import apix from './api'

const upload = apix.createForm('/files/upload', {
  onSuccess: (body) => {
    console.log('Uploaded file', body)
  },
})

const uploadFile = async (file: File) => {
  upload.data.set('meta', JSON.stringify({
    folder: 'contracts',
    visibility: 'private',
  }))

  upload.data.set('file', file)

  await upload.send()
}
</script>

Laravel Validation Errors

Laravel usually returns:

{
  "message": "The given data was invalid.",
  "errors": {
    "email": [
      "The email field is required."
    ]
  }
}

Use directly:

<el-form-item label="Email" :error="userForm.error('email')">
  <el-input v-model="userForm.data.email" />
</el-form-item>

Custom Error Format

If another project returns messages, normalize it once:

apix.transformResponse((ctx) => {
  const body = ctx.body

  if (!body.message && Array.isArray(body.messages)) {
    const first = body.messages[0]
    body.message = typeof first === 'string' ? first : first?.message ?? null
  }

  return body
})

Switch Tabs By Error Group

const openErrorTab = () => {
  if (productForm.hasErrors(['name', 'sku', 'barcode'])) {
    activeTab.value = 'general'
    return
  }

  if (productForm.hasErrors(['settings.resource.limit', 'settings.weight.limit'])) {
    activeTab.value = 'limits'
    return
  }

  if (productForm.hasErrors(['items'])) {
    activeTab.value = 'items'
  }
}

try {
  await productForm.patch()
} catch {
  openErrorTab()
}

Dirty Buttons

<el-button :disabled="!productForm.isDirty()" @click="productForm.reset()">
  Reset
</el-button>

<el-button type="primary" :disabled="!productForm.isDirty()" @click="productForm.patch()">
  Save
</el-button>

Send Only Dirty Data

The package does not force this pattern, but you can do it explicitly:

await productForm.patch({
  body: productForm.getDirty(),
})

Manual Error Handling

import { ApixError } from '@diephp/vue-model'

try {
  await productForm.patch()
} catch (error) {
  if (error instanceof ApixError) {
    if (error.httpCode === 422) {
      console.log(error.keys())
    }

    if (error.timeout) {
      console.log('Try again later')
    }
  }
}

Locked Submit Button

const paymentForm = apix.create('/payments/charge', {
  immediate: false,
  locked: true,
  default: () => ({
    amount: null,
    card_id: null,
  }),
})

If the user clicks twice, the second request will not start while the first one is active.

Abort Previous Search Request

Default behavior:

const search = apix.create('/products/search', {
  abort: true,
  watch: {
    only: ['query'],
    debounce: 200,
  },
  default: () => ({
    query: null,
    items: [],
  }),
})

When the user types quickly, the previous active request is aborted before the new one starts.

Request Params From Route

const report = apix.create('/reports/monthly', {
  params: () => ({
    month: route.query.month,
    warehouse_id: route.query.warehouse_id,
  }),
  default: () => ({
    items: [],
    totals: {},
  }),
})

The callback runs before every request, so route/query values stay current.

Response Wrapper

const profile = apix.create('/profile/current', {
  path: 'data.user',
  default: () => ({
    id: null,
    name: null,
    email: null,
  }),
})

Response:

{
  "data": {
    "user": {
      "id": 15,
      "name": "Anna",
      "email": "[email protected]"
    }
  }
}

Model data:

profile.data.name // "Anna"

Merge Rules

Initial data

data = default()

reload()

data = merge(default(), responseData)

Use reload() when you want to rebuild data from server state.

send() / refresh()

data = merge(currentData, responseData)

This avoids clearing form fields that the server does not return.

Error response

data is not changed
message/errors are updated

This means if page 2 fails, old items stay visible unless you clear them manually:

onError: () => {
  tableModel.push({
    items: [],
  })
}

TypeScript

interface ScannerForm {
  name: string | null
  hub_id: number | null
  enabled: boolean
}

const scannerForm = apix.create<ScannerForm>('/scanners/5', {
  default: () => ({
    name: null,
    hub_id: null,
    enabled: true,
  }),
})

scannerForm.data.name = 'Dock scanner'

Collections:

interface Option {
  id: number
  name: string
}

const options = apix.createCollection<Option>('/options/hubs')

options.data.forEach((option) => {
  console.log(option.name)
})

Recommended Defaults

For edit forms:

apix.create('/resource/1', {
  default: () => ({ /* fields */ }),
})

For create forms:

const form = apix.form({
  name: '',
  email: '',
})

await form.post('/resource')

For selectors:

apix.createCollection('/resource/options')

For uploads:

apix.createForm('/resource/upload')

For table filters:

apix.create('/resource/table', {
  only: ['name', 'pagination.page', 'pagination.onPage'],
  watch: {
    only: ['pagination.page', 'pagination.onPage'],
  },
})

Notes

  • The package is built around JSON data. Dirty/reset tracking is for JSON-like objects and arrays.
  • Files and FormData are not tracked in snapshots.
  • only and omit filter request payload, not server response.
  • response.only and response.omit filter successful response data.
  • Error response bodies update error state, not model.data.
  • model.data is always the place for UI binding.
  • Use model.stop() when creating models outside Vue component scope.