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! π
» Published in "A week of Symfony 757" (28 June - 4 July 2021).
Prerequisite
I will assume you have a basic knowledge of Symfony and know how to install a component and a bundle.
Configuration
- PHP 8.3
- Symfony 6.4.10
- 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,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 array $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.41'
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:
* @see config/packages/workflow.yaml
*/
#[ORM\Column(name: 'state', type: 'UserStateType', nullable: false)]
#[EnumType(entity: UserStateType::class)]
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:
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
userStateAsEnum:
type: 'state_machine'
audit_trail:
enabled: true
marking_store:
type: 'method'
property: 'stateAsString'
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:
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;
IdField::new('id'),
TextField::new('username'),
ChoiceField::new('state')->setChoices(UserStateType::getChoices())->setRequired(true),
];
}
public function configureActions(Actions $actions): Actions
{
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:
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:
->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
{
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:
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:
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
/** @var Session $session */
$session = $context->getRequest()->getSession();
$adminUrlGenerator->setController(self::class)->setAction('index')->removeReferrer()->setEntityId(null);
try {
$this->userWorkflow->validate($user);
$this->manager->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:
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 Doctrine\ORM\EntityManagerInterface;
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
{
public function __construct(
public readonly UserWorkflow $userWorkflow,
public readonly EntityManagerInterface $manager,
) {
}
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 */
$session = $context->getRequest()->getSession();
$adminUrlGenerator->setController(self::class)->setAction('index')->removeReferrer()->setEntityId(null);
try {
$this->userWorkflow->validate($user);
$this->manager->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 your 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. π
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 )
- Report any error/typo.
- Report something that could be improved.
- Like and retweet!
- Follow me on Twitter Follow me on Twitter
- Subscribe to the RSS feed.
- Click on the More on Stackoverflow buttons to make me win "Announcer" badges π .
Thank you for reading! And see you soon on Strangebuzz! π
[π¬π§] Second blog post of the year (yes, I'm very late!): "Implementing a user workflow with Symfony and EasyAdmin3"β» https://t.co/2kqUxRRS82 Proofreading, comments, likes and retweets are welcome! π Annual goal: 2/10 #symfony #php #easyadmin #admin #workflow
— COil #StaySafe π‘ #OnEstLaTech β (@C0il) June 30, 2021