Implementing a user workflow with Symfony and EasyAdmin3

Published on 2021-06-30 • Modified on 2021-06-30

In this post, we will see how to implement a user workflow with Symfony and EasyAdmin3. We will use the Symfony workflow component to handle the users' state and we will use EasyAdmin3 to modify this state with custom actions. Let's go! 😎

Prerequisite

I will assume you have a basic knowledge of Symfony and know how to install a component and a bundle.

Configuration

  • PHP 7.4
  • Symfony 5.3.5
  • The symfony/workflow component
  • The EasyAdmin3 bundle
  • The fre5h/DoctrineEnumBundle bundle

Introduction

The Symfony workflow is one of the most useful and powerful of all components. It's a widespread need to handle "states" for objects in an application. Let's see how we can use it in conjunction with EasyAdmin3 and take the best of both of them.

Goal

The goal is to create a workflow for users. We want them to pass from a "created" state where the user has registered themself to a "validated" state where they will be able to log in. A custom EasyAdmin3 action will do the transition.

Preparing the database

First, we must prepare our database so we can use a workflow on a User entity. Let's add a "state" field. This field will be the reference for the workflow and will be an enum field. To create this field, I will not use the maker bundle but the fre5h/DoctrineEnumBundle that provides a new enum type for Doctrine, precisely what we need for our state field. Let's install it:

composer req fresh/doctrine-enum-bundle

This bundle adds a new Doctrine type that we will use for our new field. Let's create the new UserStateType.php; we can put this file in the src/DBAL/Types directory for example:

<?php

declare(strict_types=1);

namespace App\DBAL\Types;

use Fresh\DoctrineEnumBundle\DBAL\Types\AbstractEnumType;

/**
 * @extends AbstractEnumType<string>
 */
final class UserStateType extends AbstractEnumType
{
    public const STATE_CREATED = 'created';
    public const STATE_VALIDATED = 'validated';

    public const TYPES = [
        self::STATE_CREATED,
        self::STATE_VALIDATED,
    ];

    /**
     * @var array<string,string>
     */
    protected static $choices = [
        self::STATE_CREATED => 'user_state_created',
        self::STATE_VALIDATED => 'user_state_validated',
    ];
}

The file is pretty straightforward; we declare the constants representing the different values for our enum type. For now, let's keep things simple. We will handle only two states: "created" and "validated". We declare these two constants; then, we use them to define the $choices property used by the bundle to know which values are allowed for this field. OK, we have our new Doctrine type; let's declare it the ORM configuration:

doctrine:
    dbal:
        driver: 'pdo_mysql'
        server_version: '5.7'
        charset: utf8mb4
        url: '%env(resolve:DATABASE_URL)%'
        types:
            ArticleType: App\DBAL\Types\ArticleType
            UserStateType: App\DBAL\Types\UserStateType

That's it, now Doctrine is aware of the new type and can use it. So let's create the new field:

    // use Fresh\DoctrineEnumBundle\Validator\Constraints as DoctrineAssert;

    /**
     * @ORM\Column(name="state", type="UserStateType", nullable=false)
     * @DoctrineAssert\Enum(entity="App\DBAL\Types\UserStateType")
     *
     * @see config/packages/workflow.yaml
     */
    private string $state = UserStateType::STATE_CREATED;

Notes: The field isn't nullable because we want all users to have a state, and we use the first constant that we introduced in the UserStateType as the default value. We add a validation assertion responsible for checking that only the values we defined (the $choices property) are valid (as a Choice constraint). The bundle provides it. Let's reload the model and the fixtures:


The new enum field

We can see our new state field, and only the two values we have defined in the UserStateType are allowed. As we set a default value in the entity, we don't even have to modify the fixtures; in this case, the "created" value will be used (of course, you probably want to have users with other states in your fixtures). OK, we have our new field; let's use it with the workflow component.

Configuration of the workflow

First, let's install the workflow component:

composer req workflow

You will notice that flex has created a new empty file config/packages/workflow.yaml. We can configure our workflows here. Here is a basic setup for the User entity and its state property:

# https://symfony.com/doc/current/workflow.html#configuration
# php bin/console debug:autowiring workflow
framework:
    workflows:
        user:
            type: 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'state'
            supports:
                - App\Entity\User
            initial_marking: created
            places:
                - created
                - validated
            transitions:
                validate:
                    from: created
                    to:   validated

As you can see, we find the two values that we have configured for the enum in the places key. The supports one tells which entity is concerned, and eventually, the marking_store config means that the workflow reference (marker) will use the state property by using its getter and setter. We also specify the initial stated with the initial_marking, which must be the same as the default value we put in the entity's property declaration. Let's check if everything is correctly configured; we can use the command:

bin/console debug:autowiring workflow
Autowirable Types
=================

 The following classes & interfaces can be used as type-hints when autowiring:
 (only showing classes/interfaces matching workflow)

 Psr\Log\LoggerInterface $workflowLogger (monolog.logger.workflow)

 Symfony\Component\Workflow\Registry (workflow.registry)

 Symfony\Component\Workflow\WorkflowInterface $userStateMachine (state_machine.user)

We have a new state_machine.user service, and a bind has been automatically created so we can already inject it into our services. So let's use it to create a proxy service that will ease our lifes and provides several shortcuts to use this workflow. Let's call it UserWorkflow and put it into src/workflow. Here it is:

<?php

declare(strict_types=1);

namespace App\Workflow;

use App\Entity\User;
use Symfony\Component\Workflow\WorkflowInterface;

final class UserWorkflow
{
    public const TRANSITION_VALIDATE = 'validate';

    // public const WORKFLOW_USER_COMPLETED_VALIDATE = 'workflow.user.completed.validate';
    // use this event to send a confirmation email to your user for example.

    private WorkflowInterface $userStateMachine;

    public function __construct(WorkflowInterface $userStateMachine)
    {
        $this->userStateMachine = $userStateMachine;
    }

    public function canValidate(User $user): bool
    {
        return $this->userStateMachine->can($user, self::TRANSITION_VALIDATE);
    }

    public function validate(User $user): void
    {
        if (!$this->userStateMachine->can($user, self::TRANSITION_VALIDATE)) {
            throw new \LogicException("Can't apply the 'validate' transition on user nΒ°{$user->getId()}Β°, current state: '{$user->getState()}'.");
        }

        $this->userStateMachine->apply($user, self::TRANSITION_VALIDATE);
    }
}

We inject $userStateMachine we just saw in the constructor. We introduce a constant for the "validate" transition; it's the same that we defined in the workflow.yaml file. Then we create two methods; canValidate() checks if the "validate" transition can be run on a given user, while validate() tries to run the "validate" transition and raises an exception if not possible. We will use these two shortcuts in the following chapter.

We can also dump the graph of the workflow, so it's easier to understand (useful for more complex scenarios):

bin/console workflow:dump user | dot -Tpng -o user-worflow.png

Here is the result, we find the two allowed states with their transition:


Users' list

Generation and customization of the EasyAdmin CRUD controller

Now that the workflow is configured. We can generate the admin. First, let's install EasyAdmin (I will not detail every step, please refer to the wonderful documentation on the Symfony website):

composer req admin

Then generate the CRUD controller for the User entity by running this command and answering the questions:

bin/console make:admin:crud

We have the following (almost empty) file generated:

<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;

final class UserCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    /*
    public function configureFields(string $pageName): iterable
    {
        return [
            IdField::new('id'),
            TextField::new('title'),
            TextEditorField::new('description'),
        ];
    }
    */
}

If we access this admin page, we get the following error (you have first to declare the new CRUD controller in your central dashboard controller):

The Doctrine type of the "state" field is "UserStateType", which is not supported by EasyAdmin yet.

It's because EasyAdmin doesn't know the new Doctrine type we introduced. We must configure it. Modify the configureFields() function to handle some fields, including the state one like this:

use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
    public function configureFields(string $pageName): iterable
    {
        return [
            IdField::new('id'),
            TextField::new('username'),
            ChoiceField::new('state')->setChoices(UserStateType::getChoices())->setRequired(true),
        ];
    }

Some explanations: we use a ChoiceField type because it's basically what an enum type is. To get the possible values, we use the getChoices function, which the DoctrineEnumBundle provides. Now, refresh the page, we get the following output:


Users' list

It works now; we can see the three fields in the list, but for the state field column, we have a code. It's the codes we put in the UserState.php type. We can use translations to display something user friendly:

# translations/messages.fr.yaml
user_state_created: Créé
user_state_validated: ValidΓ©

Refresh; you should now see the translations instead of the codes. OK, now we have a basic list, and the state is correctly displayed. Let's add an action to run the "validate" transition of the workflow.

Creating the custom action

At the right of the list, in the last column, we have two links; one to edit the user and one to delete them. This time we must use the configureActions() function to add a new action:

    public function configureActions(Actions $actions): Actions
    {
        $validateAction = Action::new('validate', 'Valider', 'fa fa-check')
            ->linkToCrudAction('validate')
            ->displayIf(fn (User $user) => $this->userWorkflow->canValidate($user))
            ->addCssClass('btn-sm btn-success');
        $actions
            ->add(Crud::PAGE_INDEX, $validateAction);

        return $actions;
    }

Some explanations: we create a new action which points to a custom action (linkToCrudAction()), the displayIf() function is a switch that displays the action only if a condition is met, in our case, if the transition can be applied on the user. This function accepts a closure or arrow function, which takes in argument the user object being processed. Then, we can pass it to our service to make the check we need. To do this, we use the UserWorkflow service we introduced in the previous chapter. It is injected into the constructor of the CRUD controller. Let's refresh the page; we should see our new links:

Users' list

That's OK, but if we click on one of the validation links, we have the following error:

The controller for URI "/admin/" is not callable: Expected method "validate" on class "App\Controller\Admin\UserCrudController"

It's because we didn't implement the custom validate action yet. Let's add it:

    public function validate(AdminContext $context, AdminUrlGenerator $adminUrlGenerator): Response
    {
        $user = $context->getEntity()->getInstance();
        if (!$user instanceof User) {
            throw new \RuntimeException('Invalid user');
        }

        /** @var Session */
        $session = $context->getRequest()->getSession();
        $adminUrlGenerator->setController(self::class)->setAction('index')->removeReferrer()->setEntityId(null);

        try {
            $this->userWorkflow->validate($user);
            $this->getDoctrine()->getManager()->flush();
            $session->getFlashBag()->add('success', "User nΒ°{$user->getId()} validated.");
        } catch (\Exception $e) {
            $session->getFlashBag()->add('error', $e->getMessage());
        }

        return $this->redirect($adminUrlGenerator->generateUrl());
    }

Some explanations. First, we get the user on whom we want to apply the transition. There is an EasyAdmin $context helper for that β„’. We check the type to ensure we have a User object, and now we can try to run the transition. We also use our UserWorkflow service and a try/catch in case there is an error. Finally, we can redirect to the CRUD homepage. A good practice is to add a flash message to give feedback to the user that the action was successful. EasyAdmin3 automatically handles them. Let's try the action on the first user:


Users' list

We are redirected, a message flash message is displayed, the user's state has changed. Its validation link is no more available as the transition can't be done anymore for this user. πŸŽ‰

Click here to see the full UserCrudController.php source.
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\DBAL\Types\UserStateType;
use App\Entity\User;
use App\Workflow\UserWorkflow;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;

/**
 * EasyAdmin bundle rocks, thanks Javier! πŸ˜‰.
 */
final class UserCrudController extends AbstractCrudController
{
    private UserWorkflow $userWorkflow;

    public function __construct(UserWorkflow $userWorkflow)
    {
        $this->userWorkflow = $userWorkflow;
    }

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return [
            IdField::new('id'),
            TextField::new('username'),
            ChoiceField::new('state')->setChoices(UserStateType::getChoices())->setRequired(true),
        ];
    }

    public function configureActions(Actions $actions): Actions
    {
        $validateAction = Action::new('validate', 'Valider', 'fa fa-check')
            ->linkToCrudAction('validate')
            ->displayIf(fn (User $user) => $this->userWorkflow->canValidate($user))
            ->addCssClass('btn-sm btn-success');
        $actions
            ->add(Crud::PAGE_INDEX, $validateAction);

        return $actions;
    }

    public function validate(AdminContext $context, AdminUrlGenerator $adminUrlGenerator): Response
    {
        $user = $context->getEntity()->getInstance();
        if (!$user instanceof User) {
            throw new \RuntimeException('Invalid user');
        }

        /** @var Session */
        $session = $context->getRequest()->getSession();
        $adminUrlGenerator->setController(self::class)->setAction('index')->removeReferrer()->setEntityId(null);

        try {
            $this->userWorkflow->validate($user);
            $this->getDoctrine()->getManager()->flush();
            $session->getFlashBag()->add('success', "User nΒ°{$user->getId()} validated.");
        } catch (\Exception $e) {
            $session->getFlashBag()->add('error', $e->getMessage());
        }

        return $this->redirect($adminUrlGenerator->generateUrl());
    }
}

Conclusion

We saw a concrete example of how to implement a workflow in a Symfony application. Of course, it's a basic example; there is a lot more to discover. What's next? For example, catching the workflow.user.completed.validate to send a confirmation email to the user, using the state field to allow you users to log in or not... In the last SymfonyWorld Online Summer Edition, there was a great talk from Łukasz ChruΕ›ciel about this subject; "a short tale about state machine" it's worth watching it. πŸ˜‰

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  More on the web


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil