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)
- Publish the migrations:
phpartisanvendor:publish--tag="helvetitec.flowengine.migrations"- Run migrations:
phpartisanmigrateThe base class that handles execution:
abstractclass FlowEngine
{
abstractprotectedfunctiondoRun(mixed$input): void;
finalpublicfunctionrun(FlowSubject$subject, mixed$input): void;
finalprotectedfunctionsubject(): FlowSubject;
finalprotectedfunctioncooldown(?Carbon$until): static;
finalprotectedfunctiontransition(string$nextState): static;
finalprotectedfunctionset(string$key, mixed$value): static;
finalprotectedfunctionget(string$key, mixed$default = null): mixed;
finalprotectedfunctionpull(string$key, mixed$default = null, bool$persist = false): mixed;
finalprotectedfunctiondelete(string$key): static;
finalprotectedfunctionstop(bool$persist = true): never;
finalprotectedfunctiontransitionAndStop(string$nextState): never;
finalprotectedfunctiondeactivate(): never;
}Any model that participates in a flow must implement:
interface FlowSubject
{
publicfunctiongetActive(): bool;
publicfunctionsetActive(bool$active): void;
publicfunctiongetStateKey(): string;
publicfunctionsetStateKey(string$state): void;
publicfunctiongetContext(): array;
publicfunctionsetContext(?array$context): void;
publicfunctiongetCooldown(): ?Carbon;
publicfunctionsetCooldown(?Carbon$until): void;
publicfunctionpersist(): void;
}class FlowRun extends Model implements FlowSubject
{
publicfunctionsubject();
publicfunctiongetActive(): bool;
publicfunctionsetActive(bool$active): void;
publicfunctiongetStateKey(): string;
publicfunctionsetStateKey(string$state): void;
publicfunctiongetContext(): array;
publicfunctionsetContext(?array$context): void;
publicfunctiongetCooldown(): ?Carbon;
publicfunctionsetCooldown(?Carbon$until): void;
publicfunctionpersist(): void;
publicfunctionresolveFlow(): FlowEngine;
publicfunctionrunFlow(mixed$input = null, bool$force = false): void;
publicfunctionmergeContext(array$data): static;
publicstaticfunctionclear(string$flowClass, ?Carbon$clearOlderThan = null, ?string$flowType = null, ?string$flowId = null): int;
}FlowRuns allow multiple FlowEngines running at the same time. The default state for every run is always 'start'.
//Add to your Subject (e.g Chat)useHasFlowRuns;class ChatFlow extends FlowEngine
{
protectedfunctiondoRun(mixed$input): void
{
$state = $this->subject()->getStateKey();
if(!($this->subject() instanceof Chat)){
thrownewLogicException("Subject is not instance of Chat!");
}
match ($state) {
'start' => $this->start(),
'waiting' => $this->handleAnswer($input),
default => $this->start(),
};
}
privatefunctionstart(): void
{
ChatService::send($this->subject(), "Choose 1 or 2");
$this->transition('waiting')
->set('options', [1,2]) //Sets the context for the flow
->stop();
}
privatefunctionhandleAnswer($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();
}
}$chat->runFlow(ChatFlow::class, $message, $force);
//With 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()->getOwner().$chat->startFlow(ChatFlow::class)->subject()->getOwner();
//Clears all flowruns older than a specific date with the selected flowclass
FlowRun::clear(ChatFlow::class, now()->subMonth());
//Clears all flowruns from a specific model
FlowRun::clear(ChatFlow::class, now()->subMonth(), Chat::class, 1);
//Clears all flowruns older than a specific date. Prefer to use the more specific ones!
FlowRun::clearAll(now()->subMonth());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');$value = $this->pull('key');$this->delete('key');$this->clear();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();$this->pause(now()->addMinutes(5));$this->reset(now()->addHour());// ✅ Correctapp(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'
];
publicfunctionsetActive(bool$active): void
{
$this->flow_active = $active;
}
publicfunctiongetActive(): bool
{
return$this->flow_active;
}AI was used to create this readme file and for smaller parts of the code to make it cleaner.