Published and pending: multi-workflow support for Plone
By Érico Andrei

Published and pending: multi-workflow support for Plone

Say your site has member profiles. A profile is published or private — that's the publication workflow doing its job, nothing to see here. But the person behind that profile is also a Foundation Member, and that has a life of its own: pending, active, lapsed.

Two lifecycles. One content item. One review_state.

So what do you do? In my experience, one of three things:

  • Split it into two content types. One for the profile, one for the membership. Congratulations: duplication today, drift in six months. (This is most of us use today)
  • Fold them into one workflow. Now you have published-and-activepublished-and-lapsedprivate-and-pending, and unrelated permissions and transitions welded to each other forever.
  • Use a plain field. Nice and simple, and you just threw away guards, transition permissions, history, worklists and role mappings — which is to say, every reason you wanted a workflow in the first place.

None of these are good. I have shipped all three.

Plone could always do this

Here's the annoying part: the machinery has been sitting there the whole time.

portal_workflow supports chains — more than one workflow active on the same content type. getChainFor resolves the chain through an IWorkflowChain multi-adapter, so it is pluggable by design. doActionFor and getInfoFor both take a wf_id= argument. And DCWorkflow keys each object's status records by workflow id, so two workflows in the same chain never overwrite each other's state.

Configure a two-workflow chain by hand today and it works. Then you walk straight into the wall, which is not the workflow tool — it's everything around it:

  • the catalog indexes review_state and nothing else;
  • plone.api.content.get_state hard-codes review_state;
  • plone.restapi flattens the whole chain into one transitions list, so an unrelated workflow's transitions arrive next to Publish with nothing to tell them apart;
  • the history viewlet reads the first workflow only;
  • Volto's workflow reducer discards everything that isn't currentStatehistory or transitions.

The feature exists. The stack around it doesn't.

collective.multiworkflow

So I wrote the missing part: collective.multiworkflow on the backend, @plone-collective/volto-multiworkflow on the frontend.

It's behavior-driven, which is the bit I care about most. You don't go and rewrite chains type by type in portal_workflow. You say "content providing this behavior also runs that workflow", once, in ZCML:

<plone:additionalworkflows
    marker=".behaviors.IMembership"
    workflows="membership_workflow"
    label="Membership"
    />

That is very nearly the whole API surface. Enable the behavior on a type and the workflow comes along for the ride.

A few properties that matter more than they sound:

  • Workflows are appended, never substituted. The chain configured for the type stays exactly where it is, and stays first. review_state keeps meaning what it means today, everywhere.
  • Content without the behavior is untouched. It gets the same REST payload Plone has always served, with no chain key at all. A client written before you installed this cannot tell the difference.
  • One catalog index for the entire chain. workflow_states holds one workflow-id|state-id value per workflow, so a site does not grow an index every time a behavior contributes a workflow. You query it like any other index:
active = api.content.find(
    workflow_states=format_state("membership_workflow", "active")
)
  • The REST API grows a chain key on participating content, with one entry per workflow: its state, state variable, available transitions and history. @history tags every entry with the workflow_id that recorded it. Meanwhile the top-level transitions list is narrowed to the primary workflow — the opposite of what stock Plone does, and precisely why old clients keep behaving.
  • Volto shows one selector per additional workflow, beside the publication one, and the history view attributes each row to the workflow that recorded it.
  • It ships no behavior of its own. Installing the add-on changes nothing about your content types until you declare one. The worked example lives in a demo subpackage that isn't loaded by default.

There are also plone.api-shaped helpers — get_stateget_statestransitiontransitions — whose defaults do exactly what plone.api does today. Pass workflow_id= when you mean a specific workflow in the chain, and leave it out when you mean "the usual".

Someone is already using it

This isn't a demo package with a demo workflow. collective.casestudy runs on it today.

Its IProvider behavior contributes a provider_workflow — created, pending, listed, verified, archived — to Organization content. So an organization is published or private and, quite separately, listed or verified. The two never touch. The directive labels it "Provider listing", and that's the name editors see instead of the workflow's own title.

Organization with Profile behavior

If you'd rather read a real example than follow a tutorial, read that package. It's also where plone.org.br and plone.org are headed, which I take as decent evidence the design survives contact with an actual site.

Modified history view

The fine print

It's 1.0.0a2. Alpha means alpha — the API can still move.

The backend needs Plone 6.2 and Python 3.11 or later; the frontend needs Volto 18 or later. The backend is perfectly usable alone: it doesn't need a frontend to append workflows, index their states, or serve them over REST.

Each additional workflow has a contract to honour:

  • declare your own state_variable, never review_state — and workflow_states is the name to use, since WorkflowTool._reindexWorkflowVariables then keeps the index fresh for free;
  • keep transition ids unique across the chain;
  • don't let two workflows in one chain manage the same permission.

That last one is a genuine constraint, not a style preference: a transition re-applies the permission mappings of its own workflow only, so an overlap leaves whichever workflow moved last in charge. The add-on reports overlaps through api.conflicting_permissions(obj) rather than trying to referee them.

Out of scope on purpose: placeful workflows, sub-workflows (the chain model is concurrent, not hierarchical), and any change at all to review_state semantics.

Where this is going

Here's my actual opinion: an add-on is the wrong home for this.

To pull it off without forking anything, the package overrides two plone.restapi service registrations, vendors the body of HistoryGet.reply(), wraps SerializeToJson.__call__ in place just to add one key, shadows two Volto components, ships its own action and reducer only to carry a key the core reducer throws away, and monkey-patches plone.exportimport. Every one of those is maintenance I get to redo on each Plone and Volto upgrade. Every one of those disappears if the change lands where the code already lives.

So I wrote a PLIP: Additional workflows contributed by behaviors, aimed at Plone 6.3. It's still marked WIP, and it's still looking for a seconder.

If you've ever wanted two workflows on one item — or you're about to explain why this is a terrible idea — that issue is the place. I would much rather hear it now than after it ships.

Try it

Two packages, released separately, and you want both. The backend one does the actual work; the frontend one renders it.

Backend, on Plone 6.2 and Python 3.11 or later:

uv add collective.multiworkflow

Then install Multi-Workflow Support for Plone from the add-ons control panel.

Frontend, on Volto 18 or later — add it to your project's package.json, in both places:

"addons": [
    "@plone-collective/volto-multiworkflow"
],
"dependencies": {
    "@plone-collective/volto-multiworkflow": "*"
}

Listing it under dependencies alone installs the code and never registers it, so nothing renders and you get to spend an afternoon wondering why. Ask me how I know.

Skip the frontend and the backend still works — it appends workflows, indexes their states and serves them over REST perfectly happily. You just won't see any of it in the UI.

The documentation has a tutorial that builds a second workflow end to end, plus how-to guides for declaring a contribution, writing a workflow that composes, and searching by state. The code lives at collective/collective-multiworkflow.