Symfony + EasyAdmin: workflows as rows, not code

The Symfony Workflow component assumes your state machines are configuration in the deployment sense: places and transitions declared in framework.yaml, checked when the container compiles, changed by shipping a release. For most applications that is the right assumption. For a document pipeline whose owners are operators — people who need to add a stage, move where a task happens, or lengthen a wait without raising a ticket — it is exactly wrong. The pipeline is their domain. The engine is ours.

The move that fixes it is small in concept: store the definitions as ordinary Doctrine entities and build Workflow objects from rows at runtime. What follows is the shape that emerged building a print-and-post pipeline this way, and — more usefully — the four or five places where the naive version bites, because a workflow in a file is checked at build time, and a workflow in a database has to earn that safety back at save time.

Recently I rehearsed a demo of this: starting from a nearly empty system, an operator (me, in a browser) built a three-stage delivery line — created the definition, its places, its transitions, attached behaviour to one of them, enabled it — and the next document submitted flowed through it to a print house’s API with no deployment anywhere in the story. That is the feature. Everything below is what makes it safe to hand over.

Three entities and a registry

A workflow is three kinds of row:

class WorkflowDefinition   // one named workflow
{
    private string $name;              // 'imail-post'
    private string $label;             // 'iMail Post'
    private WorkflowType $type;        // state_machine | workflow
    private ?WorkflowPlace $initialPlace;
    private bool $enabled;             // defaults to OFF — see below
    private string $markingProperty;   // which column carries the state
}

class WorkflowPlace        // a stage a subject can rest in
{
    private string $name;
    private string $label;
    private array $metadata;
}

class WorkflowTransition   // a named move between stages
{
    private string $name;
    private Collection $froms;
    private Collection $tos;
    private ?string $guard;            // an ExpressionLanguage string
    private array $metadata;           // where the behaviour lives
}

A registry turns these into real Workflow instances on demand, using the component’s own DefinitionBuilder and a MethodMarkingStore pointed at the definition’s markingProperty. That last field is worth having from day one: it lets one subject run two workflows at once on two different columns — ours use a fixed, system-owned ingest machine on one property and the operator’s own processing graph on another, and the two never collide.

The registry caches built definitions, and saving any part of a workflow invalidates that cache. So an edit takes effect on the next document handled — not on the next deploy, and not on some cron that rebuilds things. This is the property operators actually care about, and it costs one cache-pool clear in an entity listener.

EasyAdmin is the easy part; save-time validation is the job

CRUD controllers for the three entities are unremarkable EasyAdmin. The work is in what you refuse to save, because the compiler is no longer standing behind you:

One check is deliberately a warning rather than a refusal: a stage not yet reachable from the start. That is the normal condition of a half-built graph — the distinction between you have made a mistake and you are not finished yet is worth preserving in the UI.

Guards get one more property that matters: a guard that errors blocks the move. It never waves a document through. A broken condition that defaulted to “allow” would silently send documents somewhere nobody chose — and the block records that the expression broke, distinctly from it having been false, so an operator can tell a misconfiguration from a decision.

The behaviour line

Everything a transition does lives in its metadata, and the whole contract is four keys:

{ "task": "deliver", "args": [], "deadline": { "after": "PT1S" }, "next": "mark_posted" }

task names which piece of work runs when the move completes. The values on offer come from code — a tagged interface (below) — so the admin’s dropdown lists exactly what is implemented and nothing else; an unknown name is refused at save. Omit it and the move is a pure state change, which is a perfectly good configuration: our mark_posted is exactly that.

args is how to run it, this time. Settings belong to the task, not the engine — the same task on two transitions can carry different args, and each task validates its own at save via validateArgs(). An empty args is a decision, not an omission: for our delivery task it means “no pinned provider — let the rule engine choose at delivery time”.

deadline.after is how long a subject may rest before a timer acts, as an ISO-8601 duration, with the clock starting when the subject entered its current stage. Add deadline.transition and the timer watches the stage this move leads to, firing that other named move — “published, unread for P3D, then send_to_post”. Omit it and the deadline is self-firing: it watches the stage this move leads out of and fires this move itself, which is the only way to leave a workflow’s first stage on a timer. One scheduled sweep command applies overdue deadlines through the ordinary path — same guards, same events — and it must be idempotent (the subject’s own stage is the guard) and bounded (a limit per run).

next is what follows success. Work runs asynchronously — completing the transition queues a message; a worker does the job — and when the handler finishes, it applies the operator’s named follow-up. The graph continues itself, with the sequence still under configuration’s control. The handler flushes the completed work before attempting the follow-up: if next turns out to be blocked (someone moved the document meanwhile), the work is preserved, the blockage lands on the subject’s timeline with its reasons, and the message parks for a human. For a task whose side effect is a letter through a real door, that ordering is not a nicety.

The seam: one interface, one tag

A new kind of work — a new stamp, a new print house, a new transformation — is the one routine reason a developer appears in this story:

#[AutoconfigureTag('app.workflow_task')]
interface WorkflowTaskInterface
{
    public static function getName(): string;   // the word operators pick
    public static function getQueue(): string;  // which worker runs it
    public function validateArgs(array $args): array;  // problems, or none
    public function createMessage($subject, array $metadata): AsyncTaskMessageInterface;
}

The tag is the entire registration. Implement the interface and the task name appears in the admin’s dropdown, its settings are validated as the operator saves them, it rides its own queue (so one slow task cannot block another, and one failing integration can be paused alone), and its runs land on the subject’s timeline with the settings they ran with.

The corollary is worth stating as plainly to your stakeholders as to your team: until the class exists, no amount of configuration conjures the capability. Configuration composes what code provides. That sentence is the boundary line, and in my experience drawing it explicitly — cyan for what an operator changes at operational speed, magenta for what a developer changes at release speed — is what makes this architecture explainable in a meeting rather than merely workable in production.

What it buys, and what it costs

Bought: rewiring the pipeline is an afternoon and a support call, not a change request. Adding a stage, moving where stamping happens, changing a three-day wait to five, pointing a step at a different provider — none of it is a release. And the composition surprises you: our “publish online, post only if unread in three days” requirement fell out of a role label, a deadline, and a task binding — three mechanisms that know nothing about each other, composing because the seam is in the right place.

Paid: a workflow an operator can change is a workflow an operator can misconfigure. The answer is not to take the power back; it is the save-time validation above, plus an append-only event timeline per subject so that every move — operator click, task completion, timer — records what fired and why. Between “you cannot save a broken graph” and “every move is attributable afterwards”, handing the pipeline to its owners stops being brave and starts being ordinary.