Symfony 4 / Sonata: Create a nested CRUD interface (child 1->N) with drag'n drop ordering

In short, we want to create a CRUD interface, 1N, with which, when we are editing an item, we add a panel to manage all the child items.

Here we have a wtype table, with a wconf table that contains a series of records linked to a wtype item.


Just like for the implementation example of sortable with drag'n'drop ( available here ) we are going to use the following components:
pixassociates/sortable-behavior-bundle and stof/doctrine-extensions-bundle
You will therefore have to have previously created an entity that contains a 1N relationship with a second entity.

The manipulation consists only in injecting the second entity in the service declaration call of the first with the argument “addChild” and the reference to the parent service.


When we want to refer to a service, we use the chain used for the declaration (which is free), and we add “@” to indicate that it is a reference.

        class: App\Admin\WconfAdmin
            - { name: sonata.admin, manager_type: orm, label: "Configuration des types" }
        public: true
        arguments: [~, App\Entity\Wconf, 'PixSortableBehaviorBundle:SortableAdmin']
            - [setPositionService, ["@pix_sortable_behavior.position"]]
        class: App\Admin\WtypeAdmin
        arguments: [~, App\Entity\Wtype, ~]
            - [addChild, ["@admin.wconf"]] 
            - { name: sonata.admin, manager_type: orm, label: "Types de contrats" }
        public: true

In the 2 admin controllers, you will need to add references to the following libraries:

use Knp\Menu\ItemInterface as MenuItemInterface;
use Sonata\AdminBundle\Admin\AdminInterface;
use Sonata\AdminBundle\Route\RouteCollection;
use Pix\SortableBehaviorBundle\Services\PositionORMHandler as PositionHandler;

Then in the Parent interface, we add the buttons.
For the url of the child interface, we have to give it the reference of the service so that it creates the routes. Since our service is “admin.wconf”, the reference for the list will therefore be 'admin.wconf.list'

    protected function configureSideMenu(MenuItemInterface $menu, $action, AdminInterface $childAdmin = null): void
        if (!$childAdmin && !\in_array($action, ['edit'], true)) {
        $admin = $this->isChild() ? $this->getParent() : $this;
        $id = $admin->getRequest()->get('id');
        $label=$this->hasSubject() && null !== $this->getSubject()->getLabel() ? $this->getSubject()->getLabel():null;
            'Configuration du contrat '.$label,
            $admin->generateMenuUrl('edit', ['id' => $id])
        $menu->addChild( 'Configuration des interfaces '.$label, 
                'uri' => $admin->generateUrl('admin.wconf.list', ['id' => $id]) 

Now that our CRUD child interface is restricted to the scope of our parent selection, and since we want to manage the order by drag'n'drop, we will remove the possibility to sort the list.
It simply involves adding the argument sortable=false (default true), when defining the list fields.

    protected function configureListFields(ListMapper $listMapper)
        $listMapper->add('_action', null, array(
            'actions' => array(
                'move' => array(
                    'template' => '@PixSortableBehavior/Default/_sort_drag_drop.html.twig',
                    'enable_top_bottom_buttons' => false, //optional
        $listMapper->addIdentifier('etape', null, ['label' => 'Etape','sortable'=>false]);
        $listMapper->addIdentifier('position', null, ['label' => 'position','sortable'=>false]);
        //$listMapper->addIdentifier('id', null, ['label' => 'id','sortable'=>false]);
        $listMapper->addIdentifier('label', null, ['label' => 'Label','sortable'=>false]);
        $listMapper->addIdentifier('synchro_field', null, ['label' => 'Destination du champs','sortable'=>false]);
        $listMapper->add('actif', null, ['editable' => true,'sortable'=>false]);

We also want to preserve the default order, so that the user always finds the same order with each reload.
There are two ways.

The first consists in overloading the default configuration:

public function __construct( $code, $class, $baseControllerName ) {
        parent::__construct( $code, $class, $baseControllerName );

 protected $datagridValues = array(
        '_page' => 1,
        '_sort_by' => 'position',
        '_sort_order' => 'ASC',

The second method allows for finer sorting, for example on 2 fields by overriding the query used for the list.

    public function createQuery($context = 'list')
        $proxyQuery = parent::createQuery('list');
        $proxyQuery->addOrderBy($proxyQuery->getRootAlias().'.etape', 'ASC');
        $proxyQuery->addOrderBy($proxyQuery->getRootAlias().'.position', 'ASC');
        return $proxyQuery;

And there we realize that if we deactivate the order of the columns, our order configured in our query no longer works.
So, what we are going to do is override the template for this list only, and remove the code that manages the column headers, but keep everything else.

The list is found in vendor/sonata-project/admin-bundle/src/Resources/views/CRUD/base_list.html.twig and our concerned block is “table_header”


We are going to add our template in the declaration of our service.
For that we must take the reference of the template, described on this page:
In our case it is


And we will just add a call of type setTemplate with our configuration

        class: App\Admin\WconfAdmin
            - { name: sonata.admin, manager_type: orm, label: "Configuration des types" }
        public: true
        arguments: [~, App\Entity\Wconf, 'PixSortableBehaviorBundle:SortableAdmin']
            - [setPositionService, ["@pix_sortable_behavior.position"]]
            - [ setTemplate, [list, "Admin/wconf-list.html.twig"]]

We put: “Admin/wconf-list.html.twig”
This means that the system will go look for the file /templates/Admin/wconf-list.html.twig
This file must extend the initial view of the list, and then redefine the block that manages the header only. We really want to keep everything else.
So, we start our file by giving the reference of the master template, and then we redefine our block

{% extends '@SonataAdmin/CRUD/base_list.html.twig' %}

        {% block table_header %}
                                <tr class="sonata-ba-list-field-header">
                                    {% for field_description in admin.list.elements %}
                                        {% if admin.hasRoute('batch') and field_description.getOption('code') == '_batch' and batchactions|length > 0 %}
                                            <th class="sonata-ba-list-field-header sonata-ba-list-field-header-batch">
                                              <input type="checkbox" id="list_batch_checkbox">
                                        {% elseif field_description.getOption('code') == '_select' %}
                                            <th class="sonata-ba-list-field-header sonata-ba-list-field-header-select"></th>
                                        {% elseif == '_action' and app.request.isXmlHttpRequest %}
                                            {# Action buttons disabled in ajax view! #}
                                        {% elseif field_description.getOption('ajax_hidden') == true and app.request.isXmlHttpRequest %}
                                            {# Disable fields with 'ajax_hidden' option set to true #}
                                        {% else %}
                                            {% set sortable = false %}
                                            {% apply spaceless %}
                                                <th class="sonata-ba-list-field-header-{{ field_description.type }}{% if sortable %} sonata-ba-list-field-header-order-{{ sort_by|lower }} {{ sort_active_class }}{% endif %}{% if field_description.options.header_class is defined %} {{ field_description.options.header_class }}{% endif %}"{% if field_description.options.header_style is defined %} style="{{ field_description.options.header_style }}"{% endif %}>
                                                    {% if field_description.getOption('label_icon') %}
                                                        <i class="sonata-ba-list-field-header-label-icon {{ field_description.getOption('label_icon') }}" aria-hidden="true"></i>
                                                    {% endif %}
                                                    {{ field_description.label|trans({}, field_description.translationDomain) }}
                                            {% endapply %}
                                        {% endif %}
                                    {% endfor %}
            {% endblock %}

Then we have our header without the links to sort the content.
We make sure that the user is not going to manage the order by drag'n'drop with a poorly configured sort and call us every 5 minutes to tell us that it doesn't work.