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. spawnflow/spawnflow-laravel

ActiveLibrary

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

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

10PHPCI passing

Since Mar 14Pushed 1w agoCompare

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

READMEChangelogDependenciesVersions (1)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 = 1 config entry + 1 enum. No scaffold.**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)

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

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

---

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

[](#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 subject
- `GET /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
--------------

[](#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.

---

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

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

[](#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
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

21

—

LowBetter than 21% of packages

Maintenance64

Regular maintenance activity

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

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.

### 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 (4 commits)")

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

PHPackages © 2026

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