Buy Access to Course
21.

Foundry Tricks

Share this awesome video!

|

In QuestionFactory, we're already doing a pretty good job of making some of this data random so that all of the questions aren't identical. To help with this, Foundry comes with built-in support for Faker: a library that's great at creating all kinds of interesting, fake data.

Using Faker

If you look at the top of the Foundry docs, you'll see a section called Faker and a link to the Faker documentation. This tells you everything that Faker can do... which is... a lot. Let's use it to make our fixtures even better.

Tip

The Faker library now has a new home! At https://github.com/FakerPHP/Faker. Same great library, shiny new home.

For example, for the random -1 to -100 days, we can make it more readable by replacing the new \DateTime() with self::faker() - that's how you can get an instance of the Faker object - then ->dateTimeBetween() to go from -100 days to -1 day.

57 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
// ... lines 25 - 38
'askedAt' => rand(1, 10) > 2 ? self::faker()->dateTimeBetween('-100 days', '-1 days') : null,
// ... line 40
];
}
// ... lines 43 - 55
}

And because this is more flexible, we can even change it from -100 days to -1 minute!

57 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
// ... lines 25 - 38
'askedAt' => rand(1, 10) > 2 ? self::faker()->dateTimeBetween('-100 days', '-1 minute') : null,
// ... line 40
];
}
// ... lines 43 - 55
}

Even the random true/false condition at the beginning can be generated by Faker. What we really want is to create published questions about 70% of the time. We can do that with self::faker()->boolean(70):

57 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
// ... lines 25 - 38
'askedAt' => self::faker()->boolean(70) ? self::faker()->dateTimeBetween('-100 days', '-1 minute') : null,
// ... line 40
];
}
// ... lines 43 - 55
}

This is cool, but the real problem is that the name and question are always the same. That is definitely not realistic. Let's fix that: set name to self::faker()->realText() to get several words of "real looking" text:

57 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
'name' => self::faker()->realText(50),
// ... lines 26 - 40
];
}
// ... lines 43 - 55
}

For slug, there's a feature for that! self::faker()->slug:

57 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
// ... line 25
'slug' => self::faker()->slug,
// ... lines 27 - 40
];
}
// ... lines 43 - 55
}

Tip

Direct property access is deprecated since v1.14 of fakerphp/faker - use self::faker()->slug() instead of self::faker()->slug

Finally, for the question text, it can be made much more interesting by using self::faker->paragraphs().

49 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
// ... lines 25 - 26
'question' => self::faker()->paragraphs(
// ... lines 28 - 29
),
// ... lines 31 - 32
];
}
// ... lines 35 - 47
}

Faker lets you use paragraphs like a property or you can call a function and pass arguments, which are the number of paragraphs and whether you want them returned as text - which we do - or as an array. For the number of paragraphs, we can use Faker again! self::faker()->numberBetween(1, 4) and then true to return this as a string.

49 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
// ... lines 25 - 26
'question' => self::faker()->paragraphs(
self::faker()->numberBetween(1, 4),
true
),
// ... lines 31 - 32
];
}
// ... lines 35 - 47
}

Let's take this for a test drive! Find your terminal and reload the fixtures with:

symfony console doctrine:fixtures:load

Go check the homepage and... yea!

Oh, but the "real text" for the name is way too long. What I meant to do is pass ->realText(50). Let's reload the fixtures again:

symfony console doctrine:fixtures:load

And... there we go! We now have many Question objects and they represent a rich set of unique data. This is why I love Foundry.

Doing Things Before Saving

If you click into one of the questions, you can see that the slug is unique... but was generated in a way that is completely unrelated to the question's name. That's "maybe" ok... but it's not realistic. Could we fix that?

Of course! Foundry comes with a nice "hook" system where we can do actions before or after each item is saved. Inside QuestionFactory, the initialize() method is where you can add these hooks.

Remove the slug key from getDefaults() and, instead, down here, uncomment this beforeInstantiate() and change it to afterInstantiate().

54 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
'name' => self::faker()->realText(50),
'question' => self::faker()->paragraphs(
self::faker()->numberBetween(1, 4),
true
),
'askedAt' => self::faker()->boolean(70) ? self::faker()->dateTimeBetween('-100 days', '-1 minute') : null,
'votes' => rand(-20, 50),
];
}
// ... lines 35 - 52
}

So afterInstantiate(), we want to run this function. Inside, to generate a random slug based off of the name, we can say: if not $question->getSlug() - in case we set it manually for some reason:

54 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
// ... lines 23 - 35
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
->afterInstantiate(function(Question $question) {
if (!$question->getSlug()) {
// ... lines 42 - 43
}
})
;
}
// ... lines 48 - 52
}

then use Symfony's Slugger - $slugger = new AsciiSlugger():

54 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 6
use Symfony\Component\String\Slugger\AsciiSlugger;
// ... lines 8 - 20
final class QuestionFactory extends ModelFactory
{
// ... lines 23 - 35
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
->afterInstantiate(function(Question $question) {
if (!$question->getSlug()) {
$slugger = new AsciiSlugger();
// ... line 43
}
})
;
}
// ... lines 48 - 52
}

and set it with $question->setSlug($slugger->slug($question->getName())).

54 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 6
use Symfony\Component\String\Slugger\AsciiSlugger;
// ... lines 8 - 20
final class QuestionFactory extends ModelFactory
{
// ... lines 23 - 35
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
->afterInstantiate(function(Question $question) {
if (!$question->getSlug()) {
$slugger = new AsciiSlugger();
$question->setSlug($slugger->slug($question->getName()));
}
})
;
}
// ... lines 48 - 52
}

Nice! Let's try it. Move over, reload the fixtures again:

symfony console doctrine:fixtures:load

And... go back to the homepage. Let's see: if I click the first one... yes! It works. It has some uppercase letters... which we could normalize to lowercase. But I'm not going to worry about that because, in a few minutes, we'll add an even better way of generating slugs across our entire system.

Foundry "State"

Let's try one last thing with Foundry. To have nice testing data, we need a mixture of published and unpublished questions. We're currently accomplishing that by randomly setting some askedAt properties to null. Instead let's create two different sets of fixtures: exactly 20 that are published and exactly 5 that are unpublished.

To do this, first remove the randomness from askedAt in getDefaults(): let's always set this.

59 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
// ... lines 23 - 27
protected function getDefaults(): array
{
return [
// ... lines 31 - 35
'askedAt' => self::faker()->dateTimeBetween('-100 days', '-1 minute'),
// ... line 37
];
}
// ... lines 40 - 57
}

If we stopped here, we would, of course, have 20 questions that are all published. But now, add a new public function to the factory: public function unpublished() that returns self.

59 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
public function unpublished(): self
{
// ... line 25
}
// ... lines 27 - 57
}

I totally just made up that name. Inside, return $this->addState() and pass it an array with askedAt set to null.

59 lines | src/Factory/QuestionFactory.php
// ... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
public function unpublished(): self
{
return $this->addState(['askedAt' => null]);
}
// ... lines 27 - 57
}

Here's the deal: when you call addState(), it changes the default data inside this instance of the factory. Oh, and the return statement here just helps to return self... which allows method chaining.

To use this, go back to AppFixtures. Start with QuestionFactory::new() - to get a second instance of QuestionFactory:

24 lines | src/DataFixtures/AppFixtures.php
// ... lines 1 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
// ... lines 14 - 15
QuestionFactory::new()
// ... lines 17 - 18
;
// ... lines 20 - 21
}
}

then ->unpublished() to change the default askedAt data. You can see why I called the method unpublished(): it makes this super clear.

24 lines | src/DataFixtures/AppFixtures.php
// ... lines 1 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
// ... lines 14 - 15
QuestionFactory::new()
->unpublished()
// ... line 18
;
// ... lines 20 - 21
}
}

Finish with ->createMany(5).

24 lines | src/DataFixtures/AppFixtures.php
// ... lines 1 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
// ... lines 14 - 15
QuestionFactory::new()
->unpublished()
->createMany(5)
;
// ... lines 20 - 21
}
}

I love this! It reads like a story: create a new factory, make everything unpublished and create 5.

Let's... even make sure it works! At the terminal, reload the fixtures:

symfony console doctrine:fixtures:load

Then... refresh the homepage.

All good! If we dug into the database, we'd find 20 published questions and five unpublished. Foundry can do more - especially with Doctrine relations and testing - and we'll talk about Doctrine relations in the next tutorial.

But first, the slug property is being set automatically in our fixtures. That's cool... but I'd really love for the slug to automatically be set to a URL-safe version of the name no matter where we create a Question object. Basically, we shouldn't never need to worry about setting the slug manually.

So next let's install a bundle that will give our entity Sluggable and Timestampable superpowers.