Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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

Each Question is going to be able to have many tags: we're going to render the list of tags below each question. But then, each tag could also be related to many different questions. OMG! We need a ManyToMany relationship! But don't take my word for it, let's pretend that we haven't figured which relationship we need yet: we just know that we want to be able to set multiple Tag objects onto a Question object. In other words, we want our Question class to have a tags property. Let's add that! Find your terminal and run:

symfony console make:entity

For which entity to edit, we could actually choose Question or Tag... it won't make much difference. But in my mind, I want to edit the Question entity in order to add a new property called tags to it. Once again, use the fake type called relation to activate the relationship wizard.

Okay: what class should this entity be related to? We want to relate to the Tag entity. And just like before, we see a nice table describing the different relationship types. If you focus on ManyToMany, it says:

Each question can have many Tag objects and each Tag can also relate to many Question objects.

That describes our situation perfectly. Answer ManyToMany. Next, it asks a familiar question:

Do we want to add a new property to Tag so that we can access or update Question objects from it?

It's basically saying:

Hey! Would it be useful to have a $tag->getQuestions() method?

I'm not so sure that it would be useful... but let's say yes: it doesn't hurt anything. This will cause it to generate the other side of the relationship: we'll see that code in a minute. What should the property be called inside Tag? questions sounds perfect.

And... we're done! Hit enter to exit the wizard... and let's go check out the entities! Start in Question. Awesome. No surprise: it added a new $tags property, which will hold a collection of Tag objects. And as we mentioned before, whenever you have a relationship that holds a "collection" of things - whether that's a collection of answers or a collection of tags - in the __construct method, you need to initialize it to an ArrayCollection. That's taken care of for us.

... lines 1 - 16
class Question
{
... lines 19 - 59
/**
* @ORM\ManyToMany(targetEntity=Tag::class, inversedBy="questions")
*/
private $tags;
public function __construct()
{
... line 67
$this->tags = new ArrayCollection();
}
... lines 70 - 214
}

Above the property, we have a ManyToMany to tags... and if you scroll to the bottom of the class, we have getTags(), addTag() and removeTag() methods.

... lines 1 - 16
class Question
{
... lines 19 - 59
/**
* @ORM\ManyToMany(targetEntity=Tag::class, inversedBy="questions")
*/
private $tags;
public function __construct()
{
... line 67
$this->tags = new ArrayCollection();
}
... lines 70 - 191
/**
* @return Collection|Tag[]
*/
public function getTags(): Collection
{
return $this->tags;
}
public function addTag(Tag $tag): self
{
if (!$this->tags->contains($tag)) {
$this->tags[] = $tag;
}
return $this;
}
public function removeTag(Tag $tag): self
{
$this->tags->removeElement($tag);
return $this;
}
}

If you're thinking that this looks a lot like the code generated for a OneToMany relationship, you're right!

Now let's check out the Tag class. Things here... well... they look pretty much the same! We have a $questions property... which is initialized to an ArrayCollection. It is also a ManyToMany and points to the Question class.

... lines 1 - 13
class Tag
{
... lines 16 - 29
/**
* @ORM\ManyToMany(targetEntity=Question::class, mappedBy="tags")
*/
private $questions;
public function __construct()
{
$this->questions = new ArrayCollection();
}
... lines 39 - 82
}

And below, it has getQuestions(), addQuestion() and removeQuestion().

... lines 1 - 13
class Tag
{
... lines 16 - 29
/**
* @ORM\ManyToMany(targetEntity=Question::class, mappedBy="tags")
*/
private $questions;
public function __construct()
{
$this->questions = new ArrayCollection();
}
... lines 39 - 56
/**
* @return Collection|Question[]
*/
public function getQuestions(): Collection
{
return $this->questions;
}
public function addQuestion(Question $question): self
{
if (!$this->questions->contains($question)) {
$this->questions[] = $question;
$question->addTag($this);
}
return $this;
}
public function removeQuestion(Question $question): self
{
if ($this->questions->removeElement($question)) {
$question->removeTag($this);
}
return $this;
}
}

Now that we've seen what this look like in PHP, let's generate the migration:

symfony console make:migration

Once it finishes... spin over and open that new file. And... woh! It creates a brand new table? It's called question_tag... and it has only two columns: a question_id foreign key column and a tag_id foreign key column. That's it.

... lines 1 - 4
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20210907185958 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE question_tag (question_id INT NOT NULL, tag_id INT NOT NULL, INDEX IDX_339D56FB1E27F6BF (question_id), INDEX IDX_339D56FBBAD26311 (tag_id), PRIMARY KEY(question_id, tag_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE question_tag ADD CONSTRAINT FK_339D56FB1E27F6BF FOREIGN KEY (question_id) REFERENCES question (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE question_tag ADD CONSTRAINT FK_339D56FBBAD26311 FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE question_tag');
}
}

And... this makes sense! Even outside of Doctrine, this is how you build a ManyToMany relationship: you create a "join table" that keeps track of which tags are related to which questions.

With Doctrine, it's no different... except that Doctrine is going to handle the heavy lifting of inserting and removing records to and from this table for us. We'll see that in a minute.

But before I forget, head back to your terminal and run this migration:

symfony console doctrine:migrations:migrate

Next: let's see our relationship in action, by relating questions and tags in PHP and watching Doctrine automatically inserts rows into the join table.

Leave a comment!

1
Login or Register to join the conversation
gazzatav Avatar
gazzatav Avatar gazzatav | posted 1 year ago | edited

Struggling with this one. I keep getting the error "The class 'Zenstruck\Foundry\Proxy' was not found in the chain configured namespaces App\Entity" and there are some deprecation warnings if I turn the volume up (-vvv). It happens when I add this code:
`

    $tag1 = new Tag('dinosaurs');
    $question4->addTag($tag1);
    $manager->persist($tag1);
    $manager->persist($question4);

`

My constructor for Tag sets the name but I've read that doctrine uses reflection to create the objects so the constructor should be irrelevant, right?
I don't even understand what it means by chain configured namespaces but it is AppFixtures.php that shows up in the exception stack trace.

Reply
Cat in space

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

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "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.2.1
        "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.17.5
        "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/validator": "5.3.*", // v5.3.14
        "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
    }
}
userVoice