Workflow state machine
SkillAI & modelsReference for OrangeHRM's workflow state machine — the `WorkflowStateMachine` entity that models `(workflow, state, role, action) → resultingState` transitions in `ohrm_workflow_state_machine`, the eight `FLOW_*` constants for the workflow types (Leave, Recruitment, Timesheet, Attendance, Employee, Review, Self-Review, Claim), `AccessFlowStateMachineService` for querying allowed actions / states / next-state lookups, the per-flow `<ACTION>_*` integer constants on the entity (e.g. `LEAVE_ACTION_APPROVE`, `CLAIM_ACTION_SUBMIT`), and the typical pattern of dispatching an event after the state transition is persisted so subscribers can react (notifications, audits). Use whenever the user is adding a new workflow transition, debugging "why can this role not approve this leave", asking about state-machine state strings, or wiring a new approval-style feature. Companion to `events` (transitions dispatch events), `mail` (transitions are the primary trigger for notification emails), `authorization` (workflow access is layered on top of the data-group permission model — both apply).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Workflow state machine skill
What this skill tells your AI
The instructions your AI receives, as published by orangehrm/orangehrm in .agents/skills/workflow/SKILL.md and read by ahel’s review.
OrangeHRM has a single, generic state machine table that drives all approval-style flows: leave requests, timesheet submissions, recruitment applications, claim approvals, performance reviews, employee lifecycle events, attendance punches.
The table ohrm_workflow_state_machine stores rows of the form:
workflow: which flow ('1' for Attendance, '4' for Leave, '7' for Claim, …)
state: the current state of the thing being acted on ('PENDING APPROVAL')
role: the user role taking the action ('ESS', 'Supervisor', 'Admin')
action: the action ID (an integer specific to each flow)
resulting_state: the state after the action ('APPROVED')
A workflow run looks like: "I am role X, the thing is in state Y, can I do action Z?" The service queries the table, finds (if any) the matching row, and returns the resulting state. The application then transitions the entity and (usually) dispatches an event.
This skill covers the model, the service API, and the patterns. For the events that fire after transitions, see events. For the emails those events trigger, see mail.
The model — WorkflowStateMachine entity
OrangeHRM\Entity\WorkflowStateMachine mapped to ohrm_workflow_state_machine:
@ORM\Column(name="workflow", type="string", length=255)
$workflow; // FLOW_* constant (stored as the integer-as-string '4' for Leave, etc.)
@ORM\Column(name="state", type="string", length=255)
$state; // Current state, e.g. 'PENDING APPROVAL', 'APPROVED'
@ORM\Column(name="role", type="string", length=255)
$role; // User role, e.g. 'ESS', 'Supervisor', 'Admin'
@ORM\Column(name="action", type="string", length=255)
$action; // Action ID, e.g. '2' for LEAVE_ACTION_APPROVE
@ORM\Column(name="resulting_state", type="string", length=255)
$resultingState; // State after the action
Plus the id primary key and the standard Decorator trait (see entities skill).
Important: the values are strings, even when conceptually integers. The action 2 is stored as '2'. Comparisons are string-equality. Use the entity's constants (which are integer-typed in PHP) and let the framework coerce them.
The workflow type constants — FLOW_*
class WorkflowStateMachine
{
public const FLOW_TIME_TIMESHEET = 0;
public const FLOW_ATTENDANCE = 1;
public const FLOW_RECRUITMENT = 2;
public const FLOW_EMPLOYEE = 3;
public const FLOW_LEAVE = 4;
public const FLOW_REVIEW = 5;
public const FLOW_SELF_REVIEW = 6;
public const FLOW_CLAIM = 7;
}
Each flow has its own per-flow <DOMAIN>_ACTION_* constants. Examples from the entity:
// Leave (FLOW_LEAVE = 4)
public const LEAVE_ACTION_APPROVE = 2;
public const LEAVE_ACTION_CANCEL = 3;
public const LEAVE_ACTION_REJECT = 4;
// (full list — see the entity file)
// Timesheet (FLOW_TIME_TIMESHEET = 0)
public const TIMESHEET_ACTION_VIEW = 0;
public const TIMESHEET_ACTION_SUBMIT = 1;
public const TIMESHEET_ACTION_APPROVE = 2;
public const TIMESHEET_ACTION_REJECT = 3;
public const TIMESHEET_ACTION_RESET = 4;
public const TIMESHEET_ACTION_MODIFY = 5;
public const TIMESHEET_ACTION_CREATE = 7;
// Claim (FLOW_CLAIM = 7)
public const CLAIM_ACTION_SUBMIT = 1;
public const CLAIM_ACTION_APPROVE = 2;
public const CLAIM_ACTION_PAY = 3;
public const CLAIM_ACTION_CANCEL = 4;
public const CLAIM_ACTION_REJECT = 5;
// Employee (FLOW_EMPLOYEE = 3)
public const EMPLOYEE_ACTION_ADD = 1;
public const EMPLOYEE_ACTION_DELETE_ACTIVE = 2;
public const EMPLOYEE_ACTION_TERMINATE = 3;
public const EMPLOYEE_ACTION_REACTIVE = 4;
public const EMPLOYEE_ACTION_DELETE_TERMINATED = 5;
// Recruitment (FLOW_RECRUITMENT = 2)
public const RECRUITMENT_APPLICATION_ACTION_ATTACH_VACANCY = 1;
public const RECRUITMENT_APPLICATION_ACTION_SHORTLIST = 2;
public const RECRUITMENT_APPLICATION_ACTION_REJECT = 3;
// … many more
// Review (FLOW_REVIEW = 5)
public const REVIEW_INACTIVE_SAVE = 1;
public const REVIEW_ACTIVATE = 2;
public const REVIEW_IN_PROGRESS_SAVE = 3;
public const REVIEW_COMPLETE = 4;
States are strings, often domain-specific (e.g. 'PENDING APPROVAL', 'APPROVED', 'CANCELLED', 'REJECTED', 'TAKEN', 'SCHEDULED'). The state vocabulary is defined per flow — leave has its set, claim has its set, etc.
When in doubt about what state strings exist for a flow, query the table:
SELECT DISTINCT state FROM ohrm_workflow_state_machine WHERE workflow = '4';
SELECT DISTINCT resulting_state FROM ohrm_workflow_state_machine WHERE workflow = '4';
AccessFlowStateMachineService — querying the state machine
OrangeHRM\Core\Service\AccessFlowStateMachineService. The only service that talks to ohrm_workflow_state_machine directly. Domain services (LeaveRequestService, TimesheetService, etc.) wrap this for their specific flow.
$svc = new AccessFlowStateMachineService();
// "What actions can this role take from this state?"
$actions = $svc->getAllowedActions(
workflow: (string) WorkflowStateMachine::FLOW_LEAVE,
state: 'PENDING APPROVAL',
role: 'Supervisor',
);
// → ['2', '3', '4'] (APPROVE, CANCEL, REJECT — as strings)
// "Where does this take us?"
$next = $svc->getNextState(
workflow: (string) WorkflowStateMachine::FLOW_LEAVE,
state: 'PENDING APPROVAL',
role: 'Supervisor',
action: (string) WorkflowStateMachine::LEAVE_ACTION_APPROVE,
);
// → 'APPROVED'
// "What WorkflowStateMachine rows match this combo?"
$workflow = $svc->getWorkflowItemByStateActionAndRole(
(string) WorkflowStateMachine::FLOW_LEAVE,
'PENDING APPROVAL',
'Supervisor',
(string) WorkflowStateMachine::LEAVE_ACTION_APPROVE,
);
// → WorkflowStateMachine entity with all fields populated
// "What workflow items exist for this flow, optionally filtered by role?"
$items = $svc->getWorkFlowStateMachineRecords(
workflow: (string) WorkflowStateMachine::FLOW_LEAVE,
role: 'Supervisor',
);
// → WorkflowStateMachine[]
The service has more flow-specific helpers (getAllAlowedRecruitmentApplicationStates, getActionableStates, etc.) — see the source for the full surface.
Key methods:
| Method | Returns | Use for |
|---|---|---|
getAllowedActions($workflow, $state, $role) | string[] of action IDs | UI: which buttons to show |
getNextState($workflow, $state, $role, $action) | ?string (state) or null if action not allowed | Compute the new state after the user clicks |
getWorkflowItemByStateActionAndRole(...) | ?WorkflowStateMachine | When you need the full row (e.g. for notification routing — the Decorator has getRolesToNotify()) |
getActionableStates($workflow, $role, $actions) | ?string[] | "List items in any state where this role can do one of these actions" — used by approval queues |
All methods cache results in $allowedWorkflowItemCache per-request — the same query in two places doesn't re-query.
The typical transition flow
A leave-approval flow walks like this:
1. Supervisor clicks "Approve" on a Leave entity in state 'PENDING APPROVAL'
↓
2. LeaveRequestService::approve(LeaveRequest $req)
↓
3. Compute next state via $accessFlowService->getNextState('4', 'PENDING APPROVAL', 'Supervisor', '2')
→ returns 'APPROVED'
↓
4. Update LeaveRequest entity: $req->setStatus('APPROVED')
↓
5. Persist via DAO
↓
6. Dispatch LeaveApprove event (see events skill)
↓
7. LeaveEventSubscriber catches it
→ queueEmailNotifications('leave.approve', ...) (see mail skill)
↓
8. Response returned to UI; on TERMINATE, email sent
The state machine is the decision ("can this transition happen and what's the new state?"). The domain service is the executor ("update the entity and persist, then dispatch an event").
Two-layer authorization
Workflow access is layered on top of the data-group permission model (see authorization skill). Both have to pass:
-
Data-group permission check — does this user's role have
can_updateonapiv2_leave_leave_requests? If not → 403, no transition possible. Enforced byApiAuthorizationSubscriber. -
Workflow transition check — even with permission to update, the role might not have a row in
ohrm_workflow_state_machinefor(workflow=4, state='PENDING APPROVAL', role='Supervisor', action=2). If no row → no allowed action, regardless of CRUD permission.
The two layers serve different concerns:
- Permissions: "in general, can this role touch this resource?"
- Workflow: "in this specific state, can this role take this specific action?"
A Supervisor has can_update on leave records always — that's a permission. But they can only approve a leave when it's in PENDING APPROVAL — that's a workflow transition. Trying to approve an already-APPROVED leave returns null from getNextState, and the service treats it as a no-op or error.
Seeding workflow rows via migration
Workflow rows are seeded by migrations, not by code at runtime. When a new feature adds a flow (or a new transition to an existing flow), the migration inserts the appropriate rows into ohrm_workflow_state_machine.
There's no YAML helper for this in the current codebase — workflow seeds are typically raw createQueryBuilder()->insert() calls inside the migration. Look at the recruitment / leave / claim migrations for examples.
// Representative pattern (paraphrased)
public function up(): void
{
$rows = [
// (workflow, state, role, action, resulting_state)
['4', 'PENDING APPROVAL', 'Supervisor', '2', 'APPROVED'], // approve
['4', 'PENDING APPROVAL', 'Supervisor', '4', 'REJECTED'], // reject
['4', 'PENDING APPROVAL', 'ESS', '3', 'CANCELLED'], // cancel (own)
['4', 'APPROVED', 'ESS', '3', 'CANCELLED'], // cancel after approval
['4', 'PENDING APPROVAL', 'Admin', '2', 'APPROVED'],
// …
];
foreach ($rows as $r) {
$this->createQueryBuilder()
->insert('ohrm_workflow_state_machine')
->values([
'workflow' => ':workflow',
'state' => ':state',
'role' => ':role',
'action' => ':action',
'resulting_state' => ':resulting_state',
])
->setParameter('workflow', $r[0])
->setParameter('state', $r[1])
->setParameter('role', $r[2])
->setParameter('action', $r[3])
->setParameter('resulting_state', $r[4])
->executeStatement();
}
}
See migrations skill for the migration mechanics.
The Decorator — WorkflowStateMachineDecorator
OrangeHRM\Entity\Decorator\WorkflowStateMachineDecorator (see entities skill for the Decorator pattern in general). The leave subscriber uses it:
$workflow = $allocateEvent->getWorkflow();
$recipientRoles = $workflow->getDecorator()->getRolesToNotify();
$performerRole = strtolower($workflow->getRole());
getRolesToNotify() returns roles that should be notified when the workflow transitions — e.g., when a Supervisor approves a leave, the ESS who applied should be notified. This is computed from the workflow row's structure (out of scope for this skill, but the entity has the data needed).
When to use the workflow vs. simple status field
Use the workflow state machine when:
- Multiple roles can take different actions from the same state
- The same action can have different effects depending on role (ESS cancel vs Supervisor reject)
- Transitions need to be auditable and inspectable from outside the code
- Operators / admins might want to configure transitions without a code change
Don't use it when:
- Status is purely informational, not transitionable (e.g. "active / inactive" toggle)
- Only one role ever touches the resource
- The state space is so small (2-3 states) that overhead exceeds value
The codebase reserves the state machine for the complex multi-role approval flows (leave, claim, recruitment, review). Simple status fields stay as enum strings on the entity.
Recipes
Recipe 1 — Query "can this user do this action?"
class LeaveRequestService
{
private ?AccessFlowStateMachineService $accessFlowService = null;
public function getAccessFlowService(): AccessFlowStateMachineService
{
return $this->accessFlowService ??= new AccessFlowStateMachineService();
}
public function canApprove(LeaveRequest $req, string $userRole): bool
{
$allowedActions = $this->getAccessFlowService()->getAllowedActions(
(string) WorkflowStateMachine::FLOW_LEAVE,
$req->getStatus(),
$userRole,
);
return in_array((string) WorkflowStateMachine::LEAVE_ACTION_APPROVE, $allowedActions ?? [], true);
}
}
Use this from API endpoints / handlers to decide whether to show / hide the action button on the frontend.
Recipe 2 — Execute a transition
class LeaveRequestService
{
use EventDispatcherTrait;
public function approve(LeaveRequest $req, string $userRole): LeaveRequest
{
$nextState = $this->getAccessFlowService()->getNextState(
(string) WorkflowStateMachine::FLOW_LEAVE,
$req->getStatus(),
$userRole,
(string) WorkflowStateMachine::LEAVE_ACTION_APPROVE,
);
if ($nextState === null) {
throw new BadRequestException('Approval not allowed in current state for this role');
}
$req->setStatus($nextState);
$req = $this->getLeaveRequestDao()->saveLeaveRequest($req);
$this->getEventDispatcher()->dispatch(
new LeaveApprove($req, /* workflow */),
LeaveEvent::APPROVE,
);
return $req;
}
}
Standard pattern: compute next state → mutate entity → persist → dispatch event. The event picks up the rest (notifications, audits, downstream side effects).
Recipe 3 — UI button visibility based on allowed actions
// Vue component for a leave-request row
const allowedActions = ref([]);
onMounted(async () => {
const response = await http.get(leaveRequestId, { include: 'allowedActions' });
allowedActions.value = response.data.data.allowedActions;
});
<oxd-button v-if="allowedActions.includes('2')" @click="onApprove" :label="$t('leave.approve')" />
<oxd-button v-if="allowedActions.includes('4')" @click="onReject" :label="$t('leave.reject')" />
<oxd-button v-if="allowedActions.includes('3')" @click="onCancel" :label="$t('leave.cancel')" />
Backend endpoint computes allowedActions via AccessFlowStateMachineService::getAllowedActions() for the current user role + leave state. Frontend just checks membership.
Compare to the $can approach (see authorization): $can is for resource-level permissions ("can this role touch leave at all?"), workflow actions are for state-specific ones ("is approval available right now?"). Both should pass for an action to actually work.
Recipe 4 — Find "approval queue" items for a role
// "Show me all leave requests this Supervisor can act on"
$states = $this->getAccessFlowService()->getActionableStates(
(string) WorkflowStateMachine::FLOW_LEAVE,
'Supervisor',
[
(string) WorkflowStateMachine::LEAVE_ACTION_APPROVE,
(string) WorkflowStateMachine::LEAVE_ACTION_REJECT,
],
);
// → ['PENDING APPROVAL', ...]
$pendingForMe = $this->getLeaveRequestDao()->findInStates($states, $myEmpNumber);
getActionableStates is the inverse of getAllowedActions: "which states put items in my queue?" Used for approval-queue UIs and dashboard widgets ("3 items awaiting your action").
Checklists
Add a new transition to an existing workflow
- Identify the
FLOW_*constant for the workflow - Identify or define the action ID (a new
<DOMAIN>_ACTION_*constant if needed) - Migration inserts the row(s) into
ohrm_workflow_state_machinewith(workflow, state, role, action, resulting_state) - Add the action constant to
WorkflowStateMachineentity if it's new - Wire the action in the domain service: compute next state, update entity, persist, dispatch event
- (Optional) Add a notification email — see
mailskill (subscriber to the event)
Add a new workflow entirely
- Add a new
FLOW_*constant toWorkflowStateMachine(next integer) - Define the action constants for the flow (
<DOMAIN>_ACTION_*) - Migration seeds the initial transitions (rows for each
(state, role, action) → resulting_statecombination) - Define an Event base class for the flow (see
events); each transition fires a specific subclass - Domain service: methods for each transition, all using
AccessFlowStateMachineServicefor permission/next-state logic - Subscribers for notifications — see
mail - Optional: matching data-group permissions (
apiv2_<flow>_*) so the data-group layer also gates the resource
Debug "this user can't take this action"
- Is the data-group permission granted? Check
ohrm_user_role_data_groupfor this role + data group (seeauthorizationskill). 403 from the API authorization subscriber comes from here. - Is there a workflow row for this combo?
SELECT * FROM ohrm_workflow_state_machine WHERE workflow=? AND state=? AND role=? AND action=?— if no row, no transition allowed. - What's the effective role?
BasicUserRoleManagercomputes dynamic roles (Supervisor, ESS, etc.) per request. The user's static role might be Admin but they could also be an ESS (via the dynamic ESS addition). The workflow query is per-role — if there are rows for 'ESS' but not 'Admin', the Admin-with-ESS-role gets the ESS row. - Is the state string an exact match? Workflow rows compare by string equality.
'PENDING APPROVAL'≠'Pending Approval'≠'PENDING_APPROVAL'. Trim and case matter.
Things that bite
- All values are strings in the DB. The action
2is'2'. When passing constants, cast with(string)or compare carefully.'2' == 2is true loosely but'2' === 2is not. Use string comparisons throughout. getAllowedActionsreturnsnullfor "no rows matched" — not an empty array. Checkis_null($actions) || in_array(...)or default with?? [].- The cache is per-request, per-tuple.
$allowedWorkflowItemCachekeys on(workflow, state, role). Two calls in the same request reuse; a different request re-queries. Fine for typical use. - There's no schema-level FK between
ohrm_workflow_state_machine.roleandohrm_user_role.name. Misspelling a role in a seed silently makes the row dead —getAllowedActionsfor the typo'd role works but the typo never matches a real user's effective roles. - The same is true for
stateandresulting_state— there's no enum constraint. Typos produce dead-ends or unreachable states. Always seed via migrations and double-check the strings match what the domain service emits. getNextStatereturningnullis the "not allowed" signal. Don't accidentally treatnullas "stay in current state" — that's wrong. The right interpretation is "this transition is forbidden; throw or refuse."- The workflow state and the entity's
statuscolumn are conceptually the same value — but the workflow doesn't write the column for you. The domain service is responsible forentity->setStatus($nextState)after the lookup. Forgetting this means the lookup says "next state is APPROVED" but the leave is stillPENDING APPROVALin the DB. - Multiple workflow rows for the same
(workflow, state, role, action)is undefined behavior. The DB doesn't have a unique constraint — duplicates lead to whichever row gets returned first. Always seed uniquely.
Signals
- GitHub stars
- 1k
- Forks
- 749
- Last commit
- Jun 2026
Advanced
- Catalog kind
- skill
- Gateway key
workflow-orangehrm- Source
- github.com/orangehrm/orangehrm