There are dozens of posts and talks about how Symfony Workflows work and what they are for. To give you the simplest example, if you have a post - it can be drafted, reviewed, or published. The workflow component makes sure the transitions between these states are valid. That's it.
Yet, there is not a single post about how terrible the configuration is.
On one hand, we have Symfony components, like Event Dispatcher and Console, with native PHP 8 strict attributes. On the other hand, we have Workflows configuration with a fractal array of strings. It's like a minefield for a developer who's tasked with adding a new line there.
How can we do it better?
Hint: the goal is this post is not only to give a solution to a problem. But also to show a way to think about problems, that have easy but wrong solutions. How to find a better way, if there is none in Google or GPT. If we feel there is a better way, we have to find it ourselves. With our brains and skills. Have fun!
Maybe there is a better way than YAML trees, so I asked you first:
I have a question to @symfony devs who use workflows extensively.
— Tomas Votruba (@VotrubaT) March 23, 2025
How do you manage workflow definitions to be readable and typo-proof?
I've seen 700+ lines long definitions and I'm scared to even look at it. We use PHP already.
These long array of strings are error-prone,… pic.twitter.com/dNu88gMwRm
You've shared a couple of new syntaxes I didn't know about!
This is a summary of the replies:
Also, couple of replies like:
"This is cool. I always use YAML and I am always extra careful when defining the workflow"
Framework should not steal more attention from us while working with it. Quite the contrary: it should improve our work, save us attention, so we can be focus about the work we love. That's why it's called "framework" and not "longitudinal academic research study" :)
Okay, we have some input and new resources to explore... What now?
At first, I followed the analogy with the EventSubscriberInterface
contract: we implement a single interface, which requires a single method that returns events. Simple, single place, no config, all in clean PHP. After kicking off, I've hit 2 problems:
EventDispatcher
service loads services that implement EventSubscriberInterface
, and works with them internally (more or less).FrameworkExtension
comes in and creates a Workflow\Definition
object in ~120 lines. There is no "WorkflowManager" that would accept an external service. If only this building process would be decoupled from extension + compiler pass.Let's try one of the suggested solutions.
We're using using FrameworkConfig
and similar to get PHPStan deprecation errors early.
But I didn't realize, that the workflows have more deeper fluent syntax:
return static function (FrameworkConfig $framework): void {
$blogPublishing = $framework->workflows()->workflows('blog_publishing');
$blogPublishing->type('workflow') // or 'state_machine'
->supports([BlogPost::class])
->initialMarking(['draft']);
$blogPublishing->transition()
->name('to_review')
->from(['draft'])
->to(['reviewed']);
Wow! I wanted to jump into this syntax right away with our hundreds of lines. But, after asking GPT to rewrite the first array to this fluent API, I slowly had to face 3 problems:
->name()
. The API is not leading us, we still have to learn the documentation.Fluent APIs excel at IDE autocomplete. But once we do conditional nesting, it loses its power.
self
and some return nested objects. And some both.The configs will quickly become like a target on a shooting range:
$blogPublishing->transition()->name('to_review')->from(['draft'])->to(['reviewed'])
->transition()->name('to_publish')->from(['reviewed'])->to(['published'])
->transition()->name('to_reject')->from(['reviewed'])->to(['draft']);
"I have not failed.
I've just found 10,000 ways that won't work."Thomas Edison
Research and experiments haven't yielded any progress so far. So I took a step back, a took week's break from this topic.
"This might be a tough one", I thought to myself. There are many tempting, same-complexity different-place solutions that our team would accept.
But it would make future maintenance harder because we'd have to learn different syntax. My mission is to improve the project maintenance, even if we and all current are gone.
Then I realized, "Hm, how do I want to make such a huge change (1000 lines) without having a safeguard? Maybe my brain is not ready, because it cannot think freely". We put smoke tests on every single part of the project we modify - routes, controller, services, event subscribers, entity mapping, test fixtures, etc. These tests can be used for years and they smoke check important portions of the project.
But we don't have any such "they still load the same way" test for workflows. Time to fix that!
How do we test that all workflows still stay the same? Well, we test routes by dumping them to json
, then we verify the json is still the same.
Can we somehow dump the workflow definitions? We can.
MermaidDumper
In short:
$mermaidDumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_STATEMACHINE);
$dumpedContents = '';
foreach ($workflows as [$workflow, $supportStrategy]) {
$dumpedContents .= $mermaidDumper->dump($workflow->getDefinition());
}
$this->assertSame('expectedHash', sha1($dumpedContents));
In full, here is the full test we use in gist.
This simple test saved me a couple of times from confidently crashing production. We had valid workflow syntax, yet, I accidentally changed the intended behavior to the wrong one. No more.
"The secret of life is to fall seven times
and to get up eight times."
The week has passed, and emotions are gone, but the mission is not done. Time to get back to work.
Let's take it step by step. We'll definitely use PHP config - a standalone file apart from framework.php
for better readability:
// app/config/workflows.php
return function (ContainerConfigurator $containerConfigurator): void {
$workflows = [
// ...
];
$containerConfigurator->extension('framework', [
'workflows' => $workflows,
]);
};
The 2 solutions we've already tried had one problem in common: What is required and what is optional?
How do we define required values? We ask for them in a constructor - let's create a custom WorkflowDefinition
object that will hold all relevant configuration:
final class WorkflowBuilder
{
public function __construct(
private readonly string $name,
private readonly string $type,
private readonly array|string $supports,
private readonly string $initialMarking,
private readonly array $markingStore
) {
}
}
That's easy. But what about places and transitions? Well, all places are already defined in transitions, right? So there is no point in duplicating them.
All we have to add are transitions then:
public function addTransition(string $name, array $from, array $to): self
{
// ...
}
We can also use the static create()
method and default values for convenience:
// app/config/workflows.php
return function (ContainerConfigurator $containerConfigurator): void {
$workflows = [
WorkflowDefinition::create('post_publishing', 'draft', Post::class, 'state')
->addTransition('to_publish', from: 'reviewed', to: 'published');
];
$containerConfigurator->extension('framework', [
'workflows' => $workflows,
]);
};
To improve our experience in case of error, we add validation to the create
method. When we put accidentally Past
object or a non-existing status
property, the exception will tell us before config it even reaches the framework extension:
use Webmozart\Assert\Assert;
final class WorkflowBuilder
{
public static function create(
string $name,
string $initialMarking,
string|array $supports,
string $markingProperty,
string $type = WorkflowType::STATE_MACHINE,
): self {
$markingStore = [
'type' => 'method',
'property' => $markingProperty,
];
Assert::notEmpty($supports);
if (! is_array($supports)) {
$supports = [$supports];
}
foreach ($supports as $support) {
Assert::classExists($support);
}
return new self($name, $initialMarking, $supports, $type, $markingStore);
}
// ...
}
That's it, we have the created workflow definition with all required items and have a nice and clean object API. Places are derived from the transitions. Nice! There is just one little problem: the workflows configuration only accepts dumb arrays:
$containerConfigurator->extension('framework', [
// ...
'workflows' => $workflows,
]);
Also, we missed validation to check if there is at least 1 transition. Hm, how do we solve this?
We add a simple build()
method at the end of the chain, that creates the array for us. We hide all the validation inside as well:
// app/config/workflows.php
return function (ContainerConfigurator $containerConfigurator): void {
$workflows = [
...WorkflowDefinition::create('post_publishing', 'draft', Post::class, 'state')
->addTransition('to_publish', from: 'reviewed', to: 'published')
->build();
];
$containerConfigurator->extension('framework', [
'workflows' => $workflows,
]);
};
The build()
method can look like this:
public function build(): array
{
$places = [];
foreach ($this->transitions as $transition) {
$places = array_merge($places, $transition['from'], $transition['to']);
}
$places = array_unique($places);
Assert::notEmpty($this->transitions, sprintf('No transitions found for "%s" workflow definition', $this->name));
return [
$this->name => [
'type' => $this->type,
'marking_store' => $this->markingStore,
'initial_marking' => $this->initialMarking,
'supports' => $this->supports,
'places' => $places,
'transitions' => $this->transitions,
],
];
}
I'll leave the exact contents of your WorkflowBuilder
up to you, to fit your specific project's needs. E.g. we always use one type of marking store, so we can remove it from the constructor.
Now we have much less code to maintain, we see instantly what is required and what is optional, and we have peace of mind that we won't break anything:
I'm still working on the @symfony workflow configs improvements.
— Tomas Votruba (@VotrubaT) April 15, 2025
I want an intuitive code that will tell me (throw exception), if I've use wrong/not enough/too much configuration.
Right in the workflows.php config, but smarter than me 🤩
Getting closer today 😎
How do you… pic.twitter.com/LYFypYww7j
Happy coding!