phpmentors/workflower-bundle
Symfony bundle that integrates the Workflower BPMN 2.0 workflow engine into PHP applications. Define and execute business processes, manage transitions and tokens, and wire workflow events into Symfony services, configuration, and dependency injection.
Workflower is a Petri-net based workflow engine (places, transitions, tokens, markings), and this bundle is the Symfony glue: DI registration, config, a security participant, and (in the original demos) Doctrine-backed marking persistence. It is a 2017-era bundle with a 2020s-era patch on top — v1.4.0 modernises only the security participant. Read the first two steps as a compatibility probe, not boilerplate.
Install and watch what Composer does.
composer require phpmentors/workflower-bundle
If it resolves against your Symfony 5+/PHP 8 app, great — verify the pulled phpmentors/workflower version and check whether composer.json constraints actually moved in 1.4.0 (the release notes only mention the security participant; assume the PHP/Symfony constraints are unchanged at 2.8/3.x + PHP 5.6/7.0 until you read them). If Composer blocks, that is the finding: budget for a fork or vendor patch before writing any business code.
Register the bundle. Legacy kernels: add new WorkflowerBundle\WorkflowerBundle() to AppKernel::registerBundles(). Symfony 4+: WorkflowerBundle\WorkflowerBundle::class => ['all' => true] in config/bundles.php.
Read DependencyInjection/Configuration.php first. It is the authoritative list of config keys, and far more reliable than the README (which is Japanese-first and written for the 2015 demos). Then bin/console debug:container workflower --show-private and bin/console list workflower to see services and any dump/graphviz command the bundle registers. If you plan to use transition-level permissions, open Security/SecurityParticipant.php in the same pass — that file changed in 1.4.0.
Read the library, not just the bundle. vendor/phpmentors/workflower/src/Workflow/Workflow.php, src/PetriNet/*, and the test/ directory — the tests are the real usage documentation for constructor signatures, event names, and task hooks. Note the library uses unprefixed namespaces (Workflow\, PetriNet\), which is why autoloading looks odd next to WorkflowerBundle\.
First use case: build one small workflow by hand in a factory service, in a test, before touching config-driven registration. Two transitions, three places, one round trip.
<?php
// src/Workflow/ArticleWorkflowFactory.php
use PetriNet\{PetriNet, Place, Transition, InputArc, OutputArc};
use Workflow\Workflow;
use Workflow\MarkingStore\MarkingStoreInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
final class ArticleWorkflowFactory
{
public function __construct(
private MarkingStoreInterface $markingStore,
private EventDispatcherInterface $dispatcher,
) {}
public function create(): Workflow
{
$net = new PetriNet();
foreach (['draft', 'review', 'approved'] as $p) { $net->addPlace(new Place($p)); }
foreach (['submit', 'approve', 'reject'] as $t) { $net->addTransition(new Transition($t)); }
$net->addArc(new InputArc($net->getPlace('draft'), $net->getTransition('submit')));
$net->addArc(new OutputArc($net->getTransition('submit'), $net->getPlace('review')));
$net->addArc(new InputArc($net->getPlace('review'), $net->getTransition('approve')));
$net->addArc(new OutputArc($net->getTransition('approve'), $net->getPlace('approved')));
$net->addArc(new InputArc($net->getPlace('review'), $net->getTransition('reject')));
$net->addArc(new OutputArc($net->getTransition('reject'), $net->getPlace('draft')));
return new Workflow('article', $net, $this->markingStore, $this->dispatcher);
}
}
Then, in a controller or a test: $workflow->start($article); puts the token in draft, $workflow->fire('submit', $article); moves it to review, and $workflow->isEnabled('approve', $article) guards your UI buttons. Confirm each signature against Workflow.php in your installed version — they shifted across tags.
If you are in a Laravel app: the bundle is Symfony DI glue and will not install cleanly. Use phpmentors/workflower directly, bind MarkingStoreInterface and the dispatcher adapter in a service provider, and skip the participant entirely — you will be re-implementing the security check against Gate/Policy anyway.
One workflow service per business process, resolved by name. Don't scatter new Workflow(...) through controllers. Expose factories (or whatever registry the bundle's compiler pass builds from tagged services) and inject the concrete workflow into the handler that owns that process. Controllers then call fire($transitionName, $subject) and nothing else.
The subject stays dumb; the marking store owns persistence. Implement MarkingStoreInterface against a Doctrine entity column (JSON or a comma-joined place list is enough — a marking is just place → token count):
final class DoctrineMarkingStore implements MarkingStoreInterface
{
public function getMarking($subject): Marking
{
return new Marking($subject->getWorkflowMarking() ?? []);
}
public function setMarking($subject, Marking $marking): void
{
$places = [];
foreach ($marking as $place => $tokens) {
for ($i = 0; $i < $tokens; $i++) { $places[] = $place; }
}
$subject->setWorkflowMarking($places);
}
}
Keep that column out of your domain invariants — it is infrastructure state, and you want to be able to rebuild it.
Tasks on transitions, side effects after commit. Transition-attached task objects (implement the library's TaskInterface) are the natural place for "create invoice", "send notification". They run synchronously inside fire(), inside your DB transaction. Pattern: tasks record domain events / write to an outbox table; a post-commit listener or Messenger consumer does the I/O. Never send mail from a task.
Events for cross-cutting concerns, not for control flow. The workflow dispatches workflow.entry / workflow.transition / workflow.leave through Symfony's dispatcher. Subscribe for audit trails, logging, and cache invalidation. Every listener receives all workflows, so filter on $event->getWorkflow()->getName() immediately.
Guard transitions with the security participant — but keep it coarse. v1.4.0 rewires the participant onto AccessDecisionManagerInterface instead of constructing/consulting RoleHierarchyVoter directly. That is the right shape for modern Symfony: the participant now goes through the same decision pipeline as isGranted(), so your voters participate. Use it for role/attribute checks ("may this actor approve at all"), and keep subject-level rules ("may this user approve this article") in a voter that receives the subject or in an explicit check before fire() — the participant itself only has the token unless it forwards the subject as the decision object (verify in SecurityParticipant.php).
// config/packages/security.yaml — required now that the voter is not hand-wired:
security:
role_hierarchy:
ROLE_PUBLISHER: [ROLE_EDITOR]
ROLE_EDITOR: [ROLE_USER]
// src/Security/ArticleVoter.php — runs inside the participant's decide()
final class ArticleVoter extends Voter
{
protected function supports(string $attribute, $subject): bool
{
return $subject instanceof Article || str_starts_with($attribute, 'workflow.');
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
// cheap checks only — this runs on every guarded transition
}
}
Then make console/queue paths explicit: give them a synthetic token, or configure the participant to skip when there is no token (see gotchas). Don't let a cron-driven transition silently become a permission error.
Model routing with the net, not with if. Choice = one place with two guarded output transitions; parallelism = one transition with two output arcs; loops = output arc back to an earlier place (as reject above). This is where a Petri net outclasses symfony/workflow's state machine — you get real concurrency semantics for free, and the graph is inspectable.
Wrap transition names in constants or a PHP 8.1 enum and assert in a test that every enum case exists in the net. The engine resolves transitions by string at runtime; a typo is a production RuntimeException, not a compile error. This applies doubly to security attribute strings — assert every attribute used in config is supported by at least one voter.
Testing pattern: instantiate the workflow with an in-memory marking store (Marking held in an array keyed by spl_object_id($subject)) and no dispatcher. That gives you fast, dependency-free tests of the whole process model — start, fire, assert isEnabled(), assert marking contents. Test the security participant separately with a mocked AccessDecisionManagerInterface, not by booting the whole security firewall.
RoleHierarchyVoter to AccessDecisionManagerInterface means the decision now aggregates every configured voter under your firewall's strategy. Under affirmative (the default) this is usually a widening; under unanimous or priority, an abstaining voter can flip a previously-passing transition to denied. Re-run your permission tests after upgrading, and diff the security.yaml voter list.RoleHierarchyVoter carried its own RoleHierarchy. AccessDecisionManagerInterface does not expand roles for you — if you relied on implicit inheritance, declare security.role_hierarchy explicitly or inherited roles will start failing transition guards.decide() on a null token throws or denies depending on your Symfony version. Either inject a system token at the top of the command/consumer or decorate the participant with a bypass that logs loudly.decide() signatures drift across Symfony majors (notably the $object argument and the TokenInterface typehint). If 1.4.0 was written against Symfony 3.x/4.x, check the call against your installed AccessDecisionManagerInterface before trusting it — this is exactly the kind of thing that installs fine and fatals at runtime.AccessDecisionManager::decide($token, []) raises InvalidArgumentException. A config typo that yields security: [] on a transition becomes a hard 500, not a deny.dispatch($event, $eventName); pre-4.3 code (which this bundle still contains outside the participant) calls dispatch($eventName, $event). On modern Symfony, listeners appear to "never fire" because the object and string are swapped. Patch the bundle's dispatch() calls via a fork or a Composer patch plugin.Symfony\Component\EventDispatcher\Event is gone in Symfony 5. If the bundle's events extend it, you need a fork. Grep for extends Event before planning the upgrade. 1.4.0 does not change this.--ignore-platform-reqs is a trap. It will let you install the bundle on PHP 8 and then fail at runtime on dynamic properties, each(), or nullable-parameter ordering. Only use it to read the source, never in a deployed app.@Version optimistic locking on the entity holding the marking (or a pessimistic lock in the marking store) and catch the conflict to retry/refuse. Authorization passing is not a substitute for a lock: two authorised users can still race.fire(), a rollback after a task ran leaves side effects behind — and a denied security check that throws mid-fire leaves the same ambiguity. This is the strongest argument for the outbox pattern above.Marking objects into sessions or queues. Store the place-name list; reconstruct on read. Object graphs (places → arcs → transitions) don't survive serialization cleanly across versions.getMarking($subject) before and after every fire(); 90% of "the transition didn't happen" reports are a missing token in the input place — the other 10% are a security denial, so log the participant's attribute and the deciding voter at debug level. Also dump the net structure once at boot (foreach places and transitions) and keep the DOT output in your repo as living documentation.MarkingStoreInterface (persistence), custom voters + event subscribers (authorization/audit), task classes (business side effects), then subclassing Workflow (only if you need a shared fire() guard). Avoid patching PetriNet itself.workflower.; overriding them (including the participant) requires understanding the compiler pass ordering. Prefer defining your own services and leaving the bundle's config at defaults rather than fighting its YAML schema — for security checks especially, a thin wrapper you own is easier to test than a decorated bundle service.symfony/workflow is maintained and better documented; choose Workflower deliberately for the Petri-net concurrency model, and pin the fork you control.How can I help you explore Laravel packages today?