PHPackages                             spawnflow/spawnflow-laravel - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. spawnflow/spawnflow-laravel

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

spawnflow/spawnflow-laravel
===========================

Fluent, chain-based API request lifecycle for Laravel. Spawn context, resolve subjects, gate ownership, validate, persist — in one expression.

v0.2.1(4w ago)1402MITPHPPHP ^8.2CI passing

Since Mar 14Pushed 4w agoCompare

[ Source](https://github.com/keybrdist/spawnflow-laravel)[ Packagist](https://packagist.org/packages/spawnflow/spawnflow-laravel)[ Docs](https://github.com/keybrdist/spawnflow-laravel)[ RSS](/packages/spawnflow-spawnflow-laravel/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (8)Versions (4)Used By (0)

Spawnflow
=========

[](#spawnflow)

**Your entire API request lifecycle in one fluent chain.**

```
(new Flow)
    ->spawn($request)->auth()
    ->resolve('posts')
    ->ask('POST', $id)
    ->fields(PostContext::class)
    ->validate()
    ->save($request->all())
    ->present();
```

Authentication, subject resolution, ownership verification, field-level permissions, validation, and persistence — one expression that reads like a sentence.

---

Why use this?
-------------

[](#why-use-this)

In conventional Laravel, adding a new API resource means creating a **controller**, **form request**, **policy**, **resource**, and wiring routes — five or more files that must agree on the same truth. Spawnflow replaces that with a single config entry and an optional context enum.

TraitWhat it means**Runtime fluent chain**The entire request lifecycle is one method chain, not spread across files**Dynamic subject resolution**Models resolve from a URL segment via a registry — no per-resource controllers**Inline authorization**Ownership and field permissions live in the chain, not in separate policy files**Minimal file surface**New resource = one command (`spawnflow:resource --generate`), or 1 config entry + 1 enum by hand**Reads like a sentence**`spawn → auth → resolve → ask → fields → validate → save → present`### Built for LLM-assisted codebases

[](#built-for-llm-assisted-codebases)

Spawnflow is intentionally optimized for codebases where AI writes the majority of code.

PropertyWhy it mattersOne pattern to repeatAn LLM doesn't need to coordinate 5 file types per resource~500 lines total surfaceThe entire `Flow` class + a context enum fits in a single context windowExhaustive `match` expressionsPHP enums enforce every permission branch is handled — no forgotten casesMinimal diff surfaceAdding a resource is mechanical to generate, easy to reviewExplicit chain, no magicNo middleware, observers, or policies to hallucinate — the chain says exactly what happens---

Installation
------------

[](#installation)

```
composer require spawnflow/spawnflow-laravel
```

Publish the config:

```
php artisan vendor:publish --tag=spawnflow-config
```

---

Quick Start
-----------

[](#quick-start)

### The 3-command path

[](#the-3-command-path)

[![The 3-command path: composer require → spawnflow:install → spawnflow:resource Post --generate](demo/3-command-path.gif)](demo/3-command-path.gif)

From an existing table to a registered, permission-aware resource:

```
composer require spawnflow/spawnflow-laravel
php artisan spawnflow:install
php artisan spawnflow:resource Post --generate
```

`--generate` reads the table's real columns and foreign keys and writes `app/Spawnflow/PostFields.php` + `PostContext.php`. The FieldSet carries `#[SpawnSubject('posts', ...)]`, so it registers itself — no config edit. Every additional resource is one more command. Inference is make-time only: the generated files are the canonical, editable declarations.

Deploy-time: `php artisan spawnflow:cache` freezes attribute discovery (mirroring Laravel's bootstrap caches); `spawnflow:clear` unfreezes.

Prefer explicit config? Everything below still works — config entries override discovered ones.

### 1. Register subjects

[](#1-register-subjects)

Map URL segments to Eloquent models in `config/spawnflow.php`:

```
'subjects' => [
    'posts'    => \App\Models\Post::class,
    'comments' => \App\Models\Comment::class,
],
```

### 2. Use Flow in a controller

[](#2-use-flow-in-a-controller)

```
use Spawnflow\Flow;

class PostController extends Controller
{
    public function store(Request $request)
    {
        return (new Flow)
            ->spawn($request)->auth()
            ->resolve('posts')
            ->validate(['title' => 'required|string|max:255'])
            ->save($request->all())
            ->present(statusCode: 201);
    }

    public function update(Request $request, int $id)
    {
        return (new Flow)
            ->spawn($request)->auth()
            ->resolve('posts')
            ->ask('POST', $id)
            ->validate(['title' => 'required|string|max:255'])
            ->save($request->all())
            ->present();
    }

    public function destroy(Request $request, int $id)
    {
        return (new Flow)
            ->spawn($request)->auth()
            ->resolve('posts')
            ->ask('DELETE', $id)
            ->delete($id);
    }
}
```

### 3. Add routes

[](#3-add-routes)

```
Route::middleware('auth:api')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::post('/posts', [PostController::class, 'store']);
    Route::post('/posts/{id}', [PostController::class, 'update']);
    Route::delete('/posts/{id}', [PostController::class, 'destroy']);
});
```

---

Chain API
---------

[](#chain-api)

Every method returns `$this` (fluent) unless noted as terminal.

MethodSignatureDescription`spawn``spawn(Request $request): static`Entry point. Extracts user and request context.`auth``auth(?string $role = null): static`Verifies authentication. Optionally requires a role.`resolve``resolve(string $subject): static`Looks up the subject alias in the registry, instantiates the model.`ask``ask(string $method, int|array $ids): static`Ownership verification. Loads the instance (single ID) or validates all IDs are owned (array).`fields``fields(?string $contextClass = null): static`Resolves field-level permissions from a FieldContext enum. Auto-resolves from config if no class given.`validate``validate(?array $rules = null): static`Validates request data. Uses context rules when active, or accepts explicit rules.`save``save(array $data): static`Creates or updates. Strips disallowed fields when a context is active.`delete``delete(int|array $ids): JsonResponse`**Terminal.** Deletes record(s) by ID.`gate``gate(Closure $callback): static`Arbitrary authorization. Callback receives the Flow; should throw on failure.`after``after(Closure $callback): static`Post-operation hook for side effects (events, jobs, notifications).`present``present(?string $resourceClass = null, int $statusCode = 200): JsonResponse`**Terminal.** Returns JSON response. Filters to visible fields when context is active.`list``list(?int $perPage = null): JsonResponse`**Terminal.** Paginated listing with ownership scoping and validated sorting.### Accessors

[](#accessors)

MethodReturns`getUser()``?User``getInstance()``?Model` — the loaded record (after `ask()` or `save()`)`getSubject()``?Model` — the unhydrated model class instance`getContext()``?FieldContext``getRequest()``?Request`---

Field-Level Permissions
-----------------------

[](#field-level-permissions)

Field-level permissions use **context enums** — PHP enums that encode every role+state combination as a case. Each case declares which fields are editable, what validation rules apply, and which fields are visible in responses.

### Define a context enum

[](#define-a-context-enum)

Scaffold one from the stub:

```
php artisan make:spawnflow-context PostContext   # → app/Spawnflow/PostContext.php
```

Then fill in the `editableFields()`, `validation()`, and `visibleFields()` cases:

```
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User;
use Spawnflow\Contracts\FieldContext;

enum PostContext: string implements FieldContext
{
    case OwnerDraft     = 'owner:draft';
    case OwnerPublished = 'owner:published';
    case Viewer         = 'viewer';

    public static function resolve(User $user, Model $record): static
    {
        return match (true) {
            $user->id === $record->owner_id && $record->status === 'draft'
                => self::OwnerDraft,
            $user->id === $record->owner_id
                => self::OwnerPublished,
            default
                => self::Viewer,
        };
    }

    public function editableFields(): array
    {
        return match ($this) {
            self::OwnerDraft     => ['title', 'body', 'status'],
            self::OwnerPublished => ['title'],
            self::Viewer         => [],
        };
    }

    public function validation(): array
    {
        return match ($this) {
            self::OwnerDraft => [
                'title'  => 'required|string|max:255',
                'body'   => 'nullable|string',
                'status' => 'in:draft,published',
            ],
            self::OwnerPublished => [
                'title' => 'required|string|max:255',
            ],
            self::Viewer => [],
        };
    }

    public function visibleFields(): array
    {
        return match ($this) {
            self::OwnerDraft, self::OwnerPublished => [
                'id', 'title', 'body', 'status', 'owner_id', 'created_at', 'updated_at',
            ],
            self::Viewer => [
                'id', 'title', 'status',
            ],
        };
    }
}
```

### Register it

[](#register-it)

```
// config/spawnflow.php
'contexts' => [
    'posts' => \App\Spawnflow\PostContext::class,
],
```

### How it works

[](#how-it-works)

When you call `->fields(PostContext::class)`:

1. The enum's `resolve()` inspects the user and record to pick a case (e.g., `OwnerDraft`)
2. `->validate()` uses that case's `validation()` rules
3. `->save()` strips any fields not in `editableFields()`
4. `->present()` filters the response to `visibleFields()`

If the resolved case has zero editable fields (e.g., `Viewer`), the chain throws `ForbiddenFieldAccessException` immediately.

### The discriminated union concept

[](#the-discriminated-union-concept)

Each context enum case is a **discriminated union variant**. The `value` string (e.g., `"owner:draft"`) acts as the discriminator. This maps directly to TypeScript discriminated unions for frontend type safety:

```
type PostPermissions =
  | { context: 'owner:draft'; editable: { title: string; body: string; status: string } }
  | { context: 'owner:published'; editable: { title: string } }
  | { context: 'viewer'; editable: Record };
```

---

Generic Controller
------------------

[](#generic-controller)

`SpawnflowController` handles CRUD for **any** registered subject with 4 routes:

```
use Spawnflow\SpawnflowController;

Route::middleware('auth:api')->prefix('v2')->group(function () {
    Route::get('/{subject}', [SpawnflowController::class, 'index']);
    Route::post('/{subject}', [SpawnflowController::class, 'store']);
    Route::post('/{subject}/{id}', [SpawnflowController::class, 'update']);
    Route::delete('/{subject}/{id}', [SpawnflowController::class, 'destroy']);
});
```

Adding a new resource requires **zero new controllers and zero new routes** — just a config entry and optionally a context enum.

---

Field Descriptors
-----------------

[](#field-descriptors)

Field descriptors make fields **type-aware**. A `FieldSet` class per subject declares what each field *is* — type, widget, label, base validation rules, enum options, relation semantics — so the schema endpoint and the generator can serve frontends everything needed for form rendering and client-side validation, from one declaration.

```
use Spawnflow\Schema\Field;
use Spawnflow\Schema\FieldSet;

class PostFields extends FieldSet
{
    public static function fields(): array
    {
        return [
            Field::string('title')->rules('required|string|max:255'),
            Field::text('body')->nullable(),
            Field::enum('status', PostStatus::class),          // options + in: rule + select widget, derived
            Field::belongsTo('group_id', Group::class)         // FK: searchable select, exists rule
                ->display('name')->searchable(),
            Field::email('email')->rules('required|unique:users,email'),
            Field::bool('is_active')->wire('on_off'),          // declared wire coercion, both sides
            Field::password('password'),                       // write-only by default
        ];
    }
}
```

Register it:

```
// config/spawnflow.php
'fields' => [
    'posts' => \App\Spawnflow\PostFields::class,
],
```

Contexts keep referencing fields by name; the schema layer joins names to descriptors. Subjects without a `FieldSet` fall back to minimal inferred descriptors.

---

Eligibility Rules
-----------------

[](#eligibility-rules)

Context enums answer **who** may touch a field in **what record state**. Eligibility rules answer the orthogonal question: *given the form's current values, is this field visible/enabled?* Rules are declared on descriptors, serialized into the contract, and evaluated identically in PHP and JS — never put role checks in rules.

```
Field::string('company_name')
    ->visibleWhen(['==' => [['var' => 'type'], 'business']]),

Field::string('vat_number')
    ->enabledWhen(['and' => [
        ['==' => [['var' => 'type'], 'business']],
        ['in' => [['var' => 'country'], ['DE', 'FR', 'NL']]],
    ]]),

Field::string('discount_code')->visibleWhen(...)->serverResolved(),  // verdict only, no client re-eval
```

- **Condition body is restricted JSON Logic** — fixed op allowlist (`==` strict, `!=`, `>`, `=`, `serverResolved()`.
- **Cross-runtime parity** is pinned by one conformance suite (`resources/conformance/eligibility-fixtures.json`), run by Pest and vitest against the same fixtures.

### Groups

[](#groups)

Groups are first-class eligibility nodes — sections or wizard steps that accept the same rule envelope. A hidden group hides its members regardless of their own rules (AND-composition):

```
class BillingFields extends FieldSet
{
    public static function groups(): array
    {
        return [
            Group::make('company', ['company_name', 'vat_number'])
                ->visibleWhen(['==' => [['var' => 'type'], 'business']]),
        ];
    }
}
```

---

Centralized Validation
----------------------

[](#centralized-validation)

Rules live once — on the field descriptors, with per-context overrides — and every consumer enforces the same thing:

**1. The chain.** `validate()` with no arguments sources rules automatically: explicit argument → context `validation()` per field → field base rules → descriptor-implied rules (type checks, enum `in:`, relation `exists:{table},{key}`, nullability).

```
(new Flow)
    ->spawn($request)->auth()
    ->resolve('posts')
    ->ask('POST', $id)
    ->fields()
    ->validate()          // rules resolved from PostFields + resolved context
    ->save($request->all())
    ->present();
```

**2. FormRequests.** For conventional controllers, bridge into the same rules without adopting the chain:

```
use Spawnflow\Http\SpawnflowFormRequest;

class UpdatePostRequest extends SpawnflowFormRequest
{
    protected string $subject = 'posts';
}
```

`rules()` resolves the caller's context (record loaded from the route's `{id}`, synthetic record on create) and returns the same effective rules the chain enforces and the schema endpoint serves.

**3. Live validation (Precognition).** A request with a `Precognition` header makes `validate()` run validation only and halt the chain with `204 + Precognition: true` (or the standard 422 on failure). `Precognition-Validate-Only: title,email` scopes the pass to specific fields — Laravel Precognition frontend helpers work against Spawnflow routes without duplicated rules.

---

Schema Endpoint
---------------

[](#schema-endpoint)

Enable the built-in schema routes to serve field schemas to your frontend:

```
// config/spawnflow.php
'schema_routes' => true,
'schema_middleware' => ['auth:api'],
```

This registers:

- `GET /spawnflow/schema/{subject}` — descriptors + all context variants for the subject
- `GET /spawnflow/schema/{subject}/{id}` — the resolved variant for a specific record

Responses follow the versioned **schema contract v1** (`docs/schema-contract.md`). Validation rules are serialized structurally — mechanically compilable to Zod — with rules a client can't evaluate (database checks, closures) flagged `serverOnly`.

**Resolved variant response:**

```
{
  "spawnflow": "1",
  "resource": "posts",
  "context": "owner:draft",
  "fields": {
    "title": {
      "type": "string", "widget": "input", "label": "Title",
      "editable": true, "visible": true,
      "rules": [{"rule": "required"}, {"rule": "string"}, {"rule": "max", "params": [255]}]
    },
    "status": {
      "type": "enum", "widget": "select", "label": "Status",
      "options": [{"value": "draft", "label": "Draft"}, {"value": "published", "label": "Published"}],
      "editable": true, "visible": true,
      "rules": [{"rule": "in", "params": ["draft", "published"]}]
    },
    "owner_id": { "type": "int", "widget": "number", "label": "Owner", "editable": false, "visible": true }
  }
}
```

**All variants response** carries descriptors once plus per-variant `editable_fields`, `visible_fields`, and effective structured `rules` — a discriminated union keyed by `context`. See `docs/schema-contract.md` for the full specification.

---

Frontend Generation
-------------------

[](#frontend-generation)

Generate TypeScript types and Zod schemas from the same contract the schema endpoint serves:

```
php artisan spawnflow:generate            # writes to generator.output_path
php artisan spawnflow:generate --path=resources/js/generated
```

Per subject, one module containing:

- `PostsFields` — field-map type from descriptors (enums become literal unions, relations become `number`, nullability respected)
- `postsFieldMeta` — widgets, labels, options, relation metadata for renderers
- `postsOwnerDraftSchema`, … — one Zod schema per context variant, compiled from the structured rules
- `postsSchemas` / `postsVariants` — context-keyed maps of schemas and editable/visible field lists
- `PostsVariant` — the discriminated union over contexts (`emit_unions`)

Plus `index.ts` and an optional thin fetch client (`emit_client`) for `SpawnflowController` + schema routes.

Zod compilation is honest about its limits: rules a client can't check compile to a trailing `/* server: unique */` comment, unmapped rules to `/* unhandled: ... */` — nothing is silently dropped.

```
export const postsOwnerDraftSchema = z.object({
  title: z.string().min(1).max(255),
  body: z.string().nullable().optional(),
  status: z.enum(['draft', 'published']).optional(),
});

export type PostsVariant =
  | { context: 'owner:draft'; editable: Pick }
  | { context: 'owner:published'; editable: Pick }
  | { context: 'viewer'; editable: Record };
```

The generator and the live endpoint emit through one serializer — generated artifacts and API responses cannot drift.

---

Relation Options
----------------

[](#relation-options)

Relation fields get a data source for free. When schema routes are enabled, `GET /spawnflow/options/{subject}/{field}?q=&page=` serves `{value, label}` pages from the related model's display column — ownership-scoped by default, `unscoped()` for shared lookups (countries, plans), `q` search for `searchable()` fields. Relation descriptors carry the `options_url` so renderers wire comboboxes automatically.

---

Live Invalidation (SSE, opt-in)
-------------------------------

[](#live-invalidation-sse-opt-in)

`GET /spawnflow/events` streams `change` signals whenever a subject is written through the Flow chain — **invalidation only, never state**. Clients refetch through the endpoints they already use, so a dropped stream degrades to non-live, never to wrong data.

```
// config/spawnflow.php
'events' => true,   // registered with the schema routes, same middleware
```

```
import { subscribeToChanges } from '@spawnflow-dx/core';

const unsubscribe = subscribeToChanges(
    ({ subject }) => refetch(subject),
    { baseUrl: '/api', subjects: ['posts'] },
);
```

`since[subject]=n` replays missed changes on reconnect. Each open stream holds a PHP worker — set `events_max_polls` to recycle idle streams (EventSource reconnects automatically).

---

Livewire Renderer
-----------------

[](#livewire-renderer)

Server-rendered Laravel apps get the same machinery with zero JavaScript contract: ONE generic schema-interpreting component (no per-form classes), registered automatically when Livewire is installed.

```

```

- Widgets, labels, options come from the FieldSet descriptors; per-role editability from the context enum — identical to the React renderer.
- Eligibility rules re-evaluate **server-side on every update** (the PHP evaluator is the only evaluator; `serverResolved()` needs no special handling here).
- Saves run through the same `Flow` chain as the HTTP path — ownership, variant stripping, rule enforcement, validation. One write path, two renderers.
- Restyle via `php artisan vendor:publish --tag=spawnflow-views`.

Owning the Form Source (shadcn registry)
----------------------------------------

[](#owning-the-form-source-shadcn-registry)

The renderer is also distributed as a shadcn registry item, so the presentational source lands in **your** repo — restyle it, rewrite it, let your LLM edit it. The contract, evaluator, and client stay versioned in `@spawnflow-dx/core`:

```
npx shadcn add https://raw.githubusercontent.com/keybrdist/spawnflow-laravel/main/js/react-shadcn/public/r/spawn-form.json
# → components/spawnflow/SpawnForm.tsx + widgets.tsx, yours to edit
```

Registry artifacts build from the same source as the npm package (`npm run registry:build` in `js/react-shadcn`) — pick whichever distribution fits: dependency (npm) or owned code (registry).

React Renderer (`js/react-shadcn`)
----------------------------------

[](#react-renderer-jsreact-shadcn)

`@spawnflow-dx/react-shadcn` renders complete forms from the schema contract — shadcn-styled widgets, react-hook-form + Zod under the hood:

```
import { SpawnForm, createHttpClient } from '@spawnflow-dx/react-shadcn';

const client = createHttpClient({ baseUrl: '/api', headers: () => ({ Authorization: `Bearer ${token}` }) });

```

- Widgets picked from descriptors (enum → select, relation → async searchable combobox fed by the options endpoint, `confirmed` rules pair a confirmation input automatically)
- Client-side validation compiled at runtime from the structured rules; `serverOnly` rules render a "server-checked" hint and map 422 errors back to fields
- Context-aware: non-editable fields render disabled — the same component shows a different form per resolved permission variant
- Headless-friendly: override any widget via the registry (`widgets={{ combobox: MyCombobox }}`)

### Live demo

[](#live-demo)

```
cd js && npm install && npm run dev
```

Four forms — registration, change password, edit profile, billing details — plus a persona switcher demonstrating one component rendering three different billing forms from `owner:active` / `owner:past_due` / `viewer` contexts. Backed by a mock client serving contract-v1 JSON; swap in `createHttpClient` to point at a real API.

---

Escape Hatches
--------------

[](#escape-hatches)

Use the chain for auth and ownership, then break out for custom logic:

```
public function stats(Request $request, int $id)
{
    $flow = (new Flow)
        ->spawn($request)->auth()
        ->resolve('campaigns')
        ->ask('GET', $id);

    // Break out — use accessors for custom work
    $campaign = $flow->getInstance();
    $user = $flow->getUser();

    $stats = CampaignStatsService::compute($campaign);

    return response()->json($stats);
}
```

### Available accessors

[](#available-accessors)

```
$flow->getUser();      // Authenticated user
$flow->getInstance();  // Loaded record (after ask() or save())
$flow->getSubject();   // Unhydrated model (after resolve())
$flow->getContext();   // Resolved FieldContext enum case
$flow->getRequest();   // Original HTTP request
```

### Custom gates

[](#custom-gates)

```
(new Flow)
    ->spawn($request)->auth()
    ->resolve('campaigns')
    ->ask('POST', $id)
    ->gate(fn ($f) => $f->getInstance()->status === 'draft'
        || throw new StateException('Cannot edit a published campaign'))
    ->save($request->all())
    ->present();
```

### Post-operation hooks

[](#post-operation-hooks)

```
->save($data)
->after(fn ($f) => CampaignCreated::dispatch($f->getInstance()))
->present();
```

---

The Last Mile
-------------

[](#the-last-mile)

Spawnflow handles **~80-85%** of typical API operations. The remaining 15-20% — the "last mile" — is where generic CRUD ends and custom logic begins.

### What Spawnflow absorbs

[](#what-spawnflow-absorbs)

Operations that *seem* custom but decompose into CRUD with smart validation:

- **State transitions** (schedule, publish, archive) — a PATCH that sets `status`. The context enum enforces which transitions are valid.
- **Deep clones** (duplicate a campaign) — the frontend orchestrates a sequence of generic POST calls. No custom endpoint needed.
- **Multi-step creation** (create resource + related records) — the frontend coordinates multiple Spawnflow calls in sequence.

### What stays as custom endpoints

[](#what-stays-as-custom-endpoints)

CategoryWhyChain still helps?**Aggregation / analytics**GROUP BY, date bucketing, cross-table joinsYes — `spawn → auth → resolve → ask` for identity + ownership, then break out**External service calls**Spotify lookups, payment processing, S3 signed URLsYes — `spawn → auth` for identity context**Webhook receivers**No authenticated user, no subjectNo — these are fire-and-forget event handlers**File / binary operations**Uploads, zip streams, CSV exportsNo — response isn't a modelEven for custom endpoints, the chain's escape hatches (`getUser()`, `getInstance()`, etc.) let you reuse auth and ownership without reimplementing them.

---

MCP Server
----------

[](#mcp-server)

The contract is queryable and operable by AI agents over the Model Context Protocol. A thin adapter — every tool delegates to an existing owner (registry, serializer, eligibility, the Flow chain, artisan commands):

```
composer require laravel/mcp
# config/spawnflow.php: 'mcp' => ['enabled' => true]
claude mcp add spawnflow -- php artisan mcp:start spawnflow
```

Dev tools (introspect schemas, evaluate eligibility verdicts, scaffold resources from real tables, regenerate types) register only in the local environment over stdio. Runtime CRUD tools (opt-in `mcp.web`, behind `auth:api`) run the full Flow chain — ownership, contexts, eligibility and wire coercion enforced exactly as over HTTP, returning the persisted record. See [docs/mcp.md](docs/mcp.md).

---

Configuration Reference
-----------------------

[](#configuration-reference)

```
// config/spawnflow.php
return [
    // Maps URL segment aliases to Eloquent model classes.
    'subjects' => [
        // 'posts' => \App\Models\Post::class,
    ],

    // Maps subjects to FieldContext enum classes.
    // Subjects without a context allow all $fillable fields for the owner.
    'contexts' => [
        // 'posts' => \App\Spawnflow\PostContext::class,
    ],

    // Maps subjects to FieldSet classes (type-aware field descriptors).
    'fields' => [
        // 'posts' => \App\Spawnflow\PostFields::class,
    ],

    // #[SpawnSubject] attribute discovery — FieldSets under the discovery
    // path self-register; config entries above override on conflict.
    // Deploy-time: spawnflow:cache freezes the scan, spawnflow:clear unfreezes.
    'discovery' => true,
    'discovery_path' => null, // defaults to app_path('Spawnflow')

    // SSE invalidation channel (GET /spawnflow/events) — opt-in.
    'events' => false,
    'events_poll_interval' => 2,   // seconds between version checks
    'events_max_polls' => null,    // null = stream until client disconnects
    'events_cache_store' => null,  // null = default cache store

    // Database column linking records to their owner.
    'ownership_column' => 'ownerId',

    // Key on the User model used for ownership checks.
    'user_key' => 'id',

    // Enable GET /spawnflow/schema/{subject}/{id?} routes.
    'schema_routes' => false,

    // Middleware applied to schema routes.
    'schema_middleware' => ['auth:api'],

    // Frontend code generation settings (php artisan spawnflow:generate).
    'generator' => [
        'output_path'  => base_path('../frontend/src/generated'),
        'type_format'  => 'typescript',
        'validation'   => 'zod',
        'emit_client'  => true,
        'emit_unions'  => true,
    ],

    // MCP server — disabled by default; 'enabled' exposes stdio,
    // 'web' additionally exposes streamable HTTP behind web_middleware.
    'mcp' => [
        'enabled' => false,
        'web' => false,
        'web_route' => '/mcp/spawnflow',
        'web_middleware' => ['auth:api', 'throttle:60,1'],
    ],
];
```

---

Testing
-------

[](#testing)

Run the package tests:

```
composer install
vendor/bin/pest
```

The test suite uses Orchestra Testbench with an in-memory SQLite database. All fixtures are self-contained — no application models required. DB-introspection tests (`--group=mysql-introspection`) run against a real MySQL service in CI and skip locally without one.

JS side (`js/`): `npm test` runs the eligibility conformance suite (vitest) against the same `resources/conformance/eligibility-fixtures.json` the Pest suite uses, plus typecheck and demo build.

---

Roadmap
-------

[](#roadmap)

See [docs/roadmap.md](docs/roadmap.md) — shipped, in flight, and what stays demand-gated (with the exact triggers that unpark each item).

---

License
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance94

Actively maintained with recent releases

Popularity20

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity39

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~58 days

Total

3

Last Release

29d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/9a34318777339d3963ddf8a67e1acaff7b8d7adb88c1b99ea729a480813d6cc5?d=identicon)[keybrdist](/maintainers/keybrdist)

---

Top Contributors

[![keybrdist](https://avatars.githubusercontent.com/u/3534206?v=4)](https://github.com/keybrdist "keybrdist (56 commits)")

---

Tags

apilaravelfluentauthorizationpermissionscrudChainfield-level-permissions

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/spawnflow-spawnflow-laravel/health.svg)

```
[![Health](https://phpackages.com/badges/spawnflow-spawnflow-laravel/health.svg)](https://phpackages.com/packages/spawnflow-spawnflow-laravel)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M352](/packages/psalm-plugin-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

79227.1M206](/packages/laravel-mcp)[yajra/laravel-oci8

Oracle DB driver for Laravel via OCI8

8723.3M27](/packages/yajra-laravel-oci8)[hasinhayder/tyro

Tyro - The ultimate Authentication, Authorization, and Role &amp; Privilege Management solution for Laravel 12 &amp; 13

6775.1k7](/packages/hasinhayder-tyro)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9922.4M142](/packages/roots-acorn)[api-platform/laravel

API Platform support for Laravel

58174.6k18](/packages/api-platform-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
