Foundry Tricks
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
.
// ... 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
!
// ... 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)
:
// ... 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:
// ... 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
:
// ... 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()
.
// ... 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.
// ... 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()
.
// ... 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:
// ... 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()
:
// ... 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()))
.
// ... 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.
// ... 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
.
// ... 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.
// ... 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
:
// ... 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.
// ... 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)
.
// ... 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.
The createMany() instance method is deprecated. Use this instead:
`
QuestionFactory::new()
`