spawnflow-laravel maintained by 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?
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.