If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeEach 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 eachTag
can also relate to manyQuestion
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 updateQuestion
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.
// 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
}
}
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:
`
`
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.