Recipe — State Machine Workflow
Goal: Model a record that moves through named states (e.g. draft → review → approved → published) with transition history, authorization, and a dedicated UI input.
Time: ~15 minutes.
When to Use Which Trait
Modularous has two related traits — pick based on what you need:
| Trait | Use for |
|---|---|
HasStateable | Simple named states on a record (draft / active / archived) — one row in states table |
Processable | Full process lifecycle with history and status transitions per step |
This recipe covers both — start with HasStateable and escalate to Processable when you need process history.
1. Add HasStateable to your model
use Unusualify\Modularous\Entities\Traits\HasStateable;
class Article extends Model
{
use HasStateable;
}This installs:
morphOne(State::class, 'stateable')relationstateable(),stateable_statusaccessorsstateableChanged(),currentStateableState(),previousStateableState()helpers
2. Add the repository trait
use Unusualify\Modularous\Repositories\Traits\StateableTrait;
class ArticleRepository extends Repository
{
use StateableTrait;
}3. Configure the state list
Define the states in your repository (or a dedicated config):
// ArticleRepository.php
public function getStateableList(): array
{
return [
['value' => 'draft', 'label' => 'Draft'],
['value' => 'review', 'label' => 'In Review'],
['value' => 'approved', 'label' => 'Approved'],
['value' => 'published', 'label' => 'Published'],
];
}4. Add the input to your hydrate
public function getInputs(): array
{
return [
['type' => 'text', 'name' => 'title'],
['type' => 'textarea', 'name' => 'body'],
['type' => 'stateable', 'name' => 'stateable_id'],
];
}The stateable hydrate auto-pulls items from getStateableList() and emits a select input.
5. Query by state
Article::whereHas('stateable', fn($q) => $q->where('status', 'published'))->get();
// Current state
$article->currentStateableState(); // 'published'
$article->stateable_status; // accessor6. Listen for state transitions
Create a broadcast event that fires on state changes:
php artisan modularous:make:event StateableUpdated BlogThen in the event (already extends ModelEvent):
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Broadcasting\InteractsWithBroadcasting;
use Unusualify\Modularous\Events\ModelEvent;
class StateableUpdated extends ModelEvent implements ShouldBroadcast
{
use InteractsWithBroadcasting;
public function broadcastWhen(): bool
{
return $this->hasStateable && $this->stateableChanged;
}
}$hasStateable, $stateableChanged, $previousStateableState, $currentStateableState are auto-populated by the EventStateable trait in ModelEvent.
Dispatch it from your repository's afterSave():
public function afterSave($object, $fields)
{
parent::afterSave($object, $fields);
if ($object->stateableChanged()) {
event(new StateableUpdated($object));
}
}7. Subscribe on the frontend
Use BroadcastManager to share channel config and subscribe with Echo:
// Controller
$broadcastConfig = BroadcastManager::forModel($article, [
StateableUpdated::class,
]);
return inertia('Article/Show', compact('article', 'broadcastConfig'));// Vue
import { onMounted, onBeforeUnmount } from 'vue'
import { usePage } from '@inertiajs/vue3'
const { broadcastConfig } = usePage().props
onMounted(() => {
broadcastConfig.forEach(({ name, type, events }) => {
const channel = type === 'private' ? Echo.private(name) : Echo.channel(name)
events.forEach(({ event }) => {
channel.listen(`.${event}`, ({ currentStateableState }) => {
// update your Vue state here
})
})
})
})See Broadcasting for the full flow.
8. Verify
- Create an Article — state defaults to
draft. - Edit and switch state to
review— the broadcast event fires. $article->previousStateableState()returnsdraft;currentStateableState()returnsreview.
Escalating to Processable
Use Processable when you need:
- Per-step history (
process_historiestable) - Assignment per step (combined with
Assignable) - Conditional transitions with explicit
advance()/rollback()calls
use Unusualify\Modularous\Entities\Traits\Processable;
class Claim extends Model
{
use Processable;
}Then expose the process input:
[
'type' => 'process',
'name' => 'process_id',
]See Processable for the full pattern.
Next Steps
- HasStateable — entity trait reference
- Processable — richer state machine with history
- Events & Broadcasting — real-time state updates
- ModelEvent / EventStateable — auto-captured state context