Ancient baths turned into museum

Crédit: Y. Alsberge

02-04-2025
Custom Easy Admin - update to Symfony 7.2

This article is part of a series of Article around the customization of EasyAdmin within Symfony. You can find the list of related articles and the context of it in this article : Symfony & EasyAdmin – space for extra functionalities.

Objectives

Where were we : the last installment of this series was to define how to add custom entity action on the Post EasyAdmin Controller.

Some years later, it's time to freshen it up and continue the update of the admin with some other functionalities. But, first, let's update to 7.2...

Update to 7.2

First, let's keep PHP up-to-date, so let's bump the minimum requirement of PHP to PHP v8.2. Let's update the composer.json first for that...

1"require": {
2    "php": ">=8.2",
3    ...
4}

Change outside EasyAdmin

Use of PHP Enums

First, there are some changes to the models. We still need roles and statuses, but now with the updates of PHP8, we can use Enums now instead of class constants.

For the UserRoles, let's define a UserRole Enum

1enum UserRole : string
2{
3    case AUTHOR = "ROLE_AUTHOR";
4    case ADMIN = "ROLE_ADMIN";
5    case PUBLISHER = "ROLE_PUBLISHER";
6
7    public static function all(): array
8    {
9        return array_combine(array_map(fn($e) => $e->value, self::cases()), array_map(fn($e) => $e->value, self::cases()));
10    }
11}

Enums can't yet be used in the security framework of Symfony, so we need to use the string value of the Enum when dealing with the roles, like:

As for the status of the Posts, we can also use another Enum : PostStatus

1enum PostStatus: string {
2    case DRAFT = 'draft';
3    case IN_REVIEW = 'in_review';
4    case PUBLISHED = 'published';
5    case ARCHIVED = 'archived';
6}

Doctrine is now handling properly the Enums and so they can be setup like this in the Post Entity:

1class Post {
2    ...
3    #[ORM\Column(type: Types::STRING, enumType: PostStatus::class)]
4    private PostStatus $status = PostStatus::DRAFT;
5    ...
6}

This will enable the direct usage of the Enums when getting or setting the status.

New PostStatusChanges relation

Before the change, the different dates of the statuses where dedicated dates on the Post entity but it was a bit messy and created the issue that if there was a need to add a new status, a new property was needed.

Instead, let's create a new Entity that will hold all the status changes and so their dates: PostStatusChanges.

It's a simple entity that will be comprised of the following properties:

1class PostStatusChange
2{
3    private int $id;
4
5    private DateTimeImmutable $time;
6    private ?Post $post = null;
7    private ?User $user = null;
8    private ?PostStatus $previousStatus = null;
9    private PostStatus $currentStatus = PostStatus::DRAFT;
10    ...
11}

This allows to store any change and when we need to retrieve a status, it's possible to get it from the last change. The current Status is anyhow also stored directly on the Post entity.

Miscellaneous

Other changes:

  • No more use of a the Workflow bundle anymore, direct use of Voters to define whether the actions can be done from the Admin controller
  • No more use of a custom "ProcessHandler" but usage of the built-in Messenger.

Change to the custom field

In the previous versions, the custom field used to showcase how to display custom data or handle a specific type of data was the TranslatedTextField. It has been replaced by an Enumfield. This new custom field allows to display properly the Enum values and to handle the translation of the Enum values.

1/**
2 * Custom Field to allow for the display and usage of an Enum as a value
3 */
4class EnumField implements FieldInterface
5{
6    use FieldTrait;
7
8    public static function new(string $propertyName, ?string $label = null): self
9    {
10        return (new self())
11            ->setProperty($propertyName)
12            ->setLabel($label)
13            // add a specific template for the field
14            ->setTemplatePath('admin/field/enum_field.html.twig')
15            //define the specific form type for the field
16            ->setFormType(EnumType::class)
17            ->setFormTypeOption('attr.class', 'width-inherit')
18            //set the way the value is found
19            ->setFormTypeOption('choice_label', static function (\BackedEnum $choice): string {
20                return (string) $choice->value;
21            })
22            ;
23    }
24
25    public function setEnumClass(string $enumClass): self
26    {
27        if (!is_subclass_of($enumClass, BackedEnum::class)) {
28            throw new InvalidArgumentException(sprintf("The enum class %s should be a Backed Enum", $enumClass));
29        }
30        $this->setFormTypeOption('class', $enumClass);
31
32        return $this;
33    }
34}

It can be used liked this in a CrudController:

1public function configureFields(string $pageName): iterable
2    {
3        ...
4        // Set up a custom field for the display of the status on the index
5        yield EnumField::new('status')->hideOnForm()->setEnumClass(PostStatus::class);
6        ...
7    }

Change to the Action setup

In the EasyAdmin part, a notable change is the use of dedicated Action class and controllers to extract that logic into dedicated classes.

But before, what is an action in EasyAdmin ? We could say the following:

It :

  • has a dedicated name & display name
  • defines whether it's a global action or an entity one
  • triggers a process (whether it's just a redirection or an actual change in the backend)

So the idea is to extract the Action information into one class (an "Action" class) and the process into a dedicated controller. This allows to have a better separation of the logic and to be able to reuse the Action in different controllers.

Action class

The Action class is a simple class that will hold the information about the action. It will be used to define the action in the CrudController and to trigger the process in the controller.

1class PublishPostAction
2{
3    public const NAME = 'post_publish';
4    public const LABEL = 'Publish';
5
6    public static function create(): Action
7    {
8        return Action::new(self::NAME, self::LABEL)
9            ->linkToRoute(PostPublishController::CRUD_ROUTE_NAME,
10                            fn(Post $post) => ['id' => $post->getId()])
11            ->displayAsForm()
12            ;
13    }
14}

Here, the PublishPostAction class defines the action name and label.

The linkToRoute method is used to define the route and the parameters that will be passed to the controller.

The displayAsForm method setup the fact that the action should be rendered as form thus sending a POST request to the route defined in the linkToRoute method (it's a new feature since the v4.24.5). The only issue with the displayAsForm is that it currently works only for global Actions and so the rendering of the form is not done when the actions are shown as a dropdown and not properly done when the actions are shown inline. I've created a PR to EasyAdmin to try to address it

Controller

In Symfony, you can define controllers as callable by using the __invoke function or simply define a method and apply a route to it. Here, let's use the __invoke function to define the controller called function.

1#[Route('/admin/post/{id}/publish', name: PostPublishController::CRUD_ROUTE_NAME, methods: ['POST'])]
2class PostPublishController extends AbstractCrudController
3{
4    public const CRUD_ROUTE_NAME = 'admin_post_publish';
5
6    public function __construct(private readonly MessageBusInterface $messageBus)
7    {}
8
9    public function __invoke(Post $post): Response
10    {
11        $this->messageBus->dispatch(new PublishPost($post));
12        $this->addFlash('success', sprintf('Post %s published.', $post->getTitle()));
13
14        return $this->redirectToRoute('admin_post_index');
15    }
16
17    public static function getEntityFqcn(): string
18    {
19        return Post::class;
20    }
21}

So, multiple elements:

  1. the Route definition is applied at the class level which will trigger the fact that the _invoke method will be called when the route is triggered
  2. the controller extends the AbstractCrudController which allows to use the EasyAdmin features. We could for instance add the AdminContext class as a dependency of the function to allow to get specific EasyAdmin features
  3. the response is using the latest feature in the EasyAdmin bundle : the PrettyUrls and the fact that all the possible routes are now generated within the bundle and so we can use the admin_post_index route to redirect to the index page of the Post entity
  4. the controller can then be used normally to trigger an actual process : here the publishing of the post.

The Repository

The repository of the customization will keep in the main branch all the updates made on the easyadmin.

The blog series

The goal is also for me to document the changes made in the repository while linking them to the documentation of EasyAdmin to enable anyone to make the connection between what I’m doing and what is existing in EasyAdmin…

Below you’ll see the list of the different posts linked to one specific update in the custom EasyAdmin (each post should correspond to one specific branch in the repository):

  1. Only Admin can update (any CRUD operation) Users
  2. Post Index page : new post actions usable in 3 parts:
  3. Fields customization
  4. Access restriction
  5. Custom entity actions

> All pictures of this article are of the making of the author and some can be seen here : Rêveries