lexik/workflow-bundle
LexikWorkflowBundle integrates Symfony Workflow into Symfony applications with DI configuration, workflow visualization, and Doctrine entity state tracking.
lexik/workflow-bundle is a Symfony2-era bundle (not a Laravel package) that wires a config-defined state machine — workflows, steps, and transitions — to a Doctrine entity, with a WorkflowManager service as the single runtime entry point. It is MIT, archived, ~126 stars, and effectively has no downstream dependents.
Decision first, code second. As a Laravel developer you have three realistic paths:
WorkflowManager API, then reimplement with symfony/workflow (standalone, Composer-installable in Laravel) or spatie/laravel-model-states.Flow/, the YAML examples in the tests, and the Doctrine listener for the pattern.Minimal setup on the Symfony side:
composer require lexik/workflow-bundle
Lexik\Bundle\WorkflowBundle\LexikWorkflowBundle in AppKernel.lexik_workflow: block with your workflow name, its steps, and the transitions between them.lexik_workflow.workflow_manager (or the workflow-specific service) into your controller.Look first at: WorkflowManager (the whole public API surface is small — start, read current step, apply transition), the Flow/ namespace for the step/process model, Resources/config/ for the service wiring and config tree, and the bundle's tests for canonical YAML examples.
First use case: an order checkout wizard — cart → address → payment → review → done — with a guard on payment that refuses to advance until the charge succeeds. That exercises config, manager, entity state, and one guard in a single afternoon.
Treat the YAML as your domain contract. Define each workflow once, name steps after business states rather than routes (awaiting_payment, not payment_page), and resist letting controllers invent step names. In the Laravel port this becomes a PHP enum plus a transition map — same shape, type-safe.
One entry point for mutation. Never set the entity's state field directly. All movement goes through the manager:
$manager->applyTransition($entity, 'pay');
return $this->redirect($manager->getNextStepUrl($entity)); // or map step -> route yourself
This is the pattern worth stealing even if you don't steal the code: a single choke point makes audit logging, authorization, and event dispatch trivial.
Guards belong in services. Step conditions reference container services/callables. Keep the decision logic in a service with injected dependencies (payment gateway, inventory, user), and let the YAML only name it. Business rules in YAML are unreadable, untestable, and invisible to static analysis.
Side effects via listeners, not inside transitions. Send the confirmation email, reserve stock, and write the audit row from a listener on the step-reached event, not from the controller after applyTransition. Make every listener idempotent — retries and double-clicks happen, and nothing in the bundle stops a transition from firing twice.
Storage choice is architectural, not cosmetic. Session-backed storage is fine for a marketing funnel wizard; it breaks the moment you need an API, multiple tabs, a queue consumer, or a support dashboard ("where are all orders stuck?"). For anything you'll report on, persist the state on the entity and query it with plain Eloquent/Doctrine.
Test the graph, not the controller. Assert the transition table directly: for each (step, transition) pair, confirm the allowed outcome and that the illegal ones throw. Then a single feature test that walks the happy path end-to-end. This catches dead-end steps and unreachable states, which are the real failure mode of config-driven state machines.
Porting checklist for Laravel: entity column → state string column with an enum cast; YAML → transition map in PHP; guards → can policies or guard callables; listeners → domain events; redirect-after-transition → named-route lookup keyed by state.
payment to awaiting_payment is not a config edit — it's an UPDATE over every affected row plus a coordinated deploy. Choose names you can live with.applyTransition + flush in an explicit DB transaction, and make each side effect safe to re-run.var/cache is the #1 cause of "my new step doesn't exist." In dev, confirm the compiled container actually contains your workflow before debugging anything else.How can I help you explore Laravel packages today?