spawnflow/spawnflow-laravel

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

Maintainers

Package info

github.com/keybrdist/spawnflow-laravel

pkg:composer/spawnflow/spawnflow-laravel

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

dev-main 2026-07-08 10:23 UTC

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):

  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

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

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

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

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.