@diephp/vue-apix
v0.3.4
Published
Tiny form/model/api toolkit for Vue 3 — fetch-based, Laravel-friendly, zero dependencies
Downloads
720
Maintainers
Readme
@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-apixyarn add @diephp/vue-apixpnpm add @diephp/vue-apixRequires 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 workingThe 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.
Laravel pagination — data + links + meta
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 reachableIf 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.
watch() — filters, pagination, live search
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
},
});fieldsonly controls what triggers an auto-refetch. It never limits what gets sent — every fill request (auto-triggered or viarefresh()) always sends every field exceptignore, regardless offields. This is what lets a field be sent (and available to a "Search" button) without auto-firing on every keystroke — seeignorebelow.- Omit
fieldsto watch every field exceptignore(arrays are always skipped automatically — seeignore). - A field pointing at a plain object (
'paginator') is watched leaf-by-leaf automatically whenfieldsis omitted, sopaginator.page++triggers withoutdeep. When you list an object bare in explicitfields,deepdecides 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
ignoreare never sent by a watch-triggered request orrefresh(), no matter what. Use it for response-only data (a fetched list, paginationtotal/metayou don't want echoed back). - When
watch.fieldsis omitted,ignorealso removes the field from the default "watch everything" set. Arrays are excluded from that default set automatically, even withoutignore— an accidentally un-ignored fetched list would otherwise get serialized into the query string on every request. - A field listed in both
ignoreandwatch.fieldsstill 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 allrefresh() — 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
ignoreand applieslock, cancelling a pending debounce —watchdoesn't need to be configured at all for this: a plain search box withimmediate: falseandlock: ['trackingNumber'], nowatch, still keeps its typed value acrossrefresh()even when the backend doesn't echo it back. options.ignore/options.lockextend the form-level lists for this call only — handy for "Search resets to page 1": omitpaginator.pagefor 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 nolockat 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 featureGlobal 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) afterpathextraction and before the reactive data is touched — no false watch triggers. Thereset()snapshot stores the transformed shape.transformRequest(data)runs on the payload ofpost/put/patch/delete(skipped when you pass an explicitopts.data) and on watch payloads. It receives a detached clone — mutations never touch the model.- Both are also accepted as
getForm/postForm/formoptions.
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
