Rapyd Admin workflows
When to use this skill
Whenever a model has a lifecycle: an order that is paid, processed and shipped; a ticket that is assigned, resolved
and closed; a request that is approved or rejected. The temptation is a status column changed by hand in a dozen
places. In Rapyd Admin the lifecycle is a state machine declared once, applied through transitions, with the rules
(guards) and the side effects (listeners) in one class, and a ready-made page block that shows the buttons and the
history. Most modules built on Rapyd Admin are built around this.
Under the hood: zerodahero/laravel-workflow (Symfony Workflow). Rapyd Admin adds the discovery of the definitions,
the WorkflowTrait, the WorkflowStep history and the workflow::workflow-table-embed component.
The pieces
| Piece |
Where |
What |
| definition |
app/Modules/{Name}/workflow.php |
places, transitions, metadata (references/workflow.php) |
| model |
use WorkflowTrait; + the status column (the marking_store property) |
workflow_apply(), workflow_can(), workflow_transitions(), workflow_transition_blocked(), workflow_metadata() |
| morph alias |
Relation::morphMap(['ticket' => Ticket::class]) |
the workflow name and the morph alias must be the same string: the embed uses one value for both |
| subscriber |
app/Modules/{Name}/Listeners/{Model}WorkflowSubscriber.php |
guards and effects (references/TicketWorkflowSubscriber.php), registered with Event::subscribe() |
| page block |
<livewire:workflow::workflow-table-embed workfloableType="ticket" :workfloableId="$ticket->id" :editable="true" :showHistory="true" /> |
the buttons of the available transitions (disabled with the reason when a guard blocks them), the history of WorkflowStep |
| modal |
a *Modal Livewire component + <x-rpd::modal> (references/TicketsAssignModal.php) |
a transition that needs input: 'action' => 'eventName' in the transition metadata |
| history |
Zofe\Rapyd\Modules\Workflow\Models\WorkflowStep |
who, when, from which places, with which meta; written by the embed, or by your code when you apply a transition yourself |
Procedure
1. Design on paper first
Write the places and the transitions as a list before touching code; show it to the user when the lifecycle is not
obvious. Rules of thumb:
- A place is a state the model stays in (
open, assigned); a transition is what moves it (assign). Name
places with adjectives / past participles, transitions with verbs.
- Mark terminal places
'final' => true: pages use it (workflow_metadata('final', $model->status)).
- One transition per user intention, not one per field change. Data that changes without changing the state is not a transition.
- Who triggers it: the operator (a button), the customer (a page), the system (a webhook, a command)? A transition
that needs input from a person gets an
action and a modal; one that is automatic is applied by code.
- What must be true before (guards) and what happens after (effects). Keep both in the subscriber, not in the pages.
2. Declare
workflow.php in the module (see references/workflow.php): marking_store.property is the column, initial_marking
the state of a new record, supports the model class. Metadata per transition: label (the button), class (the
button colour: primary, warning, danger…), action (the Livewire event of a modal). The file is loaded
automatically from app/Modules/*/workflow.php and from installed vendor/zofe/* packages.
Register the morph alias with the same name as the workflow: in a package, Relation::morphMap([...], true) in the
service provider's register(); in an app module, in AppServiceProvider::boot().
3. Rules and effects: the subscriber
references/TicketWorkflowSubscriber.php. Events are named workflow.{name}.{guard|transition|completed}.{transition}:
guard: $event->setBlocked(true, 'reason'). A non-empty reason shows the button disabled with the message; an
empty reason hides the button (a transition that does not apply here).
transition: runs inside the transition. Throw to abort: the model keeps its state. Use it when an external
call must succeed for the state to change (a gateway, an API, a provisioning driver).
completed: the marking changed, the model is not saved yet: set attributes, open records, send mails; the
caller saves. Do not call save() on the subject inside a listener unless you own the whole flow.
Guards that depend on other models (all the lines assigned, the payment confirmed…) belong here too, so every page
and every command gets the same answer.
4. Apply
- From a page, through the embed: nothing to write.
editable shows the buttons, showHistory the steps.
- From your code:
$model->workflow_apply('transition', 'name'); $model->save(); and, when it matters for the history,
create the WorkflowStep (see references/TicketsAssignModal.php). Check first with workflow_can(); the reasons
are in workflow_transition_blocked().
- With input: the
action metadata + a modal component that listens to the event (#[On('assignTicket')]), sets the
data on the model before apply() (guards may read it), applies, saves, records the step, dispatches
hide-modals, savedStep and refresh.
5. Show
On the detail page, a card with the state and the embed:
<x-rpd::card title="Status">
<dl class="row">
<dt class="col-4">Status</dt><dd class="col-8">{{ $ticket->status }}</dd>
</dl>
<livewire:workflow::workflow-table-embed workfloableType="ticket" workfloableId="{{ $ticket->id }}" :editable="true" :showHistory="true" />
</x-rpd::card>
The page component listens to refresh (#[On('refresh')] public function refresh() { $this->ticket->refresh(); })
so the rest of the page follows the state. The embed requires the view workflow / edit workflow permissions
(admins have everything): give them to the roles that operate the workflow in the module's config.php.
Lists show the state as a badge and, when useful, the blocked reasons (workflow_transition_blocked()); a parent
model can count what is still open in its children with workflow_count_incomplete_from($children).
6. Test
references/WorkflowTest.php: the transitions (workflow_can, workflow_apply), the guards (the blocked reasons),
the effects (what the listener changed), the embed (Livewire::test('workflow::workflow-table-embed', [...])), the
permissions (assertForbidden()). Then open the page: press the buttons in order, check the history.
Do not
- Do not assign
status by hand, anywhere. If a state change has no transition, add the transition.
- Do not put the rules in the Livewire components: a guard is asked by every page and every command.
- Do not name the workflow differently from the model's morph alias.
- Do not skip
save() after workflow_apply(): the transition changes the attribute, the caller persists it.
- Do not reuse a transition for two different intentions because they share the same target place.
1---2name: rapyd-workflow3description: Design and implement a state machine (workflow) on a Rapyd Admin model: places, transitions, guards and effects as listeners, transitions with a modal, the workflow embed on the page, tests. Use it whenever a model has a lifecycle (orders, tickets, requests, approvals).4---56# Rapyd Admin workflows78## When to use this skill910Whenever a model has a lifecycle: an order that is paid, processed and shipped; a ticket that is assigned, resolved11and closed; a request that is approved or rejected. The temptation is a `status` column changed by hand in a dozen12places. In Rapyd Admin the lifecycle is a **state machine** declared once, applied through transitions, with the rules13(guards) and the side effects (listeners) in one class, and a ready-made page block that shows the buttons and the14history. Most modules built on Rapyd Admin are built around this.1516Under the hood: `zerodahero/laravel-workflow` (Symfony Workflow). Rapyd Admin adds the discovery of the definitions,17the `WorkflowTrait`, the `WorkflowStep` history and the `workflow::workflow-table-embed` component.1819## The pieces2021| Piece | Where | What |22|---|---|---|23| definition | `app/Modules/{Name}/workflow.php` | places, transitions, metadata (`references/workflow.php`) |24| model | `use WorkflowTrait;` + the `status` column (the `marking_store` property) | `workflow_apply()`, `workflow_can()`, `workflow_transitions()`, `workflow_transition_blocked()`, `workflow_metadata()` |25| morph alias | `Relation::morphMap(['ticket' => Ticket::class])` | **the workflow name and the morph alias must be the same string**: the embed uses one value for both |26| subscriber | `app/Modules/{Name}/Listeners/{Model}WorkflowSubscriber.php` | guards and effects (`references/TicketWorkflowSubscriber.php`), registered with `Event::subscribe()` |27| page block | `<livewire:workflow::workflow-table-embed workfloableType="ticket" :workfloableId="$ticket->id" :editable="true" :showHistory="true" />` | the buttons of the available transitions (disabled with the reason when a guard blocks them), the history of `WorkflowStep` |28| modal | a `*Modal` Livewire component + `<x-rpd::modal>` (`references/TicketsAssignModal.php`) | a transition that needs input: `'action' => 'eventName'` in the transition metadata |29| history | `Zofe\Rapyd\Modules\Workflow\Models\WorkflowStep` | who, when, from which places, with which `meta`; written by the embed, or by your code when you apply a transition yourself |3031## Procedure3233### 1. Design on paper first3435Write the places and the transitions as a list before touching code; show it to the user when the lifecycle is not36obvious. Rules of thumb:37- A place is a state the model **stays** in (`open`, `assigned`); a transition is what **moves** it (`assign`). Name38 places with adjectives / past participles, transitions with verbs.39- Mark terminal places `'final' => true`: pages use it (`workflow_metadata('final', $model->status)`).40- One transition per user intention, not one per field change. Data that changes without changing the state is not a transition.41- Who triggers it: the operator (a button), the customer (a page), the system (a webhook, a command)? A transition42 that needs input from a person gets an `action` and a modal; one that is automatic is applied by code.43- What must be true before (guards) and what happens after (effects). Keep both in the subscriber, not in the pages.4445### 2. Declare4647`workflow.php` in the module (see `references/workflow.php`): `marking_store.property` is the column, `initial_marking`48the state of a new record, `supports` the model class. Metadata per transition: `label` (the button), `class` (the49button colour: `primary`, `warning`, `danger`…), `action` (the Livewire event of a modal). The file is loaded50automatically from `app/Modules/*/workflow.php` and from installed `vendor/zofe/*` packages.5152Register the morph alias with the same name as the workflow: in a package, `Relation::morphMap([...], true)` in the53service provider's `register()`; in an app module, in `AppServiceProvider::boot()`.5455### 3. Rules and effects: the subscriber5657`references/TicketWorkflowSubscriber.php`. Events are named `workflow.{name}.{guard|transition|completed}.{transition}`:58- `guard`: `$event->setBlocked(true, 'reason')`. A non-empty reason shows the button disabled with the message; an59 empty reason hides the button (a transition that does not apply here).60- `transition`: runs **inside** the transition. Throw to abort: the model keeps its state. Use it when an external61 call must succeed for the state to change (a gateway, an API, a provisioning driver).62- `completed`: the marking changed, the model is **not saved yet**: set attributes, open records, send mails; the63 caller saves. Do not call `save()` on the subject inside a listener unless you own the whole flow.6465Guards that depend on other models (all the lines assigned, the payment confirmed…) belong here too, so every page66and every command gets the same answer.6768### 4. Apply6970- From a page, through the embed: nothing to write. `editable` shows the buttons, `showHistory` the steps.71- From your code: `$model->workflow_apply('transition', 'name'); $model->save();` and, when it matters for the history,72 create the `WorkflowStep` (see `references/TicketsAssignModal.php`). Check first with `workflow_can()`; the reasons73 are in `workflow_transition_blocked()`.74- With input: the `action` metadata + a modal component that listens to the event (`#[On('assignTicket')]`), sets the75 data on the model **before** `apply()` (guards may read it), applies, saves, records the step, dispatches76 `hide-modals`, `savedStep` and `refresh`.7778### 5. Show7980On the detail page, a card with the state and the embed:8182```blade83<x-rpd::card title="Status">84 <dl class="row">85 <dt class="col-4">Status</dt><dd class="col-8">{{ $ticket->status }}</dd>86 </dl>87 <livewire:workflow::workflow-table-embed workfloableType="ticket" workfloableId="{{ $ticket->id }}" :editable="true" :showHistory="true" />88</x-rpd::card>89```9091The page component listens to `refresh` (`#[On('refresh')] public function refresh() { $this->ticket->refresh(); }`)92so the rest of the page follows the state. The embed requires the `view workflow` / `edit workflow` permissions93(admins have everything): give them to the roles that operate the workflow in the module's `config.php`.9495Lists show the state as a badge and, when useful, the blocked reasons (`workflow_transition_blocked()`); a parent96model can count what is still open in its children with `workflow_count_incomplete_from($children)`.9798### 6. Test99100`references/WorkflowTest.php`: the transitions (`workflow_can`, `workflow_apply`), the guards (the blocked reasons),101the effects (what the listener changed), the embed (`Livewire::test('workflow::workflow-table-embed', [...])`), the102permissions (`assertForbidden()`). Then open the page: press the buttons in order, check the history.103104## Do not105106- Do not assign `status` by hand, anywhere. If a state change has no transition, add the transition.107- Do not put the rules in the Livewire components: a guard is asked by every page and every command.108- Do not name the workflow differently from the model's morph alias.109- Do not skip `save()` after `workflow_apply()`: the transition changes the attribute, the caller persists it.110- Do not reuse a transition for two different intentions because they share the same target place.