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.
- PHP with support for union types (PHP 8.0 or newer)
- A Laravel application
- A database connection for the
flow_runstable
Laravel discovers the package service provider through Composer package discovery.
Install the package with Composer:
composer require helvetitec/flowengineIf 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 migrateThe migration creates flow_runs, including a polymorphic subject, the flow class, current state, JSON context, cooldown, and active flag.
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);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.
Every new run starts in the start state. transition changes the state for the next run. transitionAndStop changes the state and persists immediately.
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]);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.
subject()returns the currentFlowSubject.transition(string $state)changes the next state.set,get,increment,decrement,pull, anddeletemanage 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 tostart.
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 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());- Implement flow logic in
doRun; do not call it directly. - Stop after handling an input so state and context are persisted.
- Keep flow state in the run's state key and durable, small values in context.
- Use
force: truedeliberately; it bypasses active and cooldown checks. - Keep side effects (messages, notifications, external calls) in the flow's state handlers.
Install dependencies and run the available static analysis command:
composer install
vendor/bin/phpstan analyseThis package is open-sourced software licensed under the MIT license.