A simple, flexible state machine engine for Laravel to handle dynamic flows like chats, onboarding, workflows, and more.
FlowEngine allows you to define state-driven flows where each subject (e.g. a chat, user, or process) moves through states based on input.
Typical flow:
- Receive input
- Process current state
- Transition to next state
- Persist state + context
- Stop execution
- Resume later (via new input or cooldown)
No further setup needed
- Publish the migrations:
php artisan vendor:publish --tag="helvetitec.flowengine.migrations"- Run migrations:
php artisan migrateThe base class that handles execution:
abstract class FlowEngine
{
abstract protected function doRun(mixed $input): void;
final public function run(FlowSubject $subject, mixed $input): void;
final protected function subject(): FlowSubject;
final protected function cooldown(?Carbon $until): static;
final protected function transition(string $nextState): static;
final protected function set(string $key, mixed $value): static;
final protected function get(string $key, mixed $default = null): mixed;
final protected function pull(string $key, mixed $default = null, bool $persist = false): mixed;
final protected function delete(string $key): static;
final protected function stop(bool $persist = true): never;
final protected function transitionAndStop(string $nextState): never;
final protected function deactivate(): never;
}Any model that participates in a flow must implement:
interface FlowSubject
{
public function getActive(): bool;
public function setActive(bool $active): void;
public function getStateKey(): string;
public function setStateKey(string $state): void;
public function getContext(): array;
public function setContext(array $context): void;
public function getCooldown(): ?Carbon;
public function setCooldown(?Carbon $until): void;
public function persist(): void;
}class FlowRun extends Model implements FlowSubject
{
public function subject();
public function getActive(): bool;
public function setActive(bool $active): void;
public function getStateKey(): string;
public function setStateKey(string $state): void;
public function getContext(): array;
public function setContext(array $context): void;
public function getCooldown(): ?Carbon;
public function setCooldown(?Carbon $until): void;
public function persist(): void;
public function resolveFlow(): FlowEngine;
public function runFlow(mixed $input = null, bool $force = false): void;
public function mergeContext(array $data): static;
public static function clear(string $flowClass, ?string $flowType = null, ?string $flowId = null, ?Carbon $clearOlderThan = null): int;
}If you want you can use the FlowRuns which would allow multiple FlowEngines running at the same time. The default state for every run is always 'start'.
Important: Follow the steps inside setup first for this to work!
//Add to your Subject (e.g Chat)
use HasFlowRuns;If you only want to use one flow at a time.
class Chat extends Model implements FlowSubject
{
use HasFlow;
protected $casts = [
'context' => 'array',
'cooldown_until' => 'datetime',
'active' => 'boolean'
];
public function getActive(): bool
{
return $this->active;
}
public function setActive(bool $active): void
{
$this->active = $active;
}
public function getStateKey(): string
{
return $this->state_key;
}
public function setStateKey(string $state): void
{
$this->state_key = $state;
}
public function getContext(): array
{
return $this->context ?? [];
}
public function setContext(array $context): void
{
$this->context = $context;
}
public function getCooldown(): ?Carbon
{
return $this->cooldown_until;
}
public function setCooldown(Carbon $until): void
{
$this->cooldown_until = $until;
}
public function persist(): void
{
$this->save();
}
}class ChatFlow extends FlowEngine
{
protected function doRun(mixed $input): void
{
$state = $this->subject()->getStateKey();
if(!($this->subject() instanceof Chat)){
throw new LogicException("Subject is not instance of Chat!");
}
match ($state) {
'start' => $this->start(),
'waiting' => $this->handleAnswer($input),
default => $this->start(),
};
}
private function start(): void
{
ChatService::send($this->subject(), "Choose 1 or 2");
$this->transition('waiting')
->set('options', [1,2]) //Sets the context for the flow
->stop();
}
private function handleAnswer($input): void
{
$options = $this->get('options');
if(!in_array($input, $options)){
ChatService::send($this->subject(), "Invalid input");
$this->stop();
return;
}
ChatService::send($this->subject(), "You chose: {$input}");
$this->transition('done')
->delete('options')
->cooldown(now()->addMinutes(5))
->stop();
}
}app(ChatFlow::class)->run($chat, $message);or
//With use HasFlow;
$chat->runFlow($message);or
//With use HasFlowRuns;
$chat->runFlow(ChatFlow::class, $message, $force);
//With use HasFlowRuns and merged context
$chat->startFlow(ChatFlow::class)->mergeContext(['some_context_to_start' => 'Hello World'])->runFlow("input");
//Update the context for all FlowRuns of the model at once.
$chat->broadcastContext(['context_for_all_runs' => true]);
//Returns the object related to the flow. In this case it would be $chat as well as it is the owner, but its powerful inside the FlowEngine as you can call subject()->owner.
$chat->startFlow(ChatFlow::class)->subject()->owner;You typically call this from:
- Controllers
- Jobs
- Event listeners
- Webhooks
Input β run() β doRun()
β
state logic
β
transition()
set()
cooldown()
β
stop()
β
persist()
Handles the transition between states.
$this->transition('next_state');Stores and loads data from the context.
$this->set('key', 'value');
$value = $this->get('key');Adds a cooldown between this run and the next run.
$this->cooldown(now()->addMinutes(10));Stops the execution of the current flow.
$this->stop(); //persistsor
$this->stop(persist: false); //does not persistSets the next state and stops.
$this->transitionAndStop('next_state');Deactivates the flow and stops it.
$this->deactivate();// β
Correct
app(MyFlow::class)->run($subject, $input);
// β Wrong
$flow->doRun($input);$this->transition('next')
->stop();Persistence is handled automatically by the engine.
'start'
'waiting_for_input'
'completed'The FlowEngine will throw a FlowEngineException if you call FlowEngine->run(). This exception has the following additional context for easier debugging: flow_engine_class Returns the class of the FlowEngine like ChatFlowExample.
flow_engine_context Returns the current context of the FlowSubject.
flow_engine_state Returns the current state of the FlowSubject.
input Returns the latest input of the run.
You can fully disable flows by setting the setActive/getActive methods to a custom field or use setActive in FlowRuns.
protected $fillable = [
'flow_active'
];
protected $casts = [
'flow_active' => 'boolean'
];
public function setActive(bool $active): void
{
$this->flow_active = $active;
}
public function getActive(): bool
{
return $this->flow_active;
}AI was used to create this readme file and for smaller parts of the code to make it cleaner.