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)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
This package is auto-updated.
Last update: 2026-05-14 22:07:51 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
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
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.
Schema Endpoint
Enable the built-in schema routes to serve field permission schemas to your frontend:
// config/spawnflow.php 'schema_routes' => true, 'schema_middleware' => ['auth:api'],
This registers:
GET /spawnflow/schema/{subject}— returns all context variants for the subjectGET /spawnflow/schema/{subject}/{id}— returns the resolved variant for a specific record
All variants response:
{
"resource": "posts",
"variants": [
{
"context": "owner:draft",
"editable_fields": ["title", "body", "status"],
"validation": { "title": "required|string|max:255", "body": "nullable|string", "status": "in:draft,published" },
"visible_fields": ["id", "title", "body", "status", "owner_id", "created_at", "updated_at"]
}
]
}
Resolved variant response:
{
"resource": "posts",
"context": "owner:draft",
"fields": {
"title": { "editable": true, "rules": "required|string|max:255" },
"body": { "editable": true, "rules": "nullable|string" },
"status": { "editable": true, "rules": "in:draft,published" },
"owner_id": { "editable": false, "rules": null }
}
}
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.
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, ], // 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.