spawnflow / spawnflow-laravel
Fluent, chain-based API request lifecycle for Laravel. Spawn context, resolve subjects, gate ownership, validate, persist — in one expression.
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- illuminate/validation: ^11.0|^12.0
Requires (Dev)
- laravel/mcp: ^0.8.2
- livewire/livewire: ^4.3
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
Suggests
- laravel/mcp: Required to expose the SpawnFlow MCP server (spawnflow.mcp.enabled).
- livewire/livewire: Enables the <livewire:spawnflow-form> server-rendered renderer (^3.0|^4.0)
This package is auto-updated.
Last update: 2026-07-08 10:23:40 UTC
README
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?
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.
| Trait | What 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 = 1 config entry + 1 enum. No scaffold. |
| Reads like a sentence | spawn → auth → resolve → ask → fields → validate → save → present |
Built for LLM-assisted codebases
Spawnflow is intentionally optimized for codebases where AI writes the majority of code.
| Property | Why it matters |
|---|---|
| One pattern to repeat | An LLM doesn't need to coordinate 5 file types per resource |
| ~500 lines total surface | The entire Flow class + a context enum fits in a single context window |
Exhaustive match expressions |
PHP enums enforce every permission branch is handled — no forgotten cases |
| Minimal diff surface | Adding a resource is mechanical to generate, easy to review |
| Explicit chain, no magic | No middleware, observers, or policies to hallucinate — the chain says exactly what happens |
Installation
composer require spawnflow/spawnflow-laravel
Publish the config:
php artisan vendor:publish --tag=spawnflow-config
Quick Start
The 3-command path
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
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
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
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
Every method returns $this (fluent) unless noted as terminal.
| Method | Signature | Description |
|---|---|---|
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
| Method | Returns |
|---|---|
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 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
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
// config/spawnflow.php 'contexts' => [ 'posts' => \App\Spawnflow\PostContext::class, ],
How it works
When you call ->fields(PostContext::class):
- The enum's
resolve()inspects the user and record to pick a case (e.g.,OwnerDraft) ->validate()uses that case'svalidation()rules->save()strips any fields not ineditableFields()->present()filters the response tovisibleFields()
If the resolved case has zero editable fields (e.g., Viewer), the chain throws ForbiddenFieldAccessException immediately.
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<string, never> };
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 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.
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
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 subjectGET /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
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 becomenumber, nullability respected)postsFieldMeta— widgets, labels, options, relation metadata for rendererspostsOwnerDraftSchema, … — one Zod schema per context variant, compiled from the structured rulespostsSchemas/postsVariants— context-keyed maps of schemas and editable/visible field listsPostsVariant— 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<PostsFields, 'title' | 'body' | 'status'> } | { context: 'owner:published'; editable: Pick<PostsFields, 'title'> } | { context: 'viewer'; editable: Record<string, never> };
The generator and the live endpoint emit through one serializer — generated artifacts and API responses cannot drift.
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.
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.
<livewire:spawnflow-form subject="posts" :record-id="$post->id" />
- 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
Flowchain 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)
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/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)
@spawnflow/react-shadcn renders complete forms from the schema contract — shadcn-styled widgets, react-hook-form + Zod under the hood:
import { SpawnForm, createHttpClient } from '@spawnflow/react-shadcn'; const client = createHttpClient({ baseUrl: '/api', headers: () => ({ Authorization: `Bearer ${token}` }) }); <SpawnForm client={client} subject="posts" id={42} onSubmit={...} />
- Widgets picked from descriptors (enum → select, relation → async searchable combobox fed by the options endpoint,
confirmedrules pair a confirmation input automatically) - Client-side validation compiled at runtime from the structured rules;
serverOnlyrules 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
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
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
$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
(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
->save($data) ->after(fn ($f) => CampaignCreated::dispatch($f->getInstance())) ->present();
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
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
| Category | Why | Chain still helps? |
|---|---|---|
| Aggregation / analytics | GROUP BY, date bucketing, cross-table joins | Yes — spawn → auth → resolve → ask for identity + ownership, then break out |
| External service calls | Spotify lookups, payment processing, S3 signed URLs | Yes — spawn → auth for identity context |
| Webhook receivers | No authenticated user, no subject | No — these are fire-and-forget event handlers |
| File / binary operations | Uploads, zip streams, CSV exports | No — response isn't a model |
Even for custom endpoints, the chain's escape hatches (getUser(), getInstance(), etc.) let you reuse auth and ownership without reimplementing them.
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.
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, ], // 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 (future). 'generator' => [ 'output_path' => base_path('../frontend/src/generated'), 'type_format' => 'typescript', 'validation' => 'zod', 'emit_client' => true, 'emit_unions' => true, ], ];
Testing
Run the package tests:
cd packages/spawnflow
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.
License
MIT. See LICENSE.