I have lost more time to status fields than to anything else in an admin panel. The field looks harmless. It is a select, the options come from an enum, and the resource saves whatever the user picked. A week later someone asks why a document jumped from draft to approved without a review, and the answer is sitting in the form: every state was offered to every role, on every record, with no memory of how the record got there.
A status column is a value. A workflow is the set of moves that value is allowed to make. I kept treating them as the same thing, and the panels I built kept letting people take roads the rest of the application did not know existed. Workflow Manager is the package I wrote after I got tired of encoding that graph as another chain of if statements inside a Filament resource.
The enum names the states. It does not name the roads.
I still want the states in PHP. A backed string enum is the right vocabulary: it is the cast on the model, the value in the database, and the label in the UI. For a document that moves through review, that looks like this.
enum DocumentStatusEnum: string
{
case DRAFT = 'draft';
case SUBMITTED = 'submitted';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function getLabel(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::SUBMITTED => 'Submitted',
self::APPROVED => 'Approved',
self::REJECTED => 'Rejected',
};
}
}
The model casts status to that enum and tells the package where the vocabulary lives.
class Document extends Model implements WorkflowsContract
{
use HasWorkflows;
protected function casts(): array
{
return [
'status' => DocumentStatusEnum::class,
];
}
public static function getStates(): string
{
return DocumentStatusEnum::class;
}
}
That is the whole contract. getStates() returns the enum class. The package can discover the model, fill the state list, and stop asking me to retype "draft" in three places.
What the enum cannot say is that a reviewer may move submitted to approved, and that an author may not. It cannot say that approval requires an amount under a limit, or that rejection is only legal when a reason is present. Those are properties of a transition, and they change more often than the set of states. Putting them in the enum turns a vocabulary into a policy, and then every policy change is a deploy.
Transitions belong in data
The first versions of this lived in the resource. A Select::make('status') listed every case, and a visible() or disabled() closure tried to remember the rules. It works until the second role arrives. Then the closure grows a role check, then a second model, then a condition someone described in a meeting and expected to see in the panel the same afternoon.
I moved the graph out of the form. A workflow record points at a model, has a name, and is scoped to a role. States on that workflow are the enum cases. Transitions are rows: from, to, and an optional list of conditions. Operations can add "submitted → rejected" for reviewers without waiting on a release. The code still owns the vocabulary, because a state that is not in the enum is not a state. The panel owns the roads between those states.
Different roles get different workflows for the same model. An author and an admin do not share a graph just because they share a table. The form picks which graph applies:
StateSelect::make('status')
->setWorkflowForModel(Document::class)
->setRole('admin')
->required();
setRole() is not decoration. It is how the select decides which transition table to read. If you pass the author's role, you get the author's roads. If you pass admin, you get the admin roads. One enum, two policies, no copied resource.
A transition is allowed, or it is not
The select is where the rule becomes visible, so it has to be strict.
The current state is always offered. Saving a record without touching status must not fail because the user "did not transition." Every other option has to be the target of a transition from the current state. If conditions are enabled, that transition's conditions have to pass against the record in front of the user. States that fail are not hidden somewhere in a tooltip. They are absent from the select.
Creating a record is the awkward case. There is no current state, so there is no edge to walk. The package skips workflow filtering for actions listed in ignored_actions. Create is ignored by default. Replicate is a common one to add, either in config or on the component:
StateSelect::make('status')
->setWorkflowForModel(Document::class)
->setRole('admin')
->setIgnoredActions(['create', 'replicate'], override: true)
->required();
override: true replaces the config list. Without it, the extra actions are merged in. I would rather be explicit on the one form that behaves differently than inherit a global list I will forget.
rules_enabled is the kill switch. Set it to false and conditions are ignored while the transitions themselves stay in place. I use that when a rule is wrong in production and I need the legal roads back without deleting the conditions someone spent an hour entering. It is a switch, not a design. Leave it on once the rules are right.
The select also validates. Hiding an option is not enforcement. A crafted request can post approved whether or not the option was rendered. StateSelect rejects a value that is neither the current state nor an allowed transition. If rules_enabled is on, the conditions have to pass as well. I do not want a second validator in the resource that can drift from the one the select already used.
Conditions are part of the edge
A transition with no conditions is a door that is either there or not. Most real workflows are worse than that. "Submitted may become approved" is true only when the amount is under a threshold, or the title matches a pattern, or a related user's department is the one that owns the record.
Each condition sits on one transition. It has a field, an operator, and a value. The value is either static or dynamic.
A static value is a literal: status equals pending, due date is on or after 2025-01-01, title matches %hello%. A dynamic value is another attribute, including dot notation into a relation: user.role, invoice.total. Equality normalises enums and strings, so DocumentStatusEnum::DRAFT and "draft" compare equal. That matters because the cast and the raw column do not always meet as the same type.
If a dynamic compare-with path is null or blank, the condition fails and the transition is not offered. I would rather block the move than treat a missing relation as a pass. Failing open is how status fields get interesting.
The operators are the ones I actually reach for: =, !=, the numeric and date comparisons, in, like, and regex. like is case-insensitive and uses SQL wildcards. regex is a PCRE pattern, which is enough for codes like ^[A-Z]{2}-\d+$ and dangerous if you let it become a programming language. Keep those patterns boring.
Several conditions on one transition combine left to right. The first result stands. Each following row applies its own logical group, AND or OR, to the result so far. Condition 1 AND condition 2 OR condition 3 means (C1 AND C2) OR C3. It does not mean a tree, and it does not mean "all ANDs first." Row order is the expression. If you reorder the rows, you change the rule. I show that order in the admin because a hidden precedence table is how people approve the wrong documents and blame the select.
If you cannot see the graph, you will get it wrong
I added a diagram because I did not trust myself to read a table of from/to rows and know what I had built. From the workflow list, View workflow draws the graph with Cytoscape. Start is a green ellipse. States are blue. A transition with conditions is a dashed edge, and each condition is an orange node whose label is the rule in plain English: "title matches pattern %hello%", "description is not equal to value of status." AND and OR sit on the edges between those nodes.
The diagram is not a second editor. It updates when the transitions change. Fit and Reset zoom are there because a workflow with a dozen states and conditions will otherwise open as a knot. I use it in reviews. If I cannot point at the dashed edge and say the sentence out loud, the condition is too clever and it should be two transitions or a real policy in code.
Permissions stay yours
The package does not invent a permission system. With enable_policy on, it asks Laravel's gate for the names in config: view_any_workflow, create_workflow, update_workflow, and the rest of the usual Filament set, including restore, force delete, reorder, and replicate. If you use Spatie, or your own gates, you assign those names the way you already assign everything else. The defaults are boring on purpose. A workflow screen that bypasses the panel's auth is a second admin.
Navigation is the same idea. Label, group, sort, icon, and slug are config, because "State Workflows" under Settings is right for my panels and wrong for yours.
What this is not
This is not a process engine. There is no timer, no branch that waits on a queue, no compensation step. If a transition must send mail, write a ledger line, or call a payment gateway, that still belongs in your model or a listener. The package decides whether the state change is legal. Your code decides what the change means.
It is also not a place to hide the domain. If every record of a type follows one graph, and that graph is stable, a match in PHP is shorter and easier to test. I reach for stored transitions when the roads differ by role, when someone who does not deploy needs to change them, or when the conditions are data ("this department", "this threshold") rather than structure.
Filament is a good host for this because the failure mode is specific. The panel is where non-developers edit records, and the status field is where they do it. A public API can reject an illegal transition with a 422 and a sentence. An admin select that offers the illegal transition will be used, and then the 422 never happens because the form already agreed.
The rule I keep
If a status field can store a state your code does not handle, the field is the defect. The enum is the list of words you are willing to store. The workflow is the list of sentences you are willing to allow: this role, from here, to there, when these conditions hold. The select is only the surface.
I published that split as xentixar/workflow-manager. It targets PHP 8.1, Laravel 11, and Filament 5. The states stay in your enum. The roads stay in the panel, where the people who argue about them can see the graph.