How we Maintain Dozens of Symfony Workflows with Peace

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:



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?

What is our goal?


The Blind Paths

1. Something like EventSubscriberInterface

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:



Let's try one of the suggested solutions.


2. Symfony 5.3 fluent config

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:


Fluent API vs Nested Fluent API

Fluent APIs excel at IDE autocomplete. But once we do conditional nesting, it loses its power.

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.

Reflection

"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.

Smoke Safety First

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.

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."

Reach the Goal with Blend

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.


Final Result - Nice and clean!

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:



Happy coding!