vifrost/laravel

Easily build API with Laravel Framework

Maintainers

Package info

github.com/asdfprah/vifrost

Language:TypeScript

pkg:composer/vifrost/laravel

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-08-24 01:10 UTC

This package is auto-updated.

Last update: 2026-08-24 01:45:42 UTC


README

Vifrost generates a REST API — routes, controllers, FormRequests — directly from your Laravel Eloquent models, and ships a typed frontend ecosystem (@vifrost/client, @vifrost/codegen, @vifrost/vue) to consume it without hand-writing HTTP calls.

This library is NOT stable yet. Use it at your own risk.

This README is split in three parts:

  1. Backend (Laravel package)
  2. Models in vanilla JS/TS (@vifrost/client), with and without the Registry
  3. Vue (@vifrost/vue)

1. Backend (Laravel package)

Requirements

  • PHP ^8.3
  • Laravel ^12.0 or ^13.0 (illuminate/database, illuminate/support, illuminate/container, illuminate/console)
  • spatie/laravel-query-builder ^6.0

Installation

composer require vifrost/laravel

The service provider (Vifrost\Laravel\Providers\VifrostServiceProvider) is auto-discovered. Optionally publish the config file to tweak it:

php artisan vendor:publish --tag=config

This publishes config/vifrost.php:

return [
    // Columns never exposed in generated FormRequest validation rules. '*.column'
    // matches that column on every table; 'table.column' matches just one table.
    'ignored_columns' => ['*.created_at', '*.updated_at', '*.deleted_at'],

    // Directories under app/Models excluded when generating for "all" models.
    'exclude' => ['api'],

    // Where `php artisan vifrost:schema` writes the models/attributes/relations JSON.
    'schema_path' => storage_path('app/vifrost-schema.json'),

    // Off by default: exposes the same JSON at schema_route_path over HTTP, for
    // adapter-generation tooling that needs to read it from a running server
    // instead of from the file the artisan command writes.
    'expose_schema_route' => false,
    'schema_route_path'   => 'api/_schema',

    // Hard ceiling on rows a collection query (index, or a nested relation
    // collection) returns per request, applied even with no ?limit= at all.
    'max_limit' => 100,

    // Per-model overrides, keyed by model FQCN. Set a model's value to null to
    // exempt it from pagination entirely (e.g. a small, rarely-changing lookup table).
    'max_limit_per_model' => [
        // \App\Models\Category::class => null,
    ],

    // Eloquent relation methods Vifrost looks for via reflection when mapping
    // a model's relations (see Mapper.php).
    'relations' => [
        'hasMany', 'hasOne', 'belongsTo', 'belongsToMany',
        'hasOneThrough', 'hasManyThrough',
        'morphOne', 'morphMany', 'morphTo', 'morphToMany', 'morphedByMany',
    ],
];

Usage

Generate a FormRequest, Controller, and routes for one model:

# Store/UpdateUserRequest + UserController + routes for \App\Models\User
php artisan vifrost:api User

Or for every model in app/Models (skipping anything under the exclude config):

php artisan vifrost:api

This appends to routes/api.php a flat CRUD group for the model itself...

Route::controller('\App\Http\Controllers\UserController')->group(function () {
    Route::get('user', 'index');
    Route::get('user/{id}', 'show');
    Route::post('user', 'store');
    Route::put('user/{id}', 'update');
    Route::delete('user/{id}', 'destroy');
});

...plus one-hop, read-only routes for each of its relations (see MakeAPICommand):

Route::get('user/{id}/posts', [\App\Http\Controllers\PostController::class, 'index']);
Route::get('user/{id}/posts/{childId}', [\App\Http\Controllers\PostController::class, 'show']);

Deeper relations (posts.comments) aren't routed as their own endpoint — they're reachable in a single request via Spatie's ?include=posts.comments on the flat index/show route instead, since every generated controller wires Spatie\QueryBuilder\QueryBuilder with allowedIncludes for the model's relations.

Filtering and sorting are opt-in per model: the generated controller ships ->allowedFilters([]) commented out, and ->allowedSorts([...]) pre-filled with only ['id', 'created_at', 'updated_at']. Uncomment/extend those in app/Http/Controllers/{Model}Controller.php with whichever columns should be filterable/sortable before relying on .where()/.orderBy() from the JS client (part 2) — otherwise Spatie ignores the corresponding query param.

Collection endpoints (index, and any nested relation collection) accept ?limit= / ?offset=, resolved centrally in Vifrost::getQuery() against the max_limit / max_limit_per_model config (see Pagination). Single-record endpoints (show) never paginate.

You can also generate just one piece:

php artisan vifrost:request StoreUserRequest User   # FormRequest only
php artisan vifrost:controller User                # Controller only (hidden command, used internally)

Exporting the schema for the JS/TS tooling

php artisan vifrost:schema [--path=storage/app/vifrost-schema.json]

Writes a JSON map of every model's table, resource name, maxLimit, columns (type, nullability, defaults, foreign keys) and relations — this is what @vifrost/codegen (part 2) reads to generate typed model classes. If expose_schema_route is enabled, the same JSON is served live at schema_route_path (api/_schema by default) instead of needing the file.

2. Models in vanilla JS/TS (@vifrost/client)

Framework-agnostic runtime: a Model base class, a QueryBuilder compatible with spatie/laravel-query-builder's query params, and an opt-in Registry identity map.

Requirements

  • Node 18+ or any modern browser (only depends on native fetch)
  • TypeScript 5+ recommended

Installation

npm install @vifrost/client
npm install --save-dev @vifrost/codegen   # to generate Model classes from your schema

Generating typed model classes

Export the schema from Laravel (part 1), then generate:

npx vifrost-codegen -i storage/app/vifrost-schema.json -o resources/js/models
Options:
  -i, --input <pathOrUrl>          schema JSON file path, or the exposed schema route URL
  -o, --output <dir>               directory to write the generated .ts files to
  -t, --type-map <path>            JSON file with { "dbTypeName": "tsType" } overrides
  -c, --client-import <specifier>  import specifier for the runtime client (default: "@vifrost/client")

This produces one file per model:

// resources/js/models/Product.ts — generated, do not edit by hand
import { Model } from '@vifrost/client'
import { Category } from './Category.js'

export class Product extends Model {
  static resource = 'product'
  static primaryKey = 'id'
  static maxLimit = 100 // null if this model is exempt from pagination

  declare id: number
  declare name: string
  declare price: string
  declare category_id: number

  category(): Promise<Category | null> {
    return this.toOne('category', Category)
  }
}

2.1 Without a Registry (plain query API)

Call configure() once, then use each model's static/instance methods directly — nothing is tracked or cached beyond the lifetime of each call.

import { configure } from '@vifrost/client'
import { Category } from './models/Category.js'
import { Product } from './models/Product.js'

configure({ baseUrl: 'https://api.example.com/api' })

// find / all
const product = await Product.find(1)
const allActive = await Product.query().where('status', 'active').limit(10).get()

// Eloquent-like chaining: where / orderBy / with / limit / offset (take/skip aliases)
const products = await Product.query()
  .where('category_id', 3)
  .orderBy('created_at', 'desc')
  .with('category')
  .limit(20)
  .offset(40)
  .get()

// whereId(id).first() — hits the dedicated GET /product/{id} endpoint, not a filtered collection
const one = await Product.query().whereId(1).first()

// relations, from an already-loaded instance
const category = await product.category()          // BelongsTo/HasOne/MorphOne -> Model | null
const relatedProducts = await category.products()   // HasMany/BelongsToMany/etc -> Model[]

// create / update / delete
const created = await new Product({ name: 'New', price: '9.99', category_id: 3 }).save()
created.price = '12.99'
await created.save()          // PUT, since the primary key is now set
await created.delete()

If a model declares a non-null static maxLimit (i.e. the backend requires pagination for it), QueryBuilder fails fast client-side, before any network request:

await Product.all()
// throws: "product requires a limit (max 100). Call .limit(n) (n <= 100) before .get()."

await Product.query().limit(1000).get()
// throws: ".limit(1000) exceeds the max allowed for product (100)."

This is stricter than the server, which would silently clamp an over-limit request — the point is to catch it in development instead of silently returning a truncated page.

2.2 With a Registry (identity map, kept in sync)

Pass a Registry to configure() to opt into an identity map: every find/all/ query/relation fetch registers its rows, save()/delete() publish into the same map, and anything can subscribe() to a specific resourceType:id and get notified in place — no polling, ready to be driven later by a WebSocket/SSE "record changed" event via registry.resync(resourceType, id).

import { configure, Registry } from '@vifrost/client'
import { Product } from './models/Product.js'

const registry = new Registry()
configure({ baseUrl: 'https://api.example.com/api', registry })

const product = await Product.find(1) // now tracked under "product:1"

const unsubscribe = registry.subscribe('product', 1, (event) => {
  if (event.type === 'updated') {
    console.log('fresh value:', event.value)
  } else {
    console.log('record 1 was deleted')
  }
})

// Any later save() on a Product with id 1 — from anywhere in the app — notifies this listener:
product.name = 'Renamed'
await product.save() // -> listener fires with { type: 'updated', value: product }

await product.delete() // -> listener fires with { type: 'deleted' }, then the entry is untracked

unsubscribe()

Without a Registry configured, save()/delete() skip this entirely — the modularity is deliberate: a consumer who only wants the plain query API never pays for tracking it doesn't use.

3. Vue (@vifrost/vue)

useModel/useQuery composables wrapping @vifrost/client, reactive to both their inputs (a ref/getter id or query) and — when a Registry is configured — to Registry events for the rows they're currently holding.

Requirements

  • Vue ^3.3.0
  • @vifrost/client configured somewhere in your app bootstrap (as in part 2)

Installation

npm install @vifrost/vue

3.1 Without a Registry

useQuery/useModel work the same whether or not a Registry is configured — the difference is only whether results stay in sync after the initial fetch (3.2).

<script setup lang="ts">
import { useModel, useQuery } from '@vifrost/vue'
import { Category } from './models/Category.js'
import { Product } from './models/Product.js'
import { ref } from 'vue'

const productId = ref(1)

// useModel(ModelClass, id) — id can be a plain value, a ref, or a getter
const { data: product, error, isLoading, refetch } = useModel(Product, productId)

// useQuery(builder) — pass a GETTER (not a resolved builder) whenever the query
// depends on something reactive, so it re-runs when that dependency changes
const { data: activeInCategory } = useQuery(() =>
  Product.query().where('category_id', productId.value).where('status', 'active').orderBy('name')
)

// A plain builder is fine too when the query never changes
const { data: categories } = useQuery(Category.query().limit(50))
</script>

<template>
  <p v-if="isLoading">Loading…</p>
  <p v-else-if="error">{{ error }}</p>
  <p v-else>{{ product?.name }}</p>
</template>

Both composables return { data, error, isLoading, refetch }data is null (for useModel) or [] (for useQuery) until the first response resolves, and refetch() re-runs the same query on demand.

3.2 With a Registry

Configure a Registry once during app bootstrap (part 2.2) — from a component's point of view nothing else changes. useQuery/useModel subscribe to every row they're currently displaying and patch it in place on an updated/deleted event, without an extra network round-trip:

// main.ts
import { configure, Registry } from '@vifrost/client'

configure({ baseUrl: 'https://api.example.com/api', registry: new Registry() })
<script setup lang="ts">
import { useModel } from '@vifrost/vue'
import { Product } from './models/Product.js'

const { data: product } = useModel(Product, 1)

async function rename() {
  product.value!.name = 'Renamed'
  await product.value!.save() // any other component's useModel(Product, 1) updates too
}
</script>

Because useModel(Product, 1) is built on useQuery (it resolves through Product.query().whereId(1), the same endpoint find() uses), any two components that fetched product 1 independently share the same Registry entry — a save() in one updates the other's data reactively, with no manual event wiring.

Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

License

MIT