SF6 / Sonata 5: Managing a OneToMany interface in a CRUD with Sonata's CollectionType

In this example, we will take the concrete case of a simple interface that includes multiple elements.
We have chosen to use a "Projects" table in which we will assign documents.
Therefore, for a project, we can assign several documents. And to facilitate administration, we will ensure that we can manage our nested interface directly within the edit view of our project.

We need to create the schema.
Firstly, we import our current schema into MysqlWorbench using the "Database/reverse engineer" function. This will allow us to have the default tables of Sonata since we are going to play with media.



With MysqlWorbench, we choose a 1->N relationship between our "projects" table and the "projects_medias" table and then an N->1 relationship between the "projects_medias" table and the "media__media" table.

1->N = OneToMany
N->1 = ManyToOne

The subtlety consists in putting a default auto-incrementing id in our linking table and not to have our foreign keys as the primary.

This is what we get:

projet_id will contain the id from the projects table and media_id will contain the id from the media__media table.
We add an order field but we can add as many fields as we wish. Because we are going to see how to manage the entire form for editing the elements of projects_medias.

In MysqlWorbench, we choose "Forward/reverse engineer" to push our schema into MySQL.

We do a final trick with the Symfony commands to generate our entities and our default admin interfaces.

bin/console doctrine:mapping:import "App\Entity" annotation --path=src/Entity
bin/console make:entity --regenerate App
bin/console make:sonata:admin App/Entity/Projets
bin/console make:sonata:admin App/Entity/ProjetsMedias

It's magical but incomplete. We need to correct the relationships in our 2 entities.
We will already define the access properties in both directions.

For the entity App\Entity\Projets it will be the documents property, with the accessors addDocument and removeDocument


use Doctrine\Common\Collections\Collection;
...
...
private $documents;

public function getDocuments(): Collection
{

    return $this->documents;
}

public function addDocument(ProjetsMedias $document): self
{
    if (!$this->documents->contains($document)) {
        $this->documents[] = $document;
        $document->setProjets($this);
    }

    return $this;
}

public function removeDocument(ProjetsMedias $document): self
{
    if ($this->documents->removeElement($document)) {
        // set the owning side to null (unless already changed)
        if ($document->getProjets() === $this) {
            $document->setProjets(null);
        }
    }

    return $this;
}


For the entity App\Entity\ProjetsMedias it will be the projets property, with the accessors getProjets() and setProjets().

private $projets;

public function getProjets(): ?Projets
{
    return $this->projets;
}

public function setProjets(?Projets $projets): self
{
    $this->projets = $projets;
    return $this;
}

Let's focus on the configuration of the Projets->ProjetsMedias relationship because this is where it all happens.

/**
 * @var \Doctrine\Common\Collections\Collection
 *
 * @ORM\OneToMany(targetEntity="App\Entity\ProjetsMedias", mappedBy="projets", cascade={"persist", "remove" })
 * @ORM\JoinTable(name="projets_medias",
 *   joinColumns={
 *     @ORM\JoinColumn(name="projets_id", referencedColumnName="id")
 *   }
 * )
 */
private $documents;

The targetEntity property of our OneToMany must point to the entity of our table that contains the list of documents and we must give it the property of our ManyToOne relationship of this entity.
So here it is \App\Entity\ProjetsMedias which has a projets property. We then give it the name of the MySQL table it must join: projets_media. JoinColumn defines the name of the field in the joined table that will contain our project primary keys, so the value of our id property, which must be stored in the projets_id of projets_media.

Then we configure our relationship in ProjetsMedia.

/**
 * @var \Projets
 *
 * @ORM\ManyToOne(targetEntity="Projets",inversedBy="documents")
 * @ORM\JoinColumns({
 *   @ORM\JoinColumn(name="projets_id", referencedColumnName="id")
 * })
 */
private $projets;

public function getProjets(): ?Projets
{
    return $this->projets;
}

public function setProjets(?Projets $projets): self
{
    $this->projets = $projets;

    return $this;
}

The targetEntity property of our ManyToOne must point to the entity of our table that manages the projects and we must give it the property of our OneToMany relationship of this entity.
So here it is Projets which has a documents property.
We then give it the field of our current table (ProjetsMedia) which stores the primary key of the project. So id from projets table which is stored in projets_id of projets_media

This is none other than the reverse of what we configured for our definition of \App\Entity\Projets::$documents

Last step, as we have chosen to play with media, we must configure our relationship with the Sonata media library and configure the accessors.

use Sonata\MediaBundle\Model\MediaInterface;
...
...
/**
 * @var \MediaMedia
 *
 * @ORM\ManyToOne(targetEntity="App\Application\Sonata\MediaBundle\Entity\Media")
 * @ORM\JoinColumns({
 *   @ORM\JoinColumn(name="media_id", referencedColumnName="id")
 * })
 */
private $mediaMedia;

public function __toString(): string
{
    return $this->getMediaMedia();
}

public function getMediaMedia(): ?MediaInterface
{

    return $this->mediaMedia;
}

public function setMediaMedia(?MediaInterface $mediaMedia): self
{
    $this->mediaMedia = $mediaMedia;

    return $this;
}

Same as above. We start by giving the target entity, here "App\Application\Sonata\MediaBundle\Entity\Media". Because it's the entity that we extended in our configuration of the Sonata/Media bundle.

In sonata_media.yml we have this:

sonata_media:
   class:
        media: App\Application\Sonata\MediaBundle\Entity\Media
        gallery: App\Application\Sonata\MediaBundle\Entity\Gallery
        gallery_item: App\Application\Sonata\MediaBundle\Entity\GalleryItem
        category: App\Application\Sonata\ClassificationBundle\Entity\SonataClassificationCategory

It seems obvious but if you come across this article without having all the basics, it seems wise to remind you. It could unblock more than one person. 😊

Then JoinColumn defines how we record the relationship. So it's the field media_id from our projets_media table that will contain the id field of our table that manages the media (media__media).

Now we just need to configure the media field of our admin that we automatically generated above (ProjetsMediasAdmin.php).
To manage a media field, you just need to use the ModelListType of Sonata. You are free to activate the optional add/edit/list/delete buttons.

use Sonata\AdminBundle\Form\Type\ModelListType;
...
...
protected function configureFormFields(FormMapper $form): void
{
    $form
        ->add('projets')
        ->add('mediaMedia', ModelListType::class, [
            'required' => false,
            'btn_add'=>true,
            'btn_edit'=>false,
            'btn_list'=>false,
            'btn_delete'=>false,
        ])
        ->add('ordre')
    ;
}

The relationships are now complete. We can check that everything works by activating our ProjetsMedia admin.

In our services, the generation of our interfaces has configured this for us:

    admin.projets:
        class: App\Admin\ProjetsAdmin
        tags:
            - { name: sonata.admin, model_class: App\Entity\Projets, controller: App\Controller\ProjetsAdminController, manager_type: orm, group: admin, label: Projets }

    admin.projets_medias:
        class: App\Admin\ProjetsMediasAdmin
        tags:
            - { name: sonata.admin, model_class: App\Entity\ProjetsMedias, controller: App\Controller\ProjetsMediasAdminController, manager_type: orm, group: admin, label: ProjetsMedias }
        arguments: [~, App\Entity\ProjetsMediasAdmin, ~]

So we can now go directly to our interface to see if it works.

But what we are interested in is to have this management interface directly in our Projects management.

And for that we will use Sonata's CollectionType.
We add to our automatically generated admin, in the method that manages the editing form configuration (configureFormFields), a documents field that will have CollectionType as its type with its configuration, and most importantly, we add the admin_code parameter that points to the name of our projets_media admin service.

$form->add('documents', \Sonata\Form\Type\CollectionType::class,
    [
        'required' => false,
        'by_reference' => false,
        'label'=> false],
    [
        'edit' => 'inline',
        'inline' => 'table',
        'sortable' => 'position',
        'link_parameters' => [
            'context' => 'default',
            'provider' => 'sonata.media.provider.file',
            'hide_context' => true
        ],
        'admin_code' => 'admin.projets_medias'
    ]
);

Incidentally, in \App\Admin\ProjetsMediasAdmin, we remove our projets field since the value will be filled in automatically when the admin is nested in projects.

protected function configureFormFields(FormMapper $form): void
{
    $form
        //->add('projets')
        ->add('mediaMedia', \Sonata\AdminBundle\Form\Type\ModelListType::class, [
            'required' => false,
            'btn_add'=>true,
            'btn_edit'=>false,
            'btn_list'=>false,
            'btn_delete'=>false,
        ])
        ->add('ordre')
    ;
}

And magic happens. We can now add/remove media directly within our interface.

But it's not quite finished. We have a field to determine the order of our display. We just need to configure the ProjetsMedia admin to modify the type of the order field.

use Symfony\Component\Form\Extension\Core\Type\HiddenType;
...
...
protected function configureFormFields(FormMapper $form): void
{
    $form
        //->add('projets')
        ->add('mediaMedia', ModelListType::class, [
            'required' => false,
            'btn_add'=>true,
            'btn_edit'=>false,
            'btn_list'=>false,
            'btn_delete'=>false,
        ])
        ->add('ordre', HiddenType::class)
    ;
}

Then we configure our Collection in Projets, so that the sortable parameter points to our correct field.

$form->add('documents', \Sonata\Form\Type\CollectionType::class,
    [
        'required' => false,
        'by_reference' => false,
        'label'=> false],
    [
        'edit' => 'inline',
        'inline' => 'table',
        'sortable' => 'ordre',
        'link_parameters' => [
            'context' => 'default',
            'provider' => 'sonata.media.provider.file',
            'hide_context' => true
        ],
        'admin_code' => 'admin.projets_medias'
    ]
);

And lastly, we modify our Projets->ProjetsMedia relationship to set the desired order.

/**
 * @var \Doctrine\Common\Collections\Collection
 *
 * @ORM\OneToMany(targetEntity="App\Entity\ProjetsMedias", mappedBy="projets", cascade={"persist", "remove" })
 * @ORM\JoinTable(name="projets_medias",
 *   joinColumns={
 *     @ORM\JoinColumn(name="projets_id", referencedColumnName="id")
 *   }
 * )
 * @ORM\OrderBy({"ordre" = "ASC"})
 */
private Collection $documents;

Our collection can now be ordered by simple drag and drop.