Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Adding to a Collection: Cascade Persist

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

After adding a new GenusScientist sub-form and submitting, we're greeted with this wonderful error!

Expected argument of type User, GenusScientist given

Updating the Adder Method

But, like always, look closely. Because if you scroll down a little, you can see that the form is calling the addGenusScientist() method on our Genus object:

... lines 1 - 14
class Genus
{
... lines 17 - 178
public function addGenusScientist(User $user)
{
... lines 181 - 187
}
... lines 189 - 207
}

Oh yea, we expected that! But, the code in this method is still outdated.

Change the argument to accept a GenusScientist object. Then, I'll refactor the variable name to $genusScientist:

... lines 1 - 14
class Genus
{
... lines 17 - 179
public function addGenusScientist(GenusScientist $genusScientist)
{
if ($this->genusScientists->contains($genusScientist)) {
return;
}
$this->genusScientists[] = $genusScientist;
}
... lines 188 - 206
}

As you guys know, we always need to set the owning side of the relationship in these methods. But, don't do that... yet. For now, only make sure that the new GenusScientist object is added to our array.

With that fixed, go back, and refresh to resubmit the form. Yay! New error! Ooh, this is an interesting one:

A new entity was found through the relationship Genus.genusScientists that was not configured to cascade persist operations for GenusScientist.

Umm, what? Here's what's going on: when we persist the Genus, Doctrine sees the new GenusScientist on the genusScientists array... and notices that we have not called persist on it. This error basically says:

Yo! You told me that you want to save this Genus, but it's related to a GenusScientist that you have not told me to save. You never called persist() on this GenusScientist! This doesn't make any sense!

Cascade Persist

So what's the fix? It's simple! We just need to call persist() on any new GenusScientist objects. We could add some code to our controller to do that after the form is submitted:

... lines 1 - 15
class GenusAdminController extends Controller
{
... lines 18 - 34
public function newAction(Request $request)
{
$form = $this->createForm(GenusFormType::class);
// only handles data on POST
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
... lines 42 - 43
$em = $this->getDoctrine()->getManager();
$em->persist($genus);
$em->flush();
... lines 47 - 53
}
... lines 55 - 58
}
... lines 60 - 87
}

Or... we could do something fancier. In Genus, add a new option to the OneToMany: cascade={"persist"}:

... lines 1 - 14
class Genus
{
... lines 17 - 71
/**
* @ORM\OneToMany(
* targetEntity="GenusScientist",
* mappedBy="genus",
* fetch="EXTRA_LAZY",
* orphanRemoval=true,
* cascade={"persist"}
* )
*/
private $genusScientists;
... lines 82 - 206
}

This says:

When we persist a Genus, automatically call persist on each of the GenusScientist objects in this array. In other words, cascade the persist onto these children.

Alright, refresh now. This is the last error, I promise! And this makes perfect sense: it is trying to insert into genus_scientist - yay! But with genus_id set to null.

The GenusScientistEmbeddedForm creates a new GenusScientist object and sets the user and yearsStudied fields:

... lines 1 - 12
class GenusScientistEmbeddedForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('user', EntityType::class, [
... lines 19 - 23
])
->add('yearsStudied')
;
}
... lines 28 - 36
}

But, nobody is ever setting the genus property on this GenusScientist.

This is because I forced you - against your will - to temporarily not set the owning side of the relationship in addGenusScientist. I'll copy the same comment from the remover, and then add $genusScientist->setGenus($this):

... lines 1 - 14
class Genus
{
... lines 17 - 179
public function addGenusScientist(GenusScientist $genusScientist)
{
... lines 182 - 186
// needed to update the owning side of the relationship!
$genusScientist->setGenus($this);
}
... lines 190 - 208
}

Owning side handled!

Ok, refresh one last time. Boom! We now have four genuses: this new one was just inserted.

And yea, that's about as complicated as you can get with this stuff.

Don't Purposefully Make your Life Difficult

Oh, but before we move on, go back to /genus, click a genus, go to one of the user show pages, and then click the pencil icon. This form is still totally broken: it's still built as if we have a ManyToMany relationship to Genus. But with our new-found knowledge, we could easily fix this in the exact same way that we just rebuilt the GenusForm. But, since that's not too interesting, instead, open UserEditForm and remove the studiedGenuses field:

... lines 1 - 14
class UserEditForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
... lines 20 - 24
->add('studiedGenuses', EntityType::class, [
'class' => Genus::class,
'multiple' => true,
'expanded' => true,
'choice_label' => 'name',
'by_reference' => false,
])
;
}
... lines 34 - 40
}

Then, open the user/edit.html.twig template and kill the render:

... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
<div class="col-xs-8">
... lines 7 - 8
{{ form_start(userForm) }}
... lines 10 - 16
{{ form_row(userForm.studiedGenuses) }}
... lines 18 - 19
{{ form_end(userForm) }}
</div>
</div>
</div>
{% endblock %}

Finally, find the User class and scroll down to the adder and remover methods. Get these outta here:

... lines 1 - 16
class User implements UserInterface
{
... lines 19 - 222
public function addStudiedGenus(Genus $genus)
{
if ($this->studiedGenuses->contains($genus)) {
return;
}
$this->studiedGenuses[] = $genus;
$genus->addGenusScientist($this);
}
public function removeStudiedGenus(Genus $genus)
{
if (!$this->studiedGenuses->contains($genus)) {
return;
}
$this->studiedGenuses->removeElement($genus);
$genus->removeGenusScientist($this);
}
}

Go back to refresh the form. Ok, better! This last task was more than just some cleanup: it illustrates an important point. If you don't need to edit the genusesStudied from this form, then you don't need all the extra code, especially the adder and remover methods. Don't make yourself do extra work. At first, whenever I map the inverse side of a relationship, I only add a "getter" method. It's only later, if I need to update things from this side, that I get fancy.

Oh, and also, remember that this entire side of the relationship is optional. The owning side of the relationship is in GenusScientist. So unless you need to be able to easily fetch the GenusScientist instances for a User - in other words, $user->getStudiedGenuses() - don't even bother mapping this side. We are using that functionality on the user show page, so I'll leave it.

Leave a comment!

10
Login or Register to join the conversation
Default user avatar
Default user avatar Vince Liem | posted 5 years ago

Hello super person,

So in my project one product can have many pictures with titles and descriptions. And I'm able to get the remover method to work.

BUT it seems that I can't get the adder to work. if I dump $form->getData(); it seems that everything I add gets ignored. While the multiple pictures I manually added in the database doesn't get ignored, and I can edit them in the form. In "inspect element" in the HTML form. I can see that embedded forms gets the necessary [0], [1] etc in the name, but it isn't visible when I post.

Any ideas what I accidentally missed?

1 Reply

Hey Vince,

Hm, if you don't see added elements in POST request on the server - check the name attribute of added HTML elements. Is it exactly the same as manually added one? The difference should be only with the numbers, i.e. [0], [1], etc. You probably just make a misprint in the field name.

Also I suppose you use collection type for it, so please, double check that you have an "allow_add" => true, option.

And the last but not least, I think you use some JS code to add more pictures in the form. Do you use jQuery or other library / JS framework for it? Are you sure that your code writes directly to the DOM (not virtual DOM)?

Cheers!

Reply
Default user avatar
Default user avatar Vince Liem | Victor | posted 5 years ago

Hey, I fixed it by placing the form_start at the beginning and form_end at the end of the file

1 Reply
Marcin K. Avatar
Marcin K. Avatar Marcin K. | posted 4 years ago | edited

Hello, I'm wondering if I could submit forms inside a collection separately? I have very long form collection with buttons to save each subform (Basically filling and validating the form at once would be difficult). So clicking the button suppose to only submit corresponding subform, but it submits whole collection.

`<?php

namespace AppBundle\Controller;

use AppBundle\Entity\Address;
use AppBundle\Entity\Applicant;
use AppBundle\Entity\Company;
use AppBundle\Entity\Director;
use AppBundle\Entity\User;
use AppBundle\Form\UserType;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Component\HttpFoundation\Request;

/**

  • Address controller.
    *
  • @Route("admin")
    */

class UserController extends Controller
{

/**
 * Lists all address entities.
 *
 * @Route("/", name="admin_index")
 * @Method("GET")
 */
public function indexAction(Request $request)
{
    $em = $this->getDoctrine()->getManager();

    $user = $this->getUser();

    if(!count($user->getApplicants())) {
        $app = new Applicant();
        $app->setUser($user);
        $user->setApplicants($app);
    }

    if(!count($user->getAddresses())) {
        $address = new Address();
        $address->setUser($user);
        $user->setAddresses($address);
    }

    if(!count($user->getCompanies())) {
        $company = new Company();
        $company->setUser($user);
        $user->setCompanies($company);
    }

    if(!count($user->getDirectors())) {
        $director = new Director();
        $director->setUser($user);
        $user->setDirectors($director);
    }

    $form = $this->createForm('AppBundle\Form\UserType', $user);

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {

        if ($form->getClickedButton() && 'submitApplicants' === $form->getClickedButton()->getName()) {

            $applicant = $form->getData()->getApplicants()[0];

            $applicant->setUser($user);

            $em->persist($applicant);

            $em->flush();

            return $this->render('admin/index.html.twig', [
                'form' => $form->createView()
            ]);

        }
        if ($form->getClickedButton() && 'submitAddresses' === $form->getClickedButton()->getName()) {

            $address = $form->getData()->getAddresses()[0];

            $em->persist($address);

            $em->flush($address);

            return $this->render('admin/index.html.twig', [
                'form' => $form->createView()
            ]);

        }

        if ($form->getClickedButton() && 'submitCompanies' === $form->getClickedButton()->getName()) {

            $company = $form->getData()->getCompanies()[0];

            $em->persist($company);
            $em->flush($company);

            return $this->render('admin/index.html.twig', [
                'form' => $form->createView()
            ]);

        }

        if ($form->getClickedButton() && 'submitDirectors' === $form->getClickedButton()->getName()) {

            $director = $form->getData()->getDirectors()[0];

            $em->persist($director);

            $em->flush($director);

            return $this->render('admin/index.html.twig', [
                'form' => $form->createView()
            ]);

        }

        //$em->flush();
    }

    return $this->render('admin/index.html.twig', [
        'form' => $form->createView()
    ]);
}

}`

Reply

Hey Marcin,

HTML does not allow you to embed form inside other form, it means that you can't have sub-forms on the HTML layout layer. So, to implement what you want I think you either need a separate form for each collection you want to save separately, or use one big form for all your collections. If you don't need an ability to send the whole big form with all collections - I think you can go with the 1st option. Otherwise, choose the 2nd option and take a look at form events: https://symfonycasts.com/sc... .

Cheers!

Reply
Luc H. Avatar
Luc H. Avatar Luc H. | posted 5 years ago | edited

Hello,

I have a problem with the addGenusScientist and removeGenusScientist methods. In my application, I use checkboxes to set or remove entries to or from the mapping table (I call it genusScientist table here for better understanding). When adding, the idea is, that I call addGenusScientist in genus, pass it a genusScientist object where only the user is set. In my opinion, this doesn't work correctly with the current implementation:


public function addGenusScientist(GenusScientist $genusScientist)
{
    if ($this->genusScientists->contains($genusScientist)) {
        return;
    }

    $this->genusScientists[] = $genusScientist;
    // needed to update the owning side of the relationship!
    $genusScientist->setGenus($this);
}

public function removeGenusScientist(GenusScientist $genusScientist)
{
    if (!$this->genusScientists->contains($genusScientist)) {
        return;
    }

    $this->genusScientists->removeElement($genusScientist);
    // needed to update the owning side of the relationship!
    $genusScientist->setGenus(null);
}

I have 3 problems with that:

  1. the setGenus line should be the first action in the method, otherwise contains will never return true, even if the combination of genus and user already exists.

  2. contains apparently only then finds the entry in the collection, when ALL fields in $genusScientist are set exactly set as in the matching object in $this->genusScientists, i.e. not only user and genus have to be set, but also id and yearsStudies. If not, contains will not find them and I will have duplicate entries in my genusScientist table.

  3. the same problem with contains happens in the removeGenusScientist method. Unless I fetched the genusScientist object from the database (i.e. all fields are set) before passing to the removeGenusScientist, contains will never find it. Even worse, the removeElement will then not work.

In my code, I implemented my own contains method, which only searches for the keys (genus and user) in this "mapping table with additional fields". But this feels strange, since the way it is implemented here can also be found on other examples on the internet.

Did I misunderstand the goal of the if-statements in add-/removeGenusScientists or is there really a bug in this code?

Reply

Hey Luc,

As for me, I agree with you, I think setGenus() method should be called first, before any if statement in addGenusScientist() / removeGenusScientist() and it makes perfect sense for me. This will fix some potential problems like this workflow:
1. $this->addGenusScientists()->add($genusScientist); // somewhere in the code we add an object to the collection manually but don't set owning side! And then further...
2. ->addGenusScientist($genusScientist); // So the object is already in the collection, BUT if we call this method - we'll ignore setting the owning side because contains() return true now.
Yes, it looks like a developer mistake, but we can cover it with calling setGenus() in the first place in both addGenusScientist() / removeGenusScientist().

What about contains() - we're operating objects, and since this method uses === in http://www.doctrine-project... - we don't have extra checks to validate that each field is matched. If we have another object - this check will fail, if we have the same object - no matter whether its fields are different or no, because we have exactly the same object, and if some fields were changed, Doctrine will do everything to store those fields in the DB on the next flush().

Cheers!

Reply
MolloKhan Avatar MolloKhan | SFCASTS | posted 5 years ago | edited

Hey there

My guts are telling me that you may forgotten to setup the inverse side of the relationship. Can you double check that?

Cheers!

Reply

Hmm, interesting. My second thought is that you didn't add the `cascade={"persist"}` to the property, but if you did then I would have to check your code so I can tell what's wrong. Maybe you could upload it to Github?

Or just take some screenshots to your entities and controller's action

Reply

Hmm, your relationships looks good to me, probably the error comes from processing the form submit. Try dumping your objects throughout the process so you can gather more information of how your objects are being built. You would have to call persist directly to "Bestelorder" and "Artikel"

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This course is built on Symfony 3, but most of the concepts apply just fine to newer versions of Symfony.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "symfony/symfony": "3.4.*", // v3.4.49
        "doctrine/orm": "^2.5", // 2.7.5
        "doctrine/doctrine-bundle": "^1.6", // 1.12.13
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.4.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.6.7
        "symfony/monolog-bundle": "^2.8", // v2.12.1
        "symfony/polyfill-apcu": "^1.0", // v1.23.0
        "sensio/distribution-bundle": "^5.0", // v5.0.25
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.29
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.4
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "knplabs/knp-markdown-bundle": "^1.4", // 1.9.0
        "doctrine/doctrine-migrations-bundle": "^1.1", // v1.3.2
        "stof/doctrine-extensions-bundle": "^1.2" // v1.3.0
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.7
        "symfony/phpunit-bridge": "^3.0", // v3.4.47
        "nelmio/alice": "^2.1", // v2.3.6
        "doctrine/doctrine-fixtures-bundle": "^2.3" // v2.4.1
    }
}
userVoice