Saving Items in a ManyToMany Relation

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

We just successfully generated a ManyToMany relationship between Question and Tag... and we even made and executed the migration.

Now let's see how we can relate these objects in PHP. Open up src/DataFixtures/AppFixtures.php. We're going to create a couple of objects by hand. Start with $question = QuestionFactory::createOne() to create a question - the lazy way - using our factory. Then I'll paste in some code that creates two Tag objects for some very important topics to my 4 year old son.

... lines 1 - 6
use App\Entity\Tag;
... lines 8 - 12
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 17 - 35
$question = QuestionFactory::createOne();
$tag1 = new Tag();
$tag1->setName('dinosaurs');
$tag2 = new Tag();
$tag2->setName('monster trucks');
... lines 42 - 45
$manager->flush();
}
}

To actually save these, we need to call $manager->persist($tag1) and $manager->persist($tag2).

... lines 1 - 6
use App\Entity\Tag;
... lines 8 - 12
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 17 - 35
$question = QuestionFactory::createOne();
$tag1 = new Tag();
$tag1->setName('dinosaurs');
$tag2 = new Tag();
$tag2->setName('monster trucks');
$manager->persist($tag1);
$manager->persist($tag2);
$manager->flush();
}
}

Relating the Objects

Awesome! Right now, this will create one new Question and two new tags... but they won't be related in the database. So how do we relate them? Well, don't think at all about the join table that was created... you really want to pretend like that doesn't even exist. Instead, like we've done with the other relationship, just think:

I want to relate these two Tag objects to this Question object.

Doing that is pretty simple: $question->addTag($tag1) and $question->addTag($tag2).

... lines 1 - 12
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 17 - 35
$question = QuestionFactory::createOne();
$tag1 = new Tag();
$tag1->setName('dinosaurs');
$tag2 = new Tag();
$tag2->setName('monster trucks');
$question->addTag($tag1);
$question->addTag($tag2);
... lines 45 - 49
}
}

That's it! Let's try this thing! Reload the fixtures:

symfony console doctrine:fixtures:load

And... no errors! Check the database:

symfony console doctrine:query:sql 'SELECT * FROM tag'

No surprise: we have two tags in this table. Now SELECT * FROM question_tag - the join table.

symfony console doctrine:query:sql 'SELECT * FROM question_tag'

And... yes! This has two rows! The first relates the first tag to the question and the second relates the second tag to that same question. How cool is that? We simply relate the objects in PHP and Doctrine handles inserting the rows into the join table.

If we saved all of this stuff and then, down here, said $question->removeTag($tag1) and saved again, this would cause Doctrine to delete the first row in that table. All of the inserting and deleting happens automatically.

Owning vs Inverse on a ManyToMany

By the way, like with any relationship, a ManyToMany has an owning side and an inverse side. Because we originally modified the Question entity and added a $tags property, this is the owning side.

In a ManyToOne and OneToMany relationship, the owning side is always the ManyToOne... because that's the entity where the foreign key column exists, like question_id on the answer table.

But a ManyToMany is a bit different: you get to choose which side is the owning side. Because we decided to update the Question entity when we ran make:entity, that command set up this class to be the owning side. The way you know is that it points to the other side by saying inversedBy="". So it's pointing to the other side of the relationship as the inverse side.

... lines 1 - 16
class Question
{
... lines 19 - 59
/**
* @ORM\ManyToMany(targetEntity=Tag::class, inversedBy="questions")
*/
private $tags;
... lines 64 - 214
}

Then, over in Tag, this is the inverse side. And you can see that it says mappedBy="tags". This says:

The owning side - or "mapped side" - is the tags property over in the Question entity.

... lines 1 - 13
class Tag
{
... lines 16 - 29
/**
* @ORM\ManyToMany(targetEntity=Question::class, mappedBy="tags")
*/
private $questions;
... lines 34 - 82
}

But... remember: this distinction isn't that important. Technically speaking, when we want to relate a Tag and Question, the only way to do that is by setting the owning side: setting the $tags property on Question.

So let's do an experiment: change the code to be $tag1->addQuestion($question) and $tag2->addQuestion($question).

... lines 1 - 12
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 17 - 42
$tag1->addQuestion($question);
$tag2->addQuestion($question);
... lines 45 - 49
}
}

So we're now setting the inverse side of the relationship only. In theory, this should not save correctly. But let's try it: reload the fixtures.

symfony console doctrine:fixtures:load

Foundry Proxy Objects

Ah! This error is unrelated: it's from Foundry: it says that $tag->addQuestion() argument one should be a Question object, but it received a Proxy object.

When you create an object with Foundry, like up here, it actually returns a Proxy object that wraps the true Question object. It doesn't normally matter, but if you start mixing Foundry code with non-Foundry code, sometimes you can get this error. To fix it, add ->object().

... lines 1 - 12
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 17 - 35
$question = QuestionFactory::createOne()->object();
... lines 37 - 49
}
}

This will now be a pure Question object.

Anyways, reload the fixtures again:

symfony console doctrine:fixtures:load

And... it works. More importantly, if we query the join table:

symfony console doctrine:query:sql 'SELECT * FROM question_tag'

We still have two rows! That means that we were able to relate Tag and Question object by setting only the inverse side of the relation... which is exactly the opposite of what I said.

But... this only works because our entity code is smart. Look at the Tag class... and go down to the addQuestion() method.

... lines 1 - 13
class Tag
{
... lines 16 - 64
public function addQuestion(Question $question): self
{
if (!$this->questions->contains($question)) {
$this->questions[] = $question;
$question->addTag($this);
}
return $this;
}
... lines 74 - 82
}

Yep, it calls $question->addTag($this). We saw this exact same thing with the Question Answer relationship. When we call, addQuestion(), it handles setting the owning side of the relationship. That is why this saved. Watch: if we comment this line out...

... lines 1 - 13
class Tag
{
... lines 16 - 64
public function addQuestion(Question $question): self
{
if (!$this->questions->contains($question)) {
$this->questions[] = $question;
//$question->addTag($this);
}
return $this;
}
... lines 74 - 82
}

reload the fixtures...

symfony console doctrine:fixtures:load

... and query the join table, it's empty! We do have 2 Tag objects... but they are not related to any questions in the database because we never set the owning side of the relationship. So... let's put that code back.

... lines 1 - 13
class Tag
{
... lines 16 - 64
public function addQuestion(Question $question): self
{
if (!$this->questions->contains($question)) {
$this->questions[] = $question;
$question->addTag($this);
}
return $this;
}
... lines 74 - 82
}

Next: let's use Foundry to create a bunch of Tag objects and randomly relate them to questions.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.3
        "doctrine/doctrine-bundle": "^2.1", // 2.4.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.9.5
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "sensio/framework-extra-bundle": "^6.0", // v6.1.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.7
        "symfony/flex": "^1.3.1", // v1.15.1
        "symfony/framework-bundle": "5.3.*", // v5.3.7
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.33.0
        "symfony/var-dumper": "5.3.*", // v5.3.7
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.5
        "zenstruck/foundry": "^1.1" // v1.13.1
    }
}