@diephp/vue-apix

Tiny form/model/api toolkit for Vue 3 — fetch-based, Laravel-friendly, zero dependencies
Install
npm install @diephp/vue-apix
Latest Version:0.3.4
Vue:3
License:MIT
Last Updated:Aug 6, 2026
Links: npm
Maintainer: diephp

@diephp/vue-apix

Tiny form / model / api toolkit for Vue 3. Fetch data, edit it, send it back — with reactive processing, Laravel-style validation errors and auto-refetch on change. Built on native fetch, zero runtime dependencies, only Vue 3 as a peer.

The closest relative is Inertia's useForm, but vue-apix works with any JSON API and also covers "load → edit → submit" and "filters + pagination" flows.

Installation

npm i @diephp/vue-apix
yarn add @diephp/vue-apix
pnpm add @diephp/vue-apix

Requires Vue ^3.3. TypeScript definitions are bundled.

Setup

Create your own configured instance once (e.g. src/api.ts) and import it everywhere:

// src/api.ts
import apix from '@diephp/vue-apix';

// all relative request URLs are prefixed with this, so they must start with /
apix.setBaseUrl('https://api.domain.com/v1');

apix.setHeader({
  // lazy value: the callback is resolved on every request
  'x-auth-token': () => localStorage.getItem('token'),
  // static value
  'x-app': 'admin',
});

// Header names are case-insensitive ('Content-Type' and 'content-type' are the same key).
// Content-Type is auto-set to application/json only when a request has a JSON body and
// you haven't configured it yourself; configure it explicitly to send it on every request
// (including GET). For FormData bodies any content-type is dropped so the browser can
// set the multipart boundary.

// fires with true when the first request starts, false when the last one ends —
// perfect for a global progress bar / spinner
apix.onProcessing((active) => progressBar.toggle(active));

// fires on every failed request
apix.onError((error, ctx) => {
  if (error instanceof ApixError && error.status === 401) redirectToLogin();
});

export default apix;
// any component / store
import apix from '@/api';

Need an isolated instance (a second API with different auth)? Create one:

import { createApix } from '@diephp/vue-apix';

// credentials: 'include' — for cookie-based auth like Laravel Sanctum
const other = createApix({ baseUrl: 'https://other.api', credentials: 'include' });

Plain requests (Promise, no model)

// GET /users?filters[name]=x&page=2
apix.get('/users', { filters: { name: 'x' }, page: 2 });

apix.post('/users', { name: 'a' });
apix.put('/users/1', { name: 'b' });
apix.patch('/users/1', { name: 'c' });

// DELETE with a body is allowed
apix.delete('/users', { ids: [1, 2] });

Query params are serialized PHP-style (filters[name]=x), booleans become 1/0, null/undefined are skipped. Non-2xx responses reject with ApixError (status, body, message, errors, isValidationError).

Forms

From scratch

const model = apix.form({ name: null, description: null });

// magic prop ≡ model.data.name, fully reactive
model.name = 'tosic';

model.post('/api/attribute').then(({ message }) => alert(message));

Filled from the API

interface AttrDto {
  id: number | null;
  name: string | null;
  description: string | null;
}

// response: {data: {item: {...}}} → model.data = item
const model = apix.getForm<AttrDto>('/api/attribute/123', 'data.item');

Full options:

const model = apix.getForm<AttrDto>('/api/attribute/123', {
  // dot-path against the RAW response body — there is no implicit body.data
  // unwrapping, include `data` yourself when the API wraps its payload
  // (see "Laravel pagination" below)
  path: 'data.item',
  // used while the request is in flight, and as the reset() fallback
  default: () => ({ id: null, name: null, description: null }),
  // default is true; false = no request until reload() or a watch trigger
  immediate: true,
  // default is true: every fill response is deep-merged over default(),
  // so keys the backend didn't return keep their default values
  mergeDefaults: true,
  // extra query params added to every fill request
  params: { with: 'labels' },
  // called after every successful fill (initial, reload(), watch-triggered)
  onSuccess: (response, model) => {},
  // called after every failed fill; aborted (superseded) requests are ignored
  onError: (error, model) => {},
  // called after every fill settles, success or failure — like Promise.finally();
  // aborted (superseded) requests are ignored, same as onSuccess/onError
  onFinish: (model) => {},
  // never sent by a watch-triggered request or refresh() — see "watch" below
  ignore: ['items'],
  // this field's value survives every watch/refresh response — see "watch" below
  lock: ['trackingNumber'],
  // auto re-fetch when data changes — see "watch() — filters, pagination, live search" below
  watch: { fields: ['name'], debounce: 300 },
});

While the request is in flight model.processing === true and model.data is default() (or null without a default). apix.postForm(url, options) is the same, but fills via POST — for complex search endpoints.

Responses are merged over default(). default() describes the shape of your model; a fill response is deep-merged on top of it, so keys the backend didn't return keep their default values and nested v-model targets like model.filter.name never disappear:

const model = apix.getForm('/api/data', {
  default: () => ({
    filter: { name: null, hub_id: null },
    items: [],
  }),
});

// backend returned only {items: [...]} → model.filter is still {name: null, hub_id: null},
// so <el-input v-model="model.filter.name" /> keeps working

The merge is deep (a partial filter object is completed key by key), a null where the default has an object falls back to the default object, explicit scalar nulls from the response are respected, and arrays are always replaced — never merged. The reset() snapshot stores the merged state. Opt out with mergeDefaults: false.

There is no implicit body.data unwrapping — path is always a dot-path against the raw response body, and without a path the whole body is used as-is. This matters for a Laravel paginated collection, which wraps an array in data with links/meta as siblings:

{ "data": [{ "id": 1 }, { "id": 2 }], "links": { "next": "/?page=2" }, "meta": { "total": 42 } }

Since nothing is auto-unwrapped, links/meta are never at risk of being silently dropped — you decide exactly how much of the body ends up in model.data:

interface PageDto {
  data: Array<{ id: number; name: string }>;
  links: { next: string | null };
  meta: { current_page: number; total: number };
}

// no `path` at all — model.data becomes the whole body
const table = apix.getForm<PageDto>('/api/attribute');

table.data.data;       // the items array
table.data.meta.total; // pagination meta, fully reachable

If you don't need links/meta and just want the items array as model.data, ask for it explicitly: path: 'data'. And for the common single-resource convention {data: {...}}}, include data in the path too: path: 'data.item' reads body.data.item.

Edit form — full component

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

interface AttrDto {
  id: number | null;
  name: string | null;
  description: string | null;
}

const model = apix.getForm<AttrDto>('/api/attribute/1', {
  path: 'data.item',
  default: () => ({ id: null, name: null, description: null }),
});

// NOTE: wrap in a function! `model.put(...)` directly in setup would fire immediately.
const submit = () =>
  model
    .put('/api/attribute/1')
    .then(({ message }) => alert(message))
    .catch(() => {
      // validation errors are already in model.errors — nothing to do here
    });
</script>

<template>
  <form @submit.prevent="submit">
    <!-- magic prop: model.name ≡ model.data.name -->
    <input v-model="model.name" :disabled="model.processing" />
    <span v-if="model.errors.name">{{ model.errors.name }}</span>

    <!-- explicit path works too -->
    <input v-model="model.data.description" :disabled="model.processing" />
    <span v-if="model.error('description')">{{ model.error('description') }}</span>

    <button type="submit" :disabled="model.processing">Save</button>

    <!-- restore the first server response -->
    <button type="button" @click="model.reset()">Reset</button>

    <!-- or reset specific fields only -->
    <button type="button" @click="model.reset(['name'])">Reset name</button>
  </form>
</template>

Model API

Member Description
model.data T | null — the payload (object or array; arrays: model.data.map(...))
model.<key> magic prop ≡ model.data.<key> (objects only, reserved names win — see below)
model.processing reactive boolean, true while any model request is in flight
model.message message from the last response (success or validation error)
model.errors flat map, Laravel keys: errors['name.en.label'] → first message string
model.error(key) first error for key; falls back to key.* sub-errors (error('name') finds name.en.label)
model.hasErrors / model.isDirty reactive booleans
model.get(path) value by dot-path; objects come back as detached deep clones
model.setData(data) replace data entirely (object or array)
model.setErrors(errors) / model.clearErrors(keys?) manage errors manually
model.reset(keys?) restore the snapshot: first server response, else default(); reset(['name']) for specific paths
model.post/put/patch/delete(url?, opts?) submit model.data (or opts.data / opts.only: ['name']), returns the response Promise
model.reload(url?) re-run the fill request as configured — a fresh load, no ignore/lock applied
model.refresh(opts?) re-send with the CURRENT state — a manual trigger for a "Search" button, see watch below
model.transformResponse(fn) / model.transformRequest(fn) reshape server data ↔ model data, see below

Submitting clears previous errors; any 4xx response carrying an errors object in the body (Laravel's 422, or any backend using the same shape on 400-499) fills model.errors and model.message and rejects the promise, so catch is still yours. 5xx bodies are never treated as validation errors — only model.message is surfaced.

Reserved names. These model members always win over data keys: data, errors, message, processing, hasErrors, isDirty, get, error, setData, setErrors, clearErrors, reset, post, put, patch, delete, reload, refresh, transformRequest, transformResponse. A data field with such a name is still there — via model.data.<key>.

Passing a model to a child component

A model is a plain object reference — hand it to a child and every mutation stays shared and reactive. Two equivalent ways:

// ChildForm.vue — via defineModel (parent: <ChildForm v-model="model" />)
import type { ApixModel } from '@diephp/vue-apix';

// type it through the macro GENERIC — not a cast on the left side,
// otherwise .value loses the model members (errors, processing, data…)
const form = defineModel<ApixModel<AttrDto>>({ required: true });
// ChildForm.vue — via a plain prop (parent: <ChildForm :form="model" />)
import type { ApixModel } from '@diephp/vue-apix';

const props = defineProps<{ form: ApixModel<AttrDto> }>();
<!-- child template is identical either way -->
<el-form-item label="Name" :error="form.errors.name">
  <el-input v-model="form.name" clearable />
</el-form-item>

One rule with defineModel: never REPLACE the model from the child (form.value = ...) — mutate it instead (form.value.setData(...), form.value.reset()). The child mutates the same model instance the parent owns, so a separate :errors prop is unnecessary — form.errors is already reactive inside the child.

The classic SPA page: a table, a filter form and a paginator. One model holds all of it; watch (a getForm/postForm/form option, not a chained method) re-sends the request whenever the watched fields change:

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

interface ItemDto {
  id: number;
  name: string;
  description: string;
}

interface PageDto {
  items: ItemDto[];
  filters: { name: string | null; status: string | null };
  paginator: { page: number; total: number; per_page: number };
}

const table = apix.getForm<PageDto>('/api/attribute', {
  // the API wraps its payload in `data` (see the JSON below) — spell it out,
  // there is no implicit body.data unwrapping
  path: 'data',
  default: () => ({
    items: [],
    filters: { name: null, status: null },
    paginator: { page: 1, total: 0, per_page: 15 },
  }),
  // items is response-only data — it must never round-trip to the server
  ignore: ['items'],
  watch: { fields: ['filters', 'paginator.page'], debounce: 300 },
});

const lastPage = () => Math.max(1, Math.ceil(table.paginator.total / table.paginator.per_page));

// changing a filter should always jump back to the first page
const applyFilter = () => (table.paginator.page = 1);
</script>

<template>
  <div>
    <!-- typing here fires ONE debounced request: GET /api/attribute?filters[name]=...&paginator[page]=1 -->
    <input v-model="table.filters.name" placeholder="Search…" @input="applyFilter" />

    <select v-model="table.filters.status" @change="applyFilter">
      <option :value="null">All</option>
      <option value="active">Active</option>
      <option value="archived">Archived</option>
    </select>

    <span v-if="table.processing">Loading…</span>

    <table>
      <tr v-for="item in table.items" :key="item.id">
        <td>{{ item.id }}</td>
        <td>{{ item.name }}</td>
      </tr>
    </table>

    <!-- pagination: just mutate the watched path, the table refetches itself -->
    <button :disabled="table.paginator.page <= 1" @click="table.paginator.page--">Prev</button>
    <span>{{ table.paginator.page }} / {{ lastPage() }} · total {{ table.paginator.total }}</span>
    <button :disabled="table.paginator.page >= lastPage()" @click="table.paginator.page++">Next</button>

    <!-- back to the first-response state; the watcher notices and refetches clean data -->
    <button @click="table.reset()">Reset filters</button>
  </div>
</template>

Expected server response shape for the example above (Laravel-style):

{
  "data": {
    "items": [{ "id": 1, "name": "Attribute 1", "description": "..." }],
    "filters": { "name": null, "status": null },
    "paginator": { "page": 1, "total": 42, "per_page": 15 }
  }
}

watch — the auto-refetch trigger

apix.getForm(url, {
  watch: true, // watch every field except `ignore`, no debounce
  // or, explicitly:
  watch: {
    fields: ['filters', 'paginator.page'], // omit to watch everything except `ignore`
    debounce: 300,                          // default 0
    deep: false,                            // default false, see below
  },
});
  • fields only controls what triggers an auto-refetch. It never limits what gets sent — every fill request (auto-triggered or via refresh()) always sends every field except ignore, regardless of fields. This is what lets a field be sent (and available to a "Search" button) without auto-firing on every keystroke — see ignore below.
  • Omit fields to watch every field except ignore (arrays are always skipped automatically — see ignore).
  • A field pointing at a plain object ('paginator') is watched leaf-by-leaf automatically when fields is omitted, so paginator.page++ triggers without deep. When you list an object bare in explicit fields, deep decides whether ANY of its nested changes trigger (true) or only a full reassignment of that object does (false, the default) — list the specific leaf ('paginator.page') instead of the whole object for precise, deep-independent reactivity.
  • A new trigger aborts the previous in-flight request (AbortController) — the last change wins, out-of-order responses can't overwrite fresh data.
  • reset() restores the first-response snapshot; if that actually changes a watched field, the request is re-sent — reset filters, the table refreshes itself.

ignore — never sent, never watched by default

apix.getForm(url, {
  ignore: ['items'], // response-only data: never sent, never auto-watched
  watch: true,
});
  • Fields in ignore are never sent by a watch-triggered request or refresh(), no matter what. Use it for response-only data (a fetched list, pagination total/meta you don't want echoed back).
  • When watch.fields is omitted, ignore also removes the field from the default "watch everything" set. Arrays are excluded from that default set automatically, even without ignore — an accidentally un-ignored fetched list would otherwise get serialized into the query string on every request.
  • A field listed in both ignore and watch.fields still triggers (fields wins for triggering) but is still never sent (ignore always wins for sending) — a rare but supported combination.

lock — protect a field from being overwritten by the response

By default a fill response fully overwrites the model (merged with default() for anything the response didn't return, exactly like any other fill — see "Responses are merged over default()" above). Nothing about a field being watched or sent protects it from this. lock is the explicit opt-in when that's not what you want:

apix.getForm(url, {
  lock: ['trackingNumber'], // this field's local value survives every response
  watch: { fields: ['filters'], debounce: 300 }, // doesn't auto-fire on trackingNumber
});

This is the fix for a field the user is typing into that the backend doesn't echo back: without lock, the very next watch-triggered request (fired by some other field changing) would merge trackingNumber back to its default() value mid-edit. lock only affects watch-triggered and refresh()-triggered fills — the very first load is unaffected (there's nothing local to protect yet), and reset() is unaffected too (it's an explicit, direct action, not a server response).

lock works even with no watch at all — a plain search box built with just immediate: false + lock + a manual refresh() on submit keeps its typed value across that refresh() the same way:

const searchForm = apix.getForm('?action=search_parcel', {
  default: () => ({ tracking_number: null }),
  lock: ['tracking_number'],
  immediate: false,
});

// <el-input v-model="searchForm.tracking_number" />
// on submit: searchForm.refresh() — tracking_number survives even if the
// response doesn't include that field at all

refresh() — the "Search" button

model.refresh();
// or, for this one call only (not persisted, not merged into future calls):
model.refresh({ ignore: ['paginator.page'], lock: ['trackingNumber'] });
  • Sends every field except ignore and applies lock, cancelling a pending debounce — watch doesn't need to be configured at all for this: a plain search box with immediate: false and lock: ['trackingNumber'], no watch, still keeps its typed value across refresh() even when the backend doesn't echo it back.
  • options.ignore/options.lock extend the form-level lists for this call only — handy for "Search resets to page 1": omit paginator.page for just this click so the server defaults to page 1, without changing the form's permanent configuration.
  • model.reload() is different: it re-runs the original fill request with no field-derived payload and no lock at all — it resets the view rather than searching with the current state.

Cleanup — no unwatch() needed inside a component

Vue automatically stops a watch() effect created inside a component's setup() once that component unmounts — no manual cleanup call exists or is needed. This only matters if you create a watch-enabled model outside any component (e.g. a module-level store shared across the whole app) — there it lives for as long as the module does, by design.

With an automatic initial fill (immediate not false), the underlying watch() is registered only once that fill settles — not at construction — so its own response is never mistaken for a change to react to. The component's effect scope is still captured synchronously at construction, so auto-cleanup on unmount works exactly the same either way.

Live search (minimal)

interface SearchDto {
  results: Array<{ id: number; title: string }>;
  query: string | null;
}

const search = apix.getForm<SearchDto>('/api/search', {
  default: () => ({ results: [], query: null }),
  // don't hit the API until the user types something
  immediate: false,
  watch: { fields: ['query'], debounce: 400 },
});

// template: <input v-model="search.query" /> — that's the whole feature

Global hooks and error normalization

// onError may RETURN an Error to replace the original one — the replacement goes
// to the next hooks, to model.errors/message and to the rejected promise.
// Handy when a backend uses its own validation format:
apix.onError((error) => {
  if (error instanceof ApixError && error.status === 400 && error.body?.details) {
    return new ApixError(422, {
      message: error.body.error_text,
      errors: error.body.details,
    });
  }
});
// Global body transformer: applied to EVERY parsed response body (success and
// error statuses) before it is used anywhere — ApixError.message/errors included.
// Return a new body, or mutate it in place and return nothing.
apix.transformResponse((body) => {
  // example: the backend sends messages as an array — lift the first one to body.message
  if (body?.messages?.[0]?.message) body.message = body.messages[0].message;
});

Model transformers — reshape server data

When the API shape is awkward (nested, legacy), unwrap it into a comfortable model shape and wrap it back on submit:

// response: {data: {item: {attr: {name: {value: 'x'}}}}} ↔ model shape: {name: 'x'}
const model = apix
  .getForm<{ name: string | null }>('/api/attribute/1', 'data.item')
  .transformResponse((data) => ({ name: data?.attr?.name?.value ?? null }))
  .transformRequest((data) => ({ attr: { name: { value: data.name } } }));

model.name = 'edited';

// body sent: {attr: {name: {value: 'edited'}}}
model.put();
  • transformResponse(data, response) runs on every fill (initial, reload(), watch) after path extraction and before the reactive data is touched — no false watch triggers. The reset() snapshot stores the transformed shape.
  • transformRequest(data) runs on the payload of post/put/patch/delete (skipped when you pass an explicit opts.data) and on watch payloads. It receives a detached clone — mutations never touch the model.
  • Both are also accepted as getForm/postForm/form options.

TypeScript

const model = apix.getForm<AttrDto>('/api/attribute/1', 'data.item');

// typed via magic props: ApixModel<AttrDto> = ApixFormModel<AttrDto> & AttrDto
model.name;

// full escape hatch
model.data?.description;

const list = apix.getForm<ItemDto[]>('/api/attribute', 'data.items');

// arrays: no magic props, iterate .data directly
list.data?.map((item) => item.name);

License

MIT

Related Packages

@sampullman/vue3-fetch-api

Small fetch wrapper for quickly prototyping API clients

1,178 4
vform

Handle Laravel-Vue forms and validation with ease.

163,759 600
@konnec/vue-eloquent

An interface for Vue and Laravel Eloquent through API

2,985 2
@aspectus/resource

Composable fetch API interface.

3,395

Version History

Version Released Vue License
0.3.4 ^3.3.0 MIT
0.3.3 ^3.3.0 MIT
0.3.2 ^3.3.0 MIT
0.3.0 ^3.3.0 MIT
0.2.0 ^3.3.0 MIT