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

v0.3.4

Published

Tiny form/model/api toolkit for Vue 3 — fetch-based, Laravel-friendly, zero dependencies

Downloads

720

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

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

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
  },
});
  • 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