Skip to content

About

Flow Engine for Laravel

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Helvetitec FlowEngine

FlowEngine is a small Laravel package for building stateful, resumable flows such as conversations, onboarding journeys, and application workflows.

A flow receives an input, handles the subject's current state, updates its state or context, and persists when execution stops. Each subject can have multiple independent FlowRun records.

Requirements

  • PHP with support for union types (PHP 8.0 or newer)
  • A Laravel application
  • A database connection for the flow_runs table

Laravel discovers the package service provider through Composer package discovery.

Installation

Install the package with Composer:

composer require helvetitec/flowengine

If your application uses Laravel Boost, its boost:install and boost:update commands automatically include FlowEngine's package-specific AI guidelines.

Publish the migration and run it:

php artisan vendor:publish --tag="helvetitec.flowengine.migrations"
php artisan migrate

The migration creates flow_runs, including a polymorphic subject, the flow class, current state, JSON context, cooldown, and active flag.

Quick start

Add HasFlowRuns to an Eloquent model that will own flows:

namespace App\Models;

use Helvetitec\FlowEngine\Traits\HasFlowRuns;
use Illuminate\Database\Eloquent\Model;

class Chat extends Model
{
    use HasFlowRuns;
}

Create a flow by extending FlowEngine. The doRun method is called with the current input. Use the subject and helper methods to implement state transitions:

namespace App\Flows;

use Helvetitec\FlowEngine\FlowEngine;

class ChatFlow extends FlowEngine
{
    protected function doRun(mixed $input): void
    {
        match ($this->subject()->getStateKey()) {
            'start' => $this->start(),
            'waiting' => $this->handleAnswer($input),
            default => $this->reset(),
        };
    }

    private function start(): void
    {
        // Send the available options to the user here.
        $this->set('options', [1, 2])
            ->transitionAndStop('waiting');
    }

    private function handleAnswer(mixed $input): void
    {
        if (! in_array($input, $this->get('options', []), true)) {
            $this->stop();
        }

        // Handle the selected option here.
        $this->delete('options')
            ->transitionAndStop('done');
    }
}

Start and run the flow from a controller, job, event listener, or webhook:

$chat->runFlow(\App\Flows\ChatFlow::class, $message);

startFlow creates the run without executing it. This is useful when context must be prepared first:

$chat->startFlow(\App\Flows\ChatFlow::class)
    ->mergeContext(['channel' => 'web'])
    ->runFlow($message);

Pass force: true to run a flow even when its run is inactive or has a future cooldown:

$chat->runFlow(\App\Flows\ChatFlow::class, $message, force: true);

When to use a custom FlowEngine

FlowRun should normally be preferred because it is the default Eloquent implementation and works well for flows owned by a model, such as a Chat. It is not the right fit when a flow has no model owner at all, for example a system-wide import or a temporary session that is not linked to a Chat or another Eloquent model.

In those cases, a custom FlowEngine can run against any application-owned class that implements FlowSubject. The subject below deliberately returns null from getOwner() and persists its state outside flow_runs:

namespace App\Flows;

use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Helvetitec\FlowEngine\Contracts\FlowSubject;
use Helvetitec\FlowEngine\FlowEngine;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Session;

final class SessionFlowSubject implements FlowSubject
{
    public function __construct(
        private string $state = 'start',
        private ?array $context = null,
        private bool $active = true,
        private Carbon|CarbonImmutable|null $cooldown = null,
    ) {}

    public function getOwner(): ?Model { return null; }
    public function getActive(): bool { return $this->active; }
    public function setActive(bool $active): void { $this->active = $active; }
    public function getStateKey(): string { return $this->state; }
    public function setStateKey(string $state): void { $this->state = $state; }
    public function getContext(): ?array { return $this->context; }
    public function setContext(?array $context): void { $this->context = $context; }
    public function getCooldown(): Carbon|CarbonImmutable|null { return $this->cooldown; }
    public function setCooldown(Carbon|CarbonImmutable|null $until): void { $this->cooldown = $until; }

    public function persist(): void
    {
        //In this example we use a simple session storage to store the data.
        Session::put('flow_subject_'.$this->id, [
            'state' => $this->state,
            'context' => $this->context,
            'active' => $this->active,
            'cooldown' => $this->cooldown?->toDateTimeString()
        ]);
    }

    // With this we will be able to manually load the stored subject by id or create a new one 
    public static function load(string $id): self
    {
        $data = Session::get('flow_subject_'.$id, [
            'state' => 'start',
            'context' => null,
            'active' => true,
            'cooldown' => null
        ]);

        return new self(
            id: $id,
            state: $data['state'],
            context: $data['context'],
            active: $data['active'],
            cooldown: $data['cooldown'] ? Carbon::parse($data['cooldown']) : null
        );
    }
}

final class SessionFlow extends FlowEngine
{
    protected function doRun(mixed $input): void
    {
        if ($this->subject()->getStateKey() === 'start') {
            $this->transitionAndStop('complete');
        }

        $this->stop();
    }
}

$subject = SessionFlowSubject::load('subject_id');
(new SessionFlow)->run($subject, $input);

The custom subject owns its lifecycle and persistence; FlowEngine still handles cooldown checks, exceptions, stopping, and the flow helpers. Use this approach only when FlowRun cannot represent the flow's ownership or persistence model. Otherwise, use HasFlowRuns and FlowRun.

Core concepts

Flow states

Every new run starts in the start state. transition changes the state for the next run. transitionAndStop changes the state and persists immediately.

Context

Context is a nullable associative array stored as JSON on the flow_runs row:

$this->set('attempts', 1);
$attempts = $this->get('attempts', 0);
$this->increment('attempts');
$this->decrement('attempts', 1);
$value = $this->pull('temporary_value'); // Read and remove
$this->delete('temporary_value');

Use broadcastContext on a model to merge the same values into every flow run:

$chat->broadcastContext(['user_id' => $user->id]);

Cooldowns and lifecycle

A future cooldown prevents a normal run. The cooldown is cleared automatically once it has expired:

$this->cooldown(now()->addMinutes(10))->stop();
$this->pause(now()->addMinutes(5)); // Set cooldown and stop
$this->deactivate();                 // Deactivate and stop
$this->reset(now()->addHour(), deleteContext: true);

Use stop(persist: false) only when the changes made during the current execution should not be saved. stop() persists by default. Exceptions raised by a flow are wrapped in FlowEngineException with flow and subject context.

API summary

FlowEngine

  • subject() returns the current FlowSubject.
  • transition(string $state) changes the next state.
  • set, get, increment, decrement, pull, and delete manage context.
  • cooldown(Carbon|CarbonImmutable|null $until) sets the next cooldown.
  • stop(bool $persist = true) ends execution.
  • transitionAndStop(string $state) transitions and persists.
  • pause(Carbon|CarbonImmutable $until) applies a cooldown and persists.
  • deactivate() disables the run and persists.
  • reset(Carbon|CarbonImmutable|null $until = null, bool $deleteContext = false) returns to start.

HasFlowRuns

  • flowRuns() returns the model's morph-many relationship.
  • startFlow(string $flowClass) creates or retrieves a run.
  • runFlow(string $flowClass, mixed $input = null, bool $force = false) starts and runs a flow.
  • broadcastContext(array $data) merges context into all runs for the model.

FlowRun

FlowRun is the default Eloquent implementation of FlowSubject. It provides resolveFlow, runFlow, mergeContext, and cleanup helpers:

use Helvetitec\FlowEngine\Models\FlowRun;

FlowRun::clear(\App\Flows\ChatFlow::class, now()->subMonth());
FlowRun::clearAll(now()->subMonth());

Rules of thumb

  1. Implement flow logic in doRun; do not call it directly.
  2. Stop after handling an input so state and context are persisted.
  3. Keep flow state in the run's state key and durable, small values in context.
  4. Use force: true deliberately; it bypasses active and cooldown checks.
  5. Keep side effects (messages, notifications, external calls) in the flow's state handlers.

Development

Install dependencies and run the available static analysis command:

composer install
vendor/bin/phpstan analyse

License

This package is open-sourced software licensed under the MIT license.

About

Flow Engine for Laravel

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages