Handling ManyToMany in Foundry

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

Now that we've seen how we can relate Tag objects and Question objects, let's use Foundry to properly create some fresh Tag fixture data. Start by generating the Tag factory

symfony console make:factory

And... we want to generate the one for Tag. Beautiful!

... lines 1 - 2
namespace App\Factory;
use App\Entity\Tag;
use App\Repository\TagRepository;
use Zenstruck\Foundry\RepositoryProxy;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
... lines 11 - 28
final class TagFactory extends ModelFactory
{
public function __construct()
{
parent::__construct();
// TODO inject services if required (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services)
}
protected function getDefaults(): array
{
return [
// TODO add your default values here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories)
'name' => self::faker()->text(),
'createdAt' => null, // TODO add DATETIME ORM type manually
'updatedAt' => null, // TODO add DATETIME ORM type manually
];
}
protected function initialize(): self
{
// see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
return $this
// ->afterInstantiate(function(Tag $tag) {})
;
}
protected static function getClass(): string
{
return Tag::class;
}
}

Go check out that class: src/Factory/TagFactory.php. Remember: our only job is to make sure that we have good default values for all of the required properties. For name, instead of using text(), we can use ->word(). And like I've done before, I'm going to remove updatedAt... but set createdAt to self::faker->dateTimeBetween('-1 year').

... lines 1 - 28
final class TagFactory extends ModelFactory
{
... lines 31 - 37
protected function getDefaults(): array
{
return [
'name' => self::faker()->word(),
'createdAt' => self::faker()->dateTimeBetween('-1 year'),
];
}
... lines 45 - 57
}

Now that we have this, at the top of the fixtures class, we can create 100 random tags with TagFactory::createMany(100). I love doing that!

... lines 1 - 9
use App\Factory\TagFactory;
... lines 11 - 13
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
TagFactory::createMany(100);
... lines 19 - 41
}
}

Below, for these 20 published questions, I want to relate each one to a random number of tags. To do that, pass a second argument: this is an array of attribute overrides. Let's think: the property we want to set on each Question object is called tags. So pass tags => some collection of tags. To get that collection, let's pass this a new function: TagFactory::randomRange(0, 5).

... lines 1 - 13
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
TagFactory::createMany(100);
$questions = QuestionFactory::createMany(20, [
'tags' => TagFactory::randomRange(0, 5),
]);
... lines 23 - 41
}
}

This is pretty cool: it will return 0 to 5 random tags from the database, giving each question a different number of random tags. There is a small problem with this code... and maybe you see it... but let's try it anyways.

Spin over and reload the fixtures:

symfony console doctrine:fixtures:load

Awesome. And now check the database. I'll first say:

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

Yep! We do have 100 tags. Actually, we have 102 tags. Go the bottom of the fixtures class and delete our code from earlier: we don't need that anymore.

Anyways, this created 100 tags. Now check the join table: SELECT * FROM question_tag

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

And... it did work... though if we're assigning 0 to 5 tags to each of the 20 questions... 20 total seems a little low. And... it is! Look closely: every row is related to the same tag!

Of course! I keep making this mistake! Because we're passing an array of attributes, the TagFactory::randomRange() method is only called once. So in my situation, this returned one random Tag... and then assigned that one Tag to all 20 questions... which is why we ended up with 20 rows.

We know the fix: change this to a callback... that returns that array.

... lines 1 - 13
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 18 - 19
$questions = QuestionFactory::createMany(20, function() {
return [
'tags' => TagFactory::randomRange(0, 5),
];
});
... lines 25 - 43
}
}

Try it again:

symfony console doctrine:fixtures:load

And then query the join table:

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

Sweet! 41 results seems right! And we can see that each question is related to different tags... and a different number of tags: some only have one, this one has 4. So, it's perfect.

Next: each published question is now related to 0 to 5 tags. Time to render the ManyToMany relationship on the frontend and learn how to join across it in a query.

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
    }
}