6min.

Make Your Entities Sortable in EasyAdmin

Imagine that your EasyAdmin administration backend contains an entity (Sponsor in our example) and that you want to give the user the possibility to choose the order in which these sponsors are displayed on the application frontend (maybe because alphabetical sorting is not relevant).

This article presents a simple implementation of this need, based on the Sortable Doctrine extension and EasyAdmin custom actions.

Final result

Section intitulée set-up-the-sortable-extensionSet up the Sortable extension

Thanks to the Doctrine Sortable extension, we will be able to store and update the position of our entity using $entity->setPosition($position), without worrying about updating the position of the other entities because the extension will manage it for us.

To use it, we must first require the StofDoctrineExtensionsBundle which will simplify the extension configuration.

composer require stof/doctrine-extensions-bundle

This generates a configuration file in which we need to declare the extension we want to enable.

# config/packages/stof_doctrine_extensions.yaml

stof_doctrine_extensions:
    default_locale: en_US
+   orm:
+       default:
+           sortable: true

Section intitulée update-our-entityUpdate our entity

Now, we need to update our entity to add the property that will store its position. We can also add an index to this new property for performance purposes.

// src/Entity/Sponsor.php

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;

#[ORM\Index(name: 'position_idx', columns: ['position'])]
class Sponsor
{
    // ...

    #[Gedmo\SortablePosition]
    #[ORM\Column]
    private int $position = 0;

    // ...

    public function getPosition(): int
    {
        return $this->position;
    }

    public function setPosition(int $position): void
    {
        $this->position = $position;
    }
}

We can now generate the migration and execute it to update our database.

php bin/console make:migration

If the property is added to a table already containing data, we need to modify the migration to avoid the position being 0 for each row and have a default sort.

To do that, add the following two lines to the migration file :

// migrations/Version20230525123934.php

public function up(Schema $schema): void
{
    $this->addSql('ALTER TABLE sponsor ADD position INT NOT NULL');
+   $this->addSql('SET @current = -1');
+   $this->addSql('UPDATE sponsor SET position = (@current := @current + 1)');
    $this->addSql('CREATE INDEX position_idx ON sponsor (position)');
}

If the property is added to an empty table, the migration can be executed directly. The Sortable extension takes care of incrementing the position value at each persistence.

php bin/console doctrine:migrations:migrate

Section intitulée configure-the-crud-controllerConfigure the CRUD controller

Section intitulée set-the-default-sortingSet the default sorting

We can define the default sorting by using the new $position property. I also suggest displaying the actions inline to get the same rendering we saw in the screenshot of the introduction but this is optional.

// src/Controller/Admin/SponsorCrudController.php

public function configureCrud(Crud $crud): Crud
{
    return $crud
        ->setDefaultSort(['position' => 'ASC'])
        ->showEntityActionsInlined();
}

Section intitulée create-the-custom-actionsCreate the custom actions

We are going to create four actions that will enable the following changes to the entity’s position: move the entity up one position, move it down one position, move it up to the first position, and move it down to the last position.

To create an action, we use the Action::new() method of EasyAdmin. It takes a name, a label, and a Font Awesome icon as arguments.

// src/Controller/Admin/SponsorCrudController.php

public function configureActions(Actions $actions): Actions
{
    $moveUp = Action::new('moveUp', false, 'fa fa-sort-up');

    return $actions
        ->add(Crud::PAGE_INDEX, $moveUp);
}

Here, we use false as a label to only display the icon. However, I suggest setting the HTML title attribute like this to improve the user experience:

$moveUp = Action::new('moveUp', false, 'fa fa-sort-up')
    ->setHtmlAttributes(['title' => 'Move up']);

To have a valid action, we need to define which method is executed when clicking on it. We are going to use a CrudAction to take advantage of the AdminContext which allows us to easily retrieve the object concerned by the modification. However, as CrudActions do not accept parameters, we need to create one for each action so we will move the logic into another method to avoid duplicating code.

Let’s create in our controller the following methods and an enumeration for the directions:

// src/Controller/Admin/SponsorCrudController.php

use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use Symfony\Component\HttpFoundation\Response;

enum Direction
{
    case Top;
    case Up;
    case Down;
    case Bottom;
}

class SponsorCrudController extends AbstractCrudController
{
    // …

    public function moveUp(AdminContext $context): Response
    {
        return $this->move($context, Direction::Up);
    }

    private function move(AdminContext $context, Direction $direction): Response
    {
        $object = $context->getEntity()->getInstance();
        $newPosition = match($direction) {
            Direction::Up => $object->getPosition() - 1,
        };

        $object->setPosition($newPosition);
        $this->em->flush();

        $this->addFlash('success', 'The element has been successfully moved.');

        return $this->redirect($context->getRequest()->headers->get('referer'));
    }
}

In the move() method, we retrieve the entity instance thanks to the AdminContext and update its position depending on the direction chosen (i.e. the action on which the user clicked). For the time being, we only manage the up direction. We will see later how to manage the others. To be able to flush our change, we also need to inject the EntityManager service into the constructor:

// src/Controller/Admin/SponsorCrudController.php

public function __construct(
    private readonly EntityManagerInterface $em,
) {
}

Finally, we redirect the user to the original page thanks to the referrer and add a flash message to inform him about the modification.

Now that our CrudAction is defined, we can link it to our action:

// src/Controller/Admin/SponsorCrudController.php

public function configureActions(Actions $actions): Actions
{
    $moveUp = Action::new('moveUp', false, 'fa fa-sort-up')
        ->setHtmlAttributes(['title' => 'Move up'])
        ->linkToCrudAction('moveUp');

    return $actions
        ->add(Crud::PAGE_INDEX, $moveUp);
}

This gives us the following result in our administration interface:

Move up action

To improve this, we are going to remove the possibility of moving up the entity that is in first position thanks to the displayIf() method of our Action.

// src/Controller/Admin/SponsorCrudController.php

public function configureActions(Actions $actions): Actions
{
    $moveUp = Action::new('moveUp', false, 'fa fa-sort-up')
        ->setHtmlAttributes(['title' => 'Move up'])
        ->linkToCrudAction('moveUp')
        ->displayIf(fn ($entity) => $entity->getPosition() > 0);

    return $actions
        ->add(Crud::PAGE_INDEX, $moveUp);
}

Move up action with a better display

The first element can no longer be moved. But how to do the same for the last element and the moveDown action?

For the last element, we need to know the total number of elements. We can retrieve this information through the SponsorRepository, injected into the constructor like this:

// src/Controller/Admin/SponsorCrudController.php

public function __construct(
    private readonly EntityManagerInterface $em,
    private readonly SponsorRepository $sponsorRepository,
) {
}

We can now use the repository to retrieve the number of elements and create all our actions:

// src/Controller/Admin/SponsorCrudController.php

public function configureActions(Actions $actions): Actions
{
    $entityCount = $this->sponsorRepository->count([]);

    $moveTop = Action::new('moveTop', false, 'fa fa-arrow-up')
        ->setHtmlAttributes(['title' => 'Move to top'])
        ->linkToCrudAction('moveTop')
        ->displayIf(fn ($entity) => $entity->getPosition() > 0);

    $moveUp = Action::new('moveUp', false, 'fa fa-sort-up')
        ->setHtmlAttributes(['title' => 'Move up'])
        ->linkToCrudAction('moveUp')
        ->displayIf(fn ($entity) => $entity->getPosition() > 0);

    $moveDown = Action::new('moveDown', false, 'fa fa-sort-down')
        ->setHtmlAttributes(['title' => 'Move down'])
        ->linkToCrudAction('moveDown')
        ->displayIf(fn ($entity) => $entity->getPosition() < $entityCount - 1);

    $moveBottom = Action::new('moveBottom', false, 'fa fa-arrow-down')
        ->setHtmlAttributes(['title' => 'Move to bottom'])
        ->linkToCrudAction('moveBottom')
        ->displayIf(fn ($entity) => $entity->getPosition() < $entityCount - 1);

    return $actions
        ->add(Crud::PAGE_INDEX, $moveBottom)
        ->add(Crud::PAGE_INDEX, $moveDown)
        ->add(Crud::PAGE_INDEX, $moveUp)
        ->add(Crud::PAGE_INDEX, $moveTop);
}

public function moveTop(AdminContext $context): Response
{
    return $this->move($context, Direction::Top);
}

public function moveUp(AdminContext $context): Response
{
    return $this->move($context, Direction::Up);
}

public function moveDown(AdminContext $context): Response
{
    return $this->move($context, Direction::Down);
}

public function moveBottom(AdminContext $context): Response
{
    return $this->move($context, Direction::Bottom);
}

private function move(AdminContext $context, Direction $direction): Response
{
    $object = $context->getEntity()->getInstance();
    $newPosition = match($direction) {
        Direction::Top => 0,
        Direction::Up => $object->getPosition() - 1,
        Direction::Down => $object->getPosition() + 1,
        Direction::Bottom => -1,
    };

    $object->setPosition($newPosition);
    $this->em->flush();

    $this->addFlash('success', 'The element has been successfully moved.');

    return $this->redirect($context->getRequest()->headers->get('referer'));
}

Section intitulée update-the-sql-queryUpdate the SQL query

Now that our entity has a user-modifiable position, all we have to do is update our query to return the results in the order chosen by the user:

// src/Repository/SponsorRepository.php

public function findSponsors(): array
{
    $qb = $this->createQueryBuilder('sponsor')
        // … any other query conditions you need
        ->orderBy('sponsor.position', 'ASC');

    return $qb->getQuery()->execute();
}

And retrieve them in the controller:

// src/Controller/SponsorController.php

#[Route('/sponsors', name: 'sponsors')]
public function list(SponsorRepository $sponsorRepository): Response
{
    $sponsors = $sponsorRepository->findSponsors();

    return $this->render('sponsor/list.html.twig', [
        'sponsors' => $sponsors
    ]);
}

Commentaires et discussions

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise