Skip to content

Foundry

Edit this page

Foundry makes creating fixtures data fun again, via an expressive, auto-completable, on-demand fixtures system with Symfony and Doctrine:

The factories can be used inside DoctrineFixturesBundle to load fixtures or inside your tests, where it has even more features.

Foundry supports doctrine/orm (with doctrine/doctrine-bundle), doctrine/mongodb-odm (with doctrine/mongodb-odm-bundle) or a combination of these.

Want to watch a screencast 🎥 about it? Check out https://symfonycasts.com/foundry

Warning

You're reading the documentation for Foundry v2 which is brand new. You might want to look at Foundry v1 documentation or the upgrade guide to v2

Installation

1
$ composer require --dev zenstruck/foundry

To use the make:* commands from this bundle, ensure Symfony MakerBundle is installed.

If not using Symfony Flex, be sure to enable the bundle in your test/dev environments.

Same Entities used in these Docs

For the remainder of the documentation, the following sample entities will be used:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace App\Entity;

use App\Repository\CategoryRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'string')]
    private $id;

    #[ORM\Column(type: 'string', length: 255)]
    private $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    // ... getters/setters
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
namespace App\Entity;

use App\Repository\PostRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'string')]
    private $id;

    #[ORM\Column(type: 'string', length: 255)]
    private $title;

    #[ORM\Column(type: 'text', nullable: true)]
    private $body;

    #[ORM\Column(type: 'datetime')]
    private $createdAt;

    #[ORM\Column(type: 'datetime', nullable: true)]
    private $publishedAt;

    #[ORM\ManyToOne(targetEntity: Category::class)]
    #[ORM\JoinColumn]
    private $category;

    public function __construct(string $title)
    {
        $this->title = $title;
        $this->createdAt = new \DateTime('now');
    }

    // ... getters/setters
}

Factories

The nicest way to use Foundry is to generate one factory class per ORM entity or MongoDB document. You can skip this and use Anonymous Factories, but persistent object factories give you IDE auto-completion and access to other useful features.

Generate

Create a persistent object factory for one of your entities with the maker command:

1
2
3
4
5
6
7
8
$ php bin/console make:factory

> Entity class to create a factory for:
> Post

created: src/Factory/PostFactory.php

Next: Open your new factory and set default values/states.

This command will generate a PostFactory class that looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// src/Factory/PostFactory.php
namespace App\Factory;

use App\Entity\Post;
use App\Repository\PostRepository;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\Proxy;
use Zenstruck\Foundry\Persistence\ProxyRepositoryDecorator;

/**
 * @extends PersistentProxyObjectFactory<Post>
 */
final class PostFactory extends PersistentProxyObjectFactory
{
    /**
     * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
     *
     * @todo inject services if required
     */
    public function __construct()
    {
    }

    public static function class(): string
    {
        return Post::class;
    }

    /**
     * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
     *
     * @todo add your default values here
     */
    protected function defaults(): array|callable
    {
        return [
            'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
            'title' => self::faker()->text(255),
        ];
    }

    /**
     * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
     */
    protected function initialize(): static
    {
        return $this
            // ->afterInstantiate(function(Post $post): void {})
        ;
    }
}

Tip

Using make:factory --test will generate the factory in tests/Factory.

Tip

You can also inherit from Zenstruck\Foundry\Persistence\PersistentObjectFactory. Which will create regular objects without proxy (see Proxy object section for more information).

Tip

You can globally configure which namespace the factories will be generated in:

1
2
3
4
5
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        make_factory:
            default_namespace: 'App\\MyFactories'

You can override this configuration by using the --namespace option.

Note

You can add the option --with-phpdoc in order to add the following @method docblocks. This would ease autocompletion in your IDE (might be not useful anymore since Foundry v2, at least in PHPStorm):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
 * @method        Post|Proxy create(array|callable $attributes = [])
 * @method static Post|Proxy createOne(array $attributes = [])
 * @method static Post|Proxy find(object|array|mixed $criteria)
 * @method static Post|Proxy findOrCreate(array $attributes)
 * @method static Post|Proxy first(string $sortBy = 'id')
 * @method static Post|Proxy last(string $sortBy = 'id')
 * @method static Post|Proxy random(array $attributes = [])
 * @method static Post|Proxy randomOrCreate(array $attributes = []))
 * @method static PostRepository|RepositoryProxy repository()
 * @method static Post[]|Proxy[] all()
 * @method static Post[]|Proxy[] createMany(int $number, array|callable $attributes = [])
 * @method static Post[]&Proxy[] createSequence(iterable|callable $sequence)
 * @method static Post[]|Proxy[] findBy(array $attributes)
 * @method static Post[]|Proxy[] randomRange(int $min, int $max, array $attributes = []))
 * @method static Post[]|Proxy[] randomSet(int $number, array $attributes = []))
 *
 * @phpstan-method Proxy<Post>&Post create(array|callable $attributes = [])
 * @phpstan-method static Proxy<Post>&Post createOne(array $attributes = [])
 * @phpstan-method static Proxy<Post>&Post find(object|array|mixed $criteria)
 * @phpstan-method static Proxy<Post>&Post findOrCreate(array $attributes)
 * @phpstan-method static Proxy<Post>&Post first(string $sortBy = 'id')
 * @phpstan-method static Proxy<Post>&Post last(string $sortBy = 'id')
 * @phpstan-method static Proxy<Post>&Post random(array $attributes = [])
 * @phpstan-method static Proxy<Post>&Post randomOrCreate(array $attributes = [])
 * @phpstan-method static list<Proxy<Post>&Post> all()
 * @phpstan-method static list<Proxy<Post>&Post> createMany(int $number, array|callable $attributes = [])
 * @phpstan-method static list<Proxy<Post>&Post> createSequence(array|callable $sequence)
 * @phpstan-method static list<Proxy<Post>&Post> findBy(array $attributes)
 * @phpstan-method static list<Proxy<Post>&Post> randomRange(int $min, int $max, array $attributes = [])
 * @phpstan-method static list<Proxy<Post>&Post> randomSet(int $number, array $attributes = [])
 * @phpstan-method static RepositoryProxy<Post>&Post repository()
 */
final class PostFactory extends PersistentProxyObjectFactory
{
    // ...
}

In the defaults(), you can return an array of all default values that any new object should have. Faker is available to easily get random data:

1
2
3
4
5
6
7
8
9
protected function defaults(): array
{
    return [
        // Symfony's property-access component is used to populate the properties
        // this means that setTitle() will be called or you can have a $title constructor argument
        'title' => self::faker()->unique()->sentence(),
        'body' => self::faker()->sentence(),
    ];
}

Tip

It is best to have defaults() return the attributes to persist a valid object (all non-nullable fields).

Tip

Using make:factory --all-fields will generate default values for all fields of the entity, not only non-nullable fields.

Note

defaults() is called everytime a factory is instantiated (even if you don't end up creating it). Lazy Values allows you to ensure the value is only calculated when/if it's needed.

Using your Factory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
use App\Factory\PostFactory;

// create/persist Post with random data from `defaults()`
PostFactory::createOne();

// or provide values for some properties (others will be random)
PostFactory::createOne(['title' => 'My Title']);

// createOne() returns the persisted Post object wrapped in a Proxy object
$post = PostFactory::createOne();

// the "Proxy" magically calls the underlying Post methods and is type-hinted to "Post"
$title = $post->getTitle(); // getTitle() can be autocompleted by your IDE!

// if you need the actual Post object, use ->_real()
$realPost = $post->_real();

// create/persist 5 Posts with random data from defaults()
PostFactory::createMany(5); // returns Post[]|Proxy[]
PostFactory::createMany(5, ['title' => 'My Title']);

// Create 5 posts with incremental title
PostFactory::createMany(
    5,
    static function(int $i) {
        return ['title' => "Title $i"]; // "Title 1", "Title 2", ... "Title 5"
    }
);

// find a persisted object for the given attributes, if not found, create with the attributes
PostFactory::findOrCreate(['title' => 'My Title']); // returns Post|Proxy

PostFactory::first(); // get the first object (assumes an auto-incremented "id" column)
PostFactory::first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object
PostFactory::last(); // get the last object (assumes an auto-incremented "id" column)
PostFactory::last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object

PostFactory::truncate(); // empty the database table

PostFactory::count(); // the number of persisted Posts
PostFactory::count(['category' => $category]); // the number of persisted Posts with the given category

PostFactory::all(); // Post[]|Proxy[] all the persisted Posts

PostFactory::findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter

$post = PostFactory::find(5); // Post|Proxy with the id of 5
$post = PostFactory::find(['title' => 'My First Post']); // Post|Proxy matching the filter

// get a random object that has been persisted
$post = PostFactory::random(); // returns Post|Proxy
$post = PostFactory::random(['author' => 'kevin']); // filter by the passed attributes

// or automatically persist a new random object if none exists
$post = PostFactory::randomOrCreate();
$post = PostFactory::randomOrCreate(['author' => 'kevin']); // filter by or create with the passed attributes

// get a random set of objects that have been persisted
$posts = PostFactory::randomSet(4); // array containing 4 "Post|Proxy" objects
$posts = PostFactory::randomSet(4, ['author' => 'kevin']); // filter by the passed attributes

// random range of persisted objects
$posts = PostFactory::randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects
$posts = PostFactory::randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes

Reusable Factory "States"

You can add any methods you want to your factories (i.e. static methods that create an object in a certain way) but you can also add states:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
final class PostFactory extends PersistentProxyObjectFactory
{
    // ...

    public function published(): self
    {
        // call setPublishedAt() and pass a random DateTime
        return $this->with(['published_at' => self::faker()->dateTime()]);
    }

    public function unpublished(): self
    {
        return $this->with(['published_at' => null]);
    }

    public function withViewCount(?int $count = null): self
    {
        return $this->with(function () use ($count) {
            return ['view_count' => $count ?? self::faker()->numberBetween(0, 10000)];
        });
    }
}

You can use states to make your tests very explicit to improve readability:

1
2
3
4
5
6
7
8
9
10
11
12
// never use the constructor (i.e. "new PostFactory()"), but use the
// "new()" method. After defining the states, call "create()" to create
// and persist the model.
$post = PostFactory::new()->unpublished()->create();
$post = PostFactory::new()->withViewCount(3)->create();

// combine multiple states
$post = PostFactory::new()
    ->unpublished()
    ->withViewCount(10)
    ->create()
;

Note

Be sure to chain the states/hooks off of $this because factories are Immutable.

Attributes

The attributes used to instantiate the object can be added several ways. Attributes can be an array, or a callable that returns an array. Using a callable ensures random data as the callable is run for each object separately during instantiation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use App\Entity\Category;
use App\Entity\Post;
use App\Factory\CategoryFactory;
use App\Factory\PostFactory;
use function Zenstruck\Foundry\faker;

// The first argument to "new()" allows you to overwrite the default
// values that are defined in the `PostFactory::defaults()`
$posts = PostFactory::new(['title' => 'Post A'])
    ->with([
        'body' => 'Post Body...',

        // CategoryFactory will be used to create a new Category for each Post
        'category' => CategoryFactory::new(['name' => 'php']),
    ])
    ->with([
        // Proxies are automatically converted to their wrapped object
        // will override previous category
        'category' => CategoryFactory::createOne(['name' => 'Symfony']),
    ])
    ->with(function() { return ['createdAt' => faker()->dateTime()]; }) // see faker section below

    // create "2" Post's
    ->many(2)->create(['title' => 'Different Title'])
;

$posts[0]->getTitle(); // "Different Title"
$posts[0]->getBody(); // "Post Body..."
$posts[0]->getCategory(); // Category with name "Symfony"
$posts[0]->getPublishedAt(); // \DateTime('last week')
$posts[0]->getCreatedAt(); // random \DateTime

$posts[1]->getTitle(); // "Different Title"
$posts[1]->getBody(); // "Post Body..."
$posts[1]->getCategory(); // Category with name "Symfony" (same object than above)
$posts[1]->getPublishedAt(); // \DateTime('last week')
$posts[1]->getCreatedAt(); // random \DateTime (different than above)

Note

Attributes passed to the create* methods are merged with any attributes set via defaults() and with().

Sequences

Sequences help to create different objects in one call:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use App\Factory\PostFactory;

// create/persist 2 posts based on a sequence of attributes
PostFactory::createSequence(
    [
        ['name' => 'title 1'],
        ['name' => 'title 2'],
    ]
);

// create 10 posts using a sequence callback with an incremental index
PostFactory::createSequence(
    function() {
        foreach (range(1, 10) as $i) {
            yield ['name' => "title $i"];
        }
    }
);

// sequences could also be used with a factory with states
$posts = PostFactory::new()
    ->unpublished()
    ->sequence(
        [
            ['name' => 'title 1'],
            ['name' => 'title 2'],
        ]
    )->create();

Faker

This library provides a wrapper for FakerPHP to help with generating random data for your factories:

1
2
3
use function Zenstruck\Foundry\faker;

faker()->email(); // random email

Note

You can customize Faker's locale and random seed:

1
2
3
4
5
6
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        faker:
            locale: fr_FR # set the locale
            seed: 5678 # set the random number generator seed

Note

You can register your own Faker Provider by tagging any service with foundry.faker_provider. All public methods on this service will be available on Foundry's Faker instance:

1
2
3
use function Zenstruck\Foundry\faker;

faker()->customMethodOnMyService();

Note

For full control, you can register your own Faker\Generator service:

1
2
3
4
5
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        faker:
            service: my_faker # service id for your own instance of Faker\Generator

Events / Hooks

The following events can be added to factories. Multiple event callbacks can be added, they are run in the order they were added.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use App\Factory\PostFactory;
use Zenstruck\Foundry\Proxy;

PostFactory::new()
    ->beforeInstantiate(function(array $attributes): array {
        // $attributes is what will be used to instantiate the object, manipulate as required
        $attributes['title'] = 'Different title';

        return $attributes; // must return the final $attributes
    })
    ->afterInstantiate(function(Post $object, array $attributes): void {
        // $object is the instantiated object
        // $attributes contains the attributes used to instantiate the object and any extras
    })
    ->afterPersist(function(Post $object, array $attributes) {
        // this event is only called if the object was persisted
        // $object is the persisted Post object
        // $attributes contains the attributes used to instantiate the object and any extras
    })

    // multiple events are allowed
    ->beforeInstantiate(function($attributes) { return $attributes; })
    ->afterInstantiate(function() {})
    ->afterPersist(function() {})
;

You can also add hooks directly in your factory class:

1
2
3
4
5
6
protected function initialize(): static
{
    return $this
        ->afterPersist(function() {})
    ;
}

Read Initialization to learn more about the initialize() method.

Initialization

You can override your factory's initialize() method to add default state/logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final class PostFactory extends PersistentProxyObjectFactory
{
    // ...

    protected function initialize(): static
    {
        return $this
            ->published() // published by default
            ->instantiateWith(function (array $attributes) {
                return new Post(); // custom instantiation for this factory
            })
            ->afterPersist(function () {}) // default event for this factory
        ;
    }
}

Instantiation

By default, objects are instantiated in the normal fashion, by using the object's constructor. Attributes that match constructor arguments are used. Remaining attributes are set to the object using Symfony's PropertyAccess component (setters/public properties). Any extra attributes cause an exception to be thrown.

You can customize the instantiator in several ways:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use App\Entity\Post;
use App\Factory\PostFactory;
use Zenstruck\Foundry\Object\Instantiator;

// set the instantiator for the current factory
PostFactory::new()
    // instantiate the object without calling the constructor
    ->instantiateWith(Instantiator::withoutConstructor())

    // "foo" and "bar" attributes are ignored when instantiating
    ->instantiateWith(Instantiator::withConstructor()->allowExtra('foo', 'bar'))

    // all extra attributes are ignored when instantiating
    ->instantiateWith(Instantiator::withConstructor()->allowExtra())

    // force set "title" and "body" when instantiating
    ->instantiateWith(Instantiator::withConstructor()->alwaysForce(['title', 'body']))

    // never use setters, always "force set" properties (even private/protected, does not use setter)
    ->instantiateWith(Instantiator::withConstructor()->alwaysForce())

    // can combine the different "modes"
    ->instantiateWith(Instantiator::withoutConstructor()->allowExtra()->alwaysForce())

    // use a "namedConstructor"
    ->instantiateWith(Instantiator::namedConstructor("methodName"))

    // use a callable
    ->instantiateWith(Instantiator::use(function(string $title): object {
        return new Post($title); // ... your own logic
    }))

    // the instantiator is just a callable, you can provide your own
    ->instantiateWith(function(array $attributes, string $class): object {
        return new Post(); // ... your own logic
    })
;

You can customize the instantiator globally for all your factories (can still be overruled by factory instance instantiators):

1
2
3
4
5
6
7
8
9
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        instantiator:
            use_constructor: false # always instantiate objects without calling the constructor
            allow_extra_attributes: true # always ignore extra attributes
            always_force_properties: true # always "force set" properties
            # or
            service: my_instantiator # your own invokable service for complete control

Immutable

Factories are immutable:

1
2
3
4
5
6
7
8
use App\Factory\PostFactory;

$factory = PostFactory::new();
$factory1 = $factory->with([]); // returns a new PostFactory object
$factory2 = $factory->instantiateWith(function () {}); // returns a new PostFactory object
$factory3 = $factory->beforeInstantiate(function () {}); // returns a new PostFactory object
$factory4 = $factory->afterInstantiate(function () {}); // returns a new PostFactory object
$factory5 = $factory->afterPersist(function () {}); // returns a new PostFactory object

Doctrine Relationships

Assuming your entities follow the best practices for Doctrine Relationships and you are using the default instantiator, Foundry just works with doctrine relationships. There are some nuances with the different relationships and how entities are created. The following tries to document these for each relationship type.

Many-to-One

The following assumes the Comment entity has a many-to-one relationship with Post:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use App\Factory\CommentFactory;
use App\Factory\PostFactory;

// Example 1: pre-create Post and attach to Comment
$post = PostFactory::createOne(); // instance of Proxy

CommentFactory::createOne(['post' => $post]);
CommentFactory::createOne(['post' => $post->object()]); // functionally the same as above

// Example 2: pre-create Posts and choose a random one
PostFactory::createMany(5); // create 5 Posts

CommentFactory::createOne(['post' => PostFactory::random()]);

// or create many, each with a different random Post
CommentFactory::createMany(
    5, // create 5 comments
    function() { // note the callback - this ensures that each of the 5 comments has a different Post
        return ['post' => PostFactory::random()]; // each comment set to a random Post from those already in the database
    }
);

// Example 3: create a separate Post for each Comment
CommentFactory::createMany(5, [
    // this attribute is an instance of PostFactory that is created separately for each Comment created
    'post' => PostFactory::new(),
]);

// Example 4: create multiple Comments with the same Post
CommentFactory::createMany(5, [
    'post' => PostFactory::createOne(), // note the "createOne()" here
]);

Tip

It is recommended that the only relationship you define in defaults() is non-null Many-to-One's.

Tip

It is also recommended that your defaults() return a Factory and not the created entity. However, you can use Lazy Values if you need to create the entity in the defaults() method.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected function defaults(): array
{
    return [
        // RECOMMENDED
        // The Post will only be created when the factory is instantiated
        'post' => PostFactory::new(),
        'post' => PostFactory::new()->published(),
        // The callback will be called when the factory is instantiated, creating the Post
        'post' => LazyValue::new(fn () => PostFactory::createOne()),
        'post' => lazy(fn () => PostFactory::new()->published()->create()),

        // NOT RECOMMENDED
        // Will potentially result in extra unintended Posts (if you override the value during instantiation)
        'post' => PostFactory::createOne(),
        'post' => PostFactory::new()->published()->create(),
    ];
}

One-to-Many

The following assumes the Post entity has a one-to-many relationship with Comment:

1
2
3
4
5
6
7
8
9
10
11
use App\Factory\CommentFactory;
use App\Factory\PostFactory;

// Example 1: Create a Post with 6 Comments
PostFactory::createOne(['comments' => CommentFactory::new()->many(6)]);

// Example 2: Create 6 Posts each with 4 Comments (24 Comments total)
PostFactory::createMany(6, ['comments' => CommentFactory::new()->many(4)]);

// Example 3: Create 6 Posts each with between 0 and 10 Comments
PostFactory::createMany(6, ['comments' => CommentFactory::new()->range(0, 10)]);

Many-to-Many

The following assumes the Post entity has a many-to-many relationship with Tag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use App\Factory\PostFactory;
use App\Factory\TagFactory;

// Example 1: pre-create Tags and attach to Post
$tags = TagFactory::createMany(3);

PostFactory::createOne(['tags' => $tags]);

// Example 2: pre-create Tags and choose a random set
TagFactory::createMany(10);

PostFactory::new()
    ->many(5) // create 5 posts
    ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random set
        return ['tags' => TagFactory::randomSet(2)]; // each post uses 2 random tags from those already in the database
    })
;

// Example 3: pre-create Tags and choose a random range
TagFactory::createMany(10);

PostFactory::new()
    ->many(5) // create 5 posts
    ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random range
        return ['tags' => TagFactory::randomRange(0, 5)]; // each post uses between 0 and 5 random tags from those already in the database
    })
;

// Example 4: create 3 Posts each with 3 unique Tags
PostFactory::createMany(3, ['tags' => TagFactory::new()->many(3)]);

// Example 5: create 3 Posts each with between 0 and 3 unique Tags
PostFactory::createMany(3, ['tags' => TagFactory::new()->many(0, 3)]);

Lazy Values

The defaults() method is called everytime a factory is instantiated (even if you don't end up creating it). Sometimes, you might not want your value calculated every time. For example, if you have a value for one of your attributes that:

  • has side effects (i.e. creating a file or fetching a random existing entity from another factory)
  • you only want to calculate once (i.e. creating an entity from another factory to pass as a value into multiple other factories)

You can wrap the value in a LazyValue which ensures the value is only calculated when/if it's needed. Additionally, the LazyValue can be memoized so that it is only calculated once.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Zenstruck\Foundry\Attributes\LazyValue;

class TaskFactory extends PersistentProxyObjectFactory
{
    // ...

    protected function defaults(): array
    {
        $owner = LazyValue::memoize(fn() => UserFactory::createOne());

        return [
            // Call CategoryFactory::random() everytime this factory is instantiated
            'category' => LazyValue::new(fn() => CategoryFactory::random()),
            // The same UserForPersistentFactory instance will be both added to the Project and set as the Task owner
            'project' => ProjectFactory::new(['users' => [$owner]]),
            'owner'   => $owner,
        ];
    }
}

Tip

the lazy() and memoize() helper functions can also be used to create LazyValues, instead of LazyValue::new() and LazyValue::memoize().

Factories as Services

If your factories require dependencies, you can define them as a service. The following example demonstrates a very common use-case: encoding a password with the UserPasswordHasherInterface service.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// src/Factory/UserFactory.php
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class UserFactory extends PersistentProxyObjectFactory
{
    private $passwordHasher;

    public function __construct(UserPasswordHasherInterface $passwordHasher)
    {
        parent::__construct();

        $this->passwordHasher = $passwordHasher;
    }

    public static function class(): string
    {
        return UserForPersistentFactory::class;
    }

    protected function defaults(): array
    {
        return [
            'email' => self::faker()->unique()->safeEmail(),
            'password' => '1234',
        ];
    }

    protected function initialize(): static
    {
        return $this
            ->afterInstantiate(function(UserForPersistentFactory $user) {
                $user->setPassword($this->passwordHasher->hashPassword($user, $user->getPassword()));
            })
        ;
    }
}

If using a standard Symfony Flex app, this will be autowired/autoconfigured. If not, register the service and tag with foundry.factory.

Use the factory as normal:

1
2
UserFactory::createOne(['password' => 'mypass'])->getPassword(); // "mypass" encoded
UserFactory::createOne()->getPassword(); // "1234" encoded (because "1234" is set as the default password)

Note

The provided bundle is required for factories as services.

Note

If using make:factory --test, factories will be created in the tests/Factory directory which is not autowired/autoconfigured in a standard Symfony Flex app. You will have to manually register these as services.

Anonymous Factories

Foundry can be used to create factories for entities that you don't have factories for:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
use App\Entity\Post;
use function Zenstruck\Foundry\Persistence\persist_proxy;
use function Zenstruck\Foundry\Persistence\proxy_factory;
use function Zenstruck\Foundry\Persistence\repository;

$factory = proxy_factory(Post::class);

// has the same API as non-anonymous factories
$factory->create(['field' => 'value']);
$factory->many(5)->create(['field' => 'value']);
$factory->instantiateWith(function () {});
$factory->beforeInstantiate(function () {});
$factory->afterInstantiate(function () {});
$factory->afterPersist(function () {});

// in order to access stored data, use `repository()` helper:
$repository = repository(Post::class);

$repository->first(); // get the first object (assumes an auto-incremented "id" column)
$repository->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object
$repository->last(); // get the last object (assumes an auto-incremented "id" column)
$repository->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object

$repository->truncate(); // empty the database table
$repository->count(); // the number of persisted Post's
$repository->all(); // Post[]|Proxy[] all the persisted Post's

$repository->findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter

$repository->find(5); // Post|Proxy with the id of 5
$repository->find(['title' => 'My First Post']); // Post|Proxy matching the filter

// get a random object that has been persisted
$repository->random(); // returns Post|Proxy
$repository->random(['author' => 'kevin']); // filter by the passed attributes

// get a random set of objects that have been persisted
$repository->randomSet(4); // array containing 4 "Post|Proxy" objects
$repository->randomSet(4, ['author' => 'kevin']); // filter by the passed attributes

// random range of persisted objects
$repository->randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects
$repository->randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes

// convenience functions
$entity = persist_proxy(Post::class, ['field' => 'value']);

Note

If your anonymous factory code is getting too complex, this could be a sign you need an explicit factory class.

Delay Flush

When creating/persisting many factories at once, it can improve performance to instantiate them all without saving to the database, then flush them all at once. To do this, wrap the operations in a flush_after() callback:

1
2
3
4
5
6
use function Zenstruck\Foundry\Persistence\flush_after;

flush_after(function() {
    CategoryFactory::createMany(100); // instantiated/persisted but not flushed
    TagFactory::createMany(200); // instantiated/persisted but not flushed
}); // single flush

The flush_after() function forwards the callback’s return, in case you need to use the objects in your tests:

1
2
3
4
5
6
use function Zenstruck\Foundry\Persistence\flush_after;

[$category, $tag] = flush_after(fn() => [
    CategoryFactory::createOne(),
    TagFactory::createOne(),
]);

Not-persisted objects factory

When dealing with objects which are not aimed to be persisted, you can make your factory inherit from Zenstruck\Foundry\ObjectFactory. This will create plain objects, that does not interact with database (these objects won't be wrapped with a proxy object).

Without Persisting

"Persitent factories" can also create objects without persisting them. This can be useful for unit tests where you just want to test the behavior of the actual object or for creating objects that are not entities. When created, they are still wrapped in a Proxy to optionally save later.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use App\Entity\Post;
use App\Factory\PostFactory;
use function Zenstruck\Foundry\object;
use function Zenstruck\Foundry\Persistence\proxy_factory;

$post = PostFactory::new()->withoutPersisting()->create(); // returns Post|Proxy
$post->setTitle('something else'); // do something with object
$post->save(); // persist the Post (save() is a method on Proxy)

$post = PostFactory::new()->withoutPersisting()->create()->object(); // actual Post object

$posts = PostFactory::new()->withoutPersisting()->many(5)->create(); // returns Post[]|Proxy[]

// anonymous factories:
$factory = proxy_factory(Post::class);

$entity = $factory->withoutPersisting()->create(['field' => 'value']); // returns Post|Proxy

$entity = $factory->withoutPersisting()->create(['field' => 'value'])->object(); // actual Post object

$entities = $factory->withoutPersisting()->many(5)->create(['field' => 'value']); // returns Post[]|Proxy[]

// convenience functions
$entity = object(Post::class, ['field' => 'value']);

If you'd like your factory to not persist by default, override its initialize() method to add this behavior:

1
2
3
4
5
6
protected function initialize(): static
{
    return $this
        ->withoutPersisting()
    ;
}

Now, after creating objects using this factory, you'd have to call ->_save() to actually persist them to the database.

Tip

If you'd like to disable persisting by default for all your object factories:

  1. Create an abstract factory that extends PersistentProxyObjectFactory.
  2. Override the initialize() method as shown above.
  3. Have all your factories extend from this.

Array factories

You can even create associative arrays, with the nice DX provided by Foundry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use Zenstruck\Foundry\ArrayFactory;

final class SomeArrayFactory extends ArrayFactory
{
    protected function defaults(): array|callable
    {
        return [
            'prop1' => 'default value 1',
            'prop2' => 'default value 2',
        ];
    }
}

// somewhere in a test

// will create ['prop1' => 'foo', 'prop2' => 'default value 2']
$array = SomeArrayFactory::createOne(['prop1' => 'foo']);

Using with DoctrineFixturesBundle

Foundry works out of the box with DoctrineFixturesBundle. You can simply use your factories and stories right within your fixture files:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/DataFixtures/AppFixtures.php
namespace App\DataFixtures;

use App\Factory\CategoryFactory;
use App\Factory\CommentFactory;
use App\Factory\PostFactory;
use App\Factory\TagFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager)
    {
        // create 10 Category's
        CategoryFactory::createMany(10);

        // create 20 Tag's
        TagFactory::createMany(20);

        // create 50 Post's
        PostFactory::createMany(50, function() {
            return [
                // each Post will have a random Category (chosen from those created above)
                'category' => CategoryFactory::random(),

                // each Post will have between 0 and 6 Tag's (chosen from those created above)
                'tags' => TagFactory::randomRange(0, 6),

                // each Post will have between 0 and 10 Comment's that are created new
                'comments' => CommentFactory::new()->range(0, 10),
            ];
        });
    }
}

Run the doctrine:fixtures:load as normal to seed your database.

Using in your Tests

Traditionally, data fixtures are defined in one or more files outside of your tests. When writing tests using these fixtures, your fixtures are a sort of a black box. There is no clear connection between the fixtures and what you are testing.

Foundry allows each individual test to fully follow the AAA ("Arrange", "Act", "Assert") testing pattern. You create your fixtures using "factories" at the beginning of each test. You only create fixtures that are applicable for the test. Additionally, these fixtures are created with only the attributes required for the test - attributes that are not applicable are filled with random data. The created fixture objects are wrapped in a "proxy" that helps with pre and post assertions.

Let's look at an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public function test_can_post_a_comment(): void
{
    // 1. "Arrange"
    $post = PostFactory::new() // New Post factory
        ->published()          // Make the post in a "published" state
        ->create([             // Instantiate Post object and persist
            'slug' => 'post-a' // This test only requires the slug field - all other fields are random data
        ])
    ;

    // 1a. "Pre-Assertions"
    $this->assertCount(0, $post->getComments());

    // 2. "Act"
    static::ensureKernelShutdown(); // Note kernel must be shutdown if you use factories before create client
    $client = static::createClient();
    $client->request('GET', '/posts/post-a'); // Note the slug from the arrange step
    $client->submitForm('Add', [
        'comment[name]' => 'John',
        'comment[body]' => 'My comment',
    ]);

    // 3. "Assert"
    self::assertResponseRedirects('/posts/post-a');

    $this->assertCount(1, $post->_refresh()->getComments()); // Refresh $post from the database and call ->getComments()

    CommentFactory::assert()->exists([ // Doctrine repository assertions
        'name' => 'John',
        'body' => 'My comment',
    ]);

    CommentFactory::assert()->count(2, ['post' => $post]); // assert given $post has 2 comments
}

Enable Foundry in your TestCase

Add the Factories trait for tests using factories:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use App\Factory\PostFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Test\Factories;

class MyTest extends WebTestCase
{
    use Factories;

    public function test_1(): void
    {
        $post = PostFactory::createOne();

        // ...
    }
}

Database Reset

This library requires that your database be reset before each test. The packaged ResetDatabase trait handles this for you.

1
2
3
4
5
6
7
8
9
10
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

class MyTest extends WebTestCase
{
    use ResetDatabase, Factories;

    // ...
}

Before the first test using the ResetDatabase trait, it drops (if exists) and creates the test database. Then, by default, before each test, it resets the schema using doctrine:schema:drop/doctrine:schema:create.

Tip

Create a base TestCase for tests using factories to avoid adding the traits to every TestCase.

Tip

If your tests are not persisting the objects they create, the ResetDatabase trait is not required.

By default, ResetDatabase resets the default configured connection's database and default configured object manager's schema. To customize the connection's and object manager's to be reset (or reset multiple connections/managers), use the bundle's configuration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        database_resetter:
            orm:
                connections:
                    - orm_connection_1
                    - orm_connection_2
                object_managers:
                    - orm_object_manager_1
                    - orm_object_manager_2
                reset_mode: schema # default value, enables resetting the schema with doctrine:schema commands
            mongo:
                object_managers:
                    - odm_object_manager_1
                    - odm_object_manager_2

Resetting using migrations

Alternatively, you can have it run your migrations instead by modifying the orm.reset.mode option in configuration file. When using this mode, before each test, the database is dropped/created and your migrations run (via doctrine:migrations:migrate). This mode can really make your test suite slow (especially if you have a lot of migrations). It is highly recommended to use DamaDoctrineTestBundle to improve the speed. When this bundle is enabled, the database is dropped/created and migrated only once for the suite.

Additionally, it is possible to provide configuration files to be used by the migrations. The configuration files can be in any format supported by Doctrine Migrations (php, xml, json, yml). Then then command doctrine:migrations:migrate will run as many times as the number of configuration files.

1
2
3
4
5
6
7
8
9
10
11
12
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        database_resetter:
            orm:
                reset_mode: migrate # enables resetting with migrations

                # optional: allows you to pass additional configuration to the doctrine:migrations:migrate command
                migrations:
                    configurations:
                        - '%kernel.root_dir%/migrations/configuration.php'
                        - 'migrations/configuration.yaml'

Extending reset mechanism

The reset mechanism can be extended thanks to decoration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\When;
use Symfony\Component\HttpKernel\KernelInterface;
use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter;

// The decorator should be declared in test environment only.
#[When('test')]
// You can also decorate `MongoResetter::class`.
#[AsDecorator(OrmResetter::class)]
final readonly class DecorateDatabaseResetter implements OrmResetter
{
    public function __construct(
        private OrmResetter $decorated
    ) {}

    public function resetBeforeFirstTest(KernelInterface $kernel): void
    {
        // do something once per test suite (for instance: install a PostgreSQL extension)

        $this->decorated->resetBeforeFirstTest($kernel);
    }

    public function resetBeforeEachTest(KernelInterface $kernel): void
    {
        // do something once per test case (for instance: restart PostgreSQL sequences)

        $this->decorated->resetBeforeEachTest($kernel);
    }
}

If using a standard Symfony Flex app, this will be autowired/autoconfigured. If not, register the service

Object Proxy

Objects created by a factory are wrapped in a special Proxy object. These objects allow your doctrine entities to have Active Record like behavior:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use App\Factory\PostFactory;

$post = PostFactory::createOne(['title' => 'My Title']); // instance of Zenstruck\Foundry\Proxy

// get the wrapped object
$realPost = $post->_real(); // instance of Post

// call any Post method
$post->getTitle(); // "My Title"

// set property and save to the database
$post->setTitle('New Title');
$post->_save();

// refresh from the database
$post->_refresh();

// delete from the database
$post->_delete();

$post->_repository(); // repository proxy wrapping PostRepository (see Repository Proxy section below)

Force Setting

Object proxies have helper methods to access non-public properties of the object they wrap:

1
2
3
4
5
// set private/protected properties
$post->_set('createdAt', new \DateTime());

// get private/protected properties
$post->_get('createdAt');

Auto-Refresh

Object proxies have the option to enable auto refreshing that removes the need to call ->_refresh() before calling methods on the underlying object. When auto-refresh is enabled, most calls to proxy objects first refresh the wrapped object from the database. This is mainly useful with "integration" test which interacts with your database and Symfony's kernel.

1
2
3
4
5
6
7
8
9
10
use App\Factory\PostFactory;

$post = PostFactory::new(['title' => 'Original Title'])
    ->create()
    ->_enableAutoRefresh()
;

// ... logic that changes the $post title to "New Title" (like your functional test)

$post->getTitle(); // "New Title" (equivalent to $post->_refresh()->getTitle())

Without auto-refreshing enabled, the above call to $post->getTitle() would return "Original Title".

Note

A situation you need to be aware of when using auto-refresh is that all methods refresh the object first. If changing the object's state via multiple methods (or multiple force-sets), an "unsaved changes" exception will be thrown:

1
2
3
4
5
6
7
8
9
10
11
use App\Factory\PostFactory;

    $post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body'])
        ->create()
        ->_enableAutoRefresh()
    ;

    $post->setTitle('New Title');
    $post->setBody('New Body'); // exception thrown because of "unsaved changes" to $post from above

To overcome this, you need to first disable auto-refreshing, then re-enable after making/saving the changes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use App\Entity\Post;
use App\Factory\PostFactory;

$post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body'])
    ->create()
    ->_enableAutoRefresh()
;

$post->_disableAutoRefresh();
$post->setTitle('New Title'); // or using ->forceSet('title', 'New Title')
$post->setBody('New Body'); // or using ->forceSet('body', 'New Body')
$post->_enableAutoRefresh();
$post->save();

$post->getBody(); // "New Body"
$post->getTitle(); // "New Title"

// alternatively, use the ->_withoutAutoRefresh() helper which first disables auto-refreshing, then re-enables after
// executing the callback.
$post->_withoutAutoRefresh(function (Post $post) { // can pass either Post or Proxy to the callback
    $post->setTitle('New Title');
    $post->setBody('New Body');
});
$post->_save();

Proxy objects pitfalls

Proxified objects may have some pitfalls when dealing with Doctrine's entity manager. You may encounter this error:

> DoctrineORMORMInvalidArgumentException: A new entity was found through the relationship
'AppEntityPost#category' that was not configured to cascade persist operations for entity: AppEntityCategoryProxy@3082. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement 'AppEntityCategory#__toString()' to get a clue.

The problem will occur if a proxy has been passed to EntityManager::persist(). To fix this, you should pass the "real" object, by calling $proxyfiedObject->_real().

Factory without proxy

It is possible to create factories which do not create "proxified" objects. Instead of making your factory inherit from PersistentProxyObjectFactory, you can inherit from PersistentObjectFactory. Your factory will then directly return the "real" object, which won't be wrapped by `Proxy` class.

Warning

Be aware that your object won't refresh automatically if they are not wrapped with a proxy.

Repository Proxy

This library provides a Repository Proxy that wraps your object repositories to provide useful assertions and methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use App\Entity\Post;
use App\Factory\PostFactory;
use function Zenstruck\Foundry\Persistence\repository;

// instance of RepositoryProxy that wraps PostRepository
$repository = PostFactory::repository();

// alternative to above for proxying repository you haven't created factories for
$repository = repository(Post::class);

// helpful methods - all returned object(s) are proxied
$repository->inner(); // the real "wrapped" repository
$repository->count(); // number of rows in the database table
count($repository); // equivalent to above (RepositoryProxy implements \Countable)
$repository->first(); // get the first object (assumes an auto-incremented "id" column)
$repository->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object
$repository->last(); // get the last object (assumes an auto-incremented "id" column)
$repository->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object
$repository->truncate(); // delete all rows in the database table
$repository->random(); // get a random object
$repository->random(['author' => 'kevin']); // get a random object filtered by the passed criteria
$repository->randomSet(5); // get 5 random objects
$repository->randomSet(5, ['author' => 'kevin']); // get 5 random objects filtered by the passed criteria
$repository->randomRange(0, 5); // get 0-5 random objects
$repository->randomRange(0, 5, ['author' => 'kevin']); // get 0-5 random objects filtered by the passed criteria

// instance of ObjectRepository - all returned object(s) are proxied
$repository->find(1); // Proxy|Post|null
$repository->find(['title' => 'My Title']); // Proxy|Post|null
$repository->findOneBy(['title' => 'My Title']); // Proxy|Post|null
$repository->findAll(); // Proxy[]|Post[]
iterator_to_array($repository); // equivalent to above (RepositoryProxy implements \IteratorAggregate)
$repository->findBy(['title' => 'My Title']); // Proxy[]|Post[]

// can call methods on the underlying repository - returned object(s) are proxied
$repository->findOneByTitle('My Title'); // Proxy|Post|null

Assertions

Both object proxies and your Factory have helpful PHPUnit assertions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use App\Factory\PostFactory;

$post = PostFactory::createOne();
$post->_assertPersisted();
$post->_assertNotPersisted();

PostFactory::assert()->empty();
PostFactory::assert()->count(3);
PostFactory::assert()->countGreaterThan(3);
PostFactory::assert()->countGreaterThanOrEqual(3);
PostFactory::assert()->countLessThan(3);
PostFactory::assert()->countLessThanOrEqual(3);
PostFactory::assert()->exists(['title' => 'My Title']);
PostFactory::assert()->notExists(['title' => 'My Title']);

Global State

If you have an initial database state you want for all tests, you can set this in the config of the bundle. Accepted values are: stories as service, "global" stories and invokable services. Global state is loaded before each using the ResetDatabase trait. If you are using DamaDoctrineTestBundle, it is only loaded once for the entire test suite.

1
2
3
4
5
6
7
8
# config/packages/zenstruck_foundry.yaml
when@test: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        global_state:
            - App\Story\StoryThatIsAService
            - App\Story\GlobalStory
            - invokable.service # just a service with ::invoke()
            - ...

Note

You can still access Story State for Global State Stories in your tests and they are still only loaded once.

Note

The ResetDatabase trait is required when using global state.

Warning

Be aware that a complex global state could slow down your test suite.

PHPUnit Data Providers

It is possible to use factories in PHPUnit data providers. Their usage depends on whether you're using Foundry's PHPUnit Extension or not.:

With PHPUnit Extension

2.2

The ability to call Factory::create() in data providers was introduced in Foundry 2.2.

Warning

You will need at least PHPUnit 11.4 to call Factory::create() in your data providers.

Thanks to Foundry's PHPUnit Extension, you'll be able to use your factories in your data providers the same way you're using them in tests. Thanks to it, you can: Call ->create() or ::createOne() or any other method which creates objects in unit tests (using PHPUnit\Framework\TestCase) and functional tests (Symfony\Bundle\FrameworkBundle\Test\KernelTestCase) Use Factories as Services in functional tests * Use `faker()` normally, without wrapping its call in a callable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use App\Factory\PostFactory;
use PHPUnit\Framework\Attributes\DataProvider;

#[DataProvider('createMultipleObjectsInDataProvider')]
public function test_post_via_data_provider(Post $post): void
{
    // at this point, `$post` exists, and is already stored in database
}

public static function postDataProvider(): iterable
{
    yield [PostFactory::createOne()];
    yield [PostWithServiceFactory::createOne()];
    yield [PostFactory::createOne(['body' => faker()->sentence()];
}

Warning

Because Foundry is relying on its Proxy mechanism, when using persistence, your factories must extend Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory to work in your data providers.

Warning

For the same reason, you should not call methods from `Proxy` class in your data providers, not even ->_real().

Without PHPUnit Extension

Data providers are computed early in the phpunit process before Foundry is booted. Be sure your data provider returns only instances of Factory and you do not try to call ->create() on them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use App\Factory\PostFactory;

/**
 * @dataProvider postDataProvider
 */
public function test_post_via_data_provider(PostFactory $factory): void
{
    $post = $factory->create();

    // ...
}

public static function postDataProvider(): iterable
{
    yield [PostFactory::new()];
    yield [PostFactory::new()->published()];
}

Note

For the same reason as above, it is not possible to use Factories as Services with required constructor arguments (the container is not yet available).

Note

Still for the same reason, if Faker is needed along with ->with() within a data provider, you'll need to pass attributes as a callable.

Given the data provider of the previous example, here is PostFactory::published()

1
2
3
4
5
6
7
8
9
10
11
12
public function published(): self
{
    // This won't work in a data provider!
    // return $this->with(['published_at' => self::faker()->dateTime()]);

    // use this instead:
    return $this->with(
        static fn() => [
            'published_at' => self::faker()->dateTime()
        ]
    );
}

Tip

ObjectFactory::new()->many() and ObjectFactory::new()->sequence() return a special FactoryCollection object which can be used to generate data providers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use App\Factory\PostFactory;

    /**
     * @dataProvider postDataProvider
     */
    public function test_post_via_data_provider(PostFactory $factory): void
    {
        $factory->create();

        // ...
    }

    public static function postDataProvider(): iterable
    {
        yield from PostFactory::new()->sequence(
            [
                ['title' => 'foo'],
                ['title' => 'bar'],
            ]
        )->asDataProvider();
    }

The ``FactoryCollection`` could also be passed directly to the test case in order to have several objects available in the same test:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use App\Factory\PostFactory;

/**
 * @dataProvider postDataProvider
 */
public function test_post_via_data_provider(FactoryCollection $factoryCollection): void
{
    $factoryCollection->create();

    // ...
}

public static function postDataProvider(): iterable
{
    // 3 posts will be created for the first test case
    yield PostFactory::new()->sequence(
        [
            ['title' => 'foo 1'],
            ['title' => 'bar 1'],
            ['title' => 'baz 1'],
        ]
    );

    // 2 posts will be created for the second test case
    yield PostFactory::new()->sequence(
        [
            ['title' => 'foo 2'],
            ['title' => 'bar 2'],
        ]
    );
}

Performance

The following are possible options to improve the speed of your test suite.

DAMADoctrineTestBundle

This library integrates seamlessly with DAMADoctrineTestBundle to wrap each test in a transaction which dramatically reduces test time. This library's test suite runs 5x faster with this bundle enabled.

Follow its documentation to install. Foundry's ResetDatabase trait detects when using the bundle and adjusts accordingly. Your database is still reset before running your test suite but the schema isn't reset before each test (just the first).

Note

If using Global State, it is persisted to the database (not in a transaction) before your test suite is run. This could further improve test speed if you have a complex global state.

Caution

Using Global State that creates both ORM and ODM factories when using DAMADoctrineTestBundle is not supported.

paratestphp/paratest

You can use paratestphp/paratest to run your tests in parallel. This can dramatically improve test speed. The following considerations need to be taken into account:

  1. Your doctrine package configuration needs to have paratest's TEST_TOKEN environment variable in the database name. This is so each parallel process has its own database. For example:

    1
    2
    3
    4
    5
    # config/packages/doctrine.yaml
    when@test:
        doctrine:
            dbal:
                dbname_suffix: '_test%env(default::TEST_TOKEN)%'
  2. If using DAMADoctrineTestBundle and paratestphp/paratest < 7.0, you need to set the --runner option to WrapperRunner. This is so the database is reset once per process (without this option, it is reset once per test class).

    1
    vendor/bin/paratest --runner WrapperRunner
  3. If running with debug mode disabled, you need to adjust the Disable Debug Mode code to the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // tests/bootstrap.php
    // ...
    if (false === (bool) $_SERVER['APP_DEBUG'] && null === ($_SERVER['TEST_TOKEN'] ?? null)) {
        /*
         * Ensure a fresh cache when debug mode is disabled. When using paratest, this
         * file is required once at the very beginning, and once per process. Checking that
         * TEST_TOKEN is not set ensures this is only run once at the beginning.
         */
        (new Filesystem())->remove(__DIR__.'/../var/cache/test');
    }

Disable Debug Mode

In your .env.test file, you can set APP_DEBUG=0 to have your tests run without debug mode. This can speed up your tests considerably. You will need to ensure you cache is cleared before running the test suite. The best place to do this is in your tests/bootstrap.php:

1
2
3
4
5
6
// tests/bootstrap.php
// ...
if (false === (bool) $_SERVER['APP_DEBUG']) {
    // ensure fresh cache
    (new Symfony\Component\Filesystem\Filesystem())->remove(__DIR__.'/../var/cache/test');
}

Reduce Password Encoder Work Factor

If you have a lot of tests that work with encoded passwords, this will cause these tests to be unnecessarily slow. You can improve the speed by reducing the work factor of your encoder:

1
2
3
4
5
6
7
8
9
# config/packages/test/security.yaml
encoders:
    # use your user class name here
    App\Entity\UserForPersistentFactory:
        # This should be the same value as in config/packages/security.yaml
        algorithm: auto
        cost: 4 # Lowest possible value for bcrypt
        time_cost: 3 # Lowest possible value for argon
        memory_cost: 10 # Lowest possible value for argon

Pre-Encode Passwords

Pre-encode user passwords with a known value via bin/console security:encode-password and set this in defaults(). Add the known value as a const on your factory:

1
2
3
4
5
6
7
8
9
10
11
12
class UserFactory extends PersistentProxyObjectFactory
{
    public const DEFAULT_PASSWORD = '1234'; // the password used to create the pre-encoded version below

    protected function defaults(): array
    {
        return [
            // ...
            'password' => '$argon2id$v=19$m=65536,t=4,p=1$pLFF3D2gnvDmxMuuqH4BrA$3vKfv0cw+6EaNspq9btVAYc+jCOqrmWRstInB2fRPeQ',
        ];
    }
}

Now, in your tests, when you need access to the unencoded password for a user created with UserFactory, use UserFactory::DEFAULT_PASSWORD.

Non-Kernel Tests

Foundry can be used in standard PHPUnit unit tests (TestCase's that just extend PHPUnit\Framework\TestCase and not Symfony\Bundle\FrameworkBundle\Test\KernelTestCase). These tests still require using the Factories trait to boot Foundry but will not have doctrine available. Factories created in these tests will not be persisted (calling ->withoutPersisting() is not necessary). Because the bundle is not available in these tests, any bundle configuration you have will not be picked up.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use App\Factory\PostFactory;
use PHPUnit\Framework\TestCase;
use Zenstruck\Foundry\Test\Factories;

class MyUnitTest extends TestCase
{
    use Factories;

    public function some_test(): void
    {
        $post = PostFactory::createOne();

        // $post is not persisted to the database
    }
}

You will need to configure manually Foundry. Unfortunately, this may mean duplicating your bundle configuration here.

1
2
3
4
5
6
7
8
9
// tests/bootstrap.php
// ...

Zenstruck\Foundry\Test\UnitTestConfig::configure(
    instantiator: Zenstruck\Foundry\Object\Instantiator::withoutConstructor()
        ->allowExtra()
        ->alwaysForce(),
    faker: Faker\Factory::create('fr_FR')
);

Note

Factories as Services and Stories as Services with required constructor arguments are not usable in non-Kernel tests. The container is not available to resolve their dependencies. The easiest work-around is to make the test an instance of Symfony\Bundle\FrameworkBundle\Test\KernelTestCase so the container is available.

Stories

Stories are useful if you find your test's arrange step is getting complex (loading lots of fixtures) or duplicating logic between tests and/or your dev fixtures. They are used to extract a specific database state into a story. Stories can be loaded in your fixtures and in your tests, they can also depend on other stories.

Create a story using the maker command:

1
$ php bin/console make:story Post

Note

Creates PostStory.php in src/Story, add --test flag to create in tests/Story.

Modify the build method to set the state for this story:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/Story/PostStory.php
namespace App\Story;

use App\Factory\CategoryFactory;
use App\Factory\PostFactory;
use App\Factory\TagFactory;
use Zenstruck\Foundry\Story;

final class PostStory extends Story
{
    public function build(): void
    {
        // create 10 Category's
        CategoryFactory::createMany(10);

        // create 20 Tag's
        TagFactory::createMany(20);

        // create 50 Post's
        PostFactory::createMany(50, function() {
            return [
                // each Post will have a random Category (created above)
                'category' => CategoryFactory::random(),

                // each Post will between 0 and 6 Tag's (created above)
                'tags' => TagFactory::randomRange(0, 6),
            ];
        });
    }
}

Use the new story in your tests, dev fixtures, or even other stories:

1
2
3
PostStory::load(); // loads the state defined in PostStory::build()

PostStory::load(); // does nothing - already loaded

Note

Objects persisted in stories are cleared after each test (unless it is a Global State Story).

Stories as Services

If your stories require dependencies, you can define them as a service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/Story/PostStory.php
namespace App\Story;

use App\Factory\PostFactory;
use App\Service\ServiceA;
use App\Service\ServiceB;
use Zenstruck\Foundry\Story;

final class PostStory extends Story
{
    private $serviceA;
    private $serviceB;

    public function __construct(ServiceA $serviceA, ServiceB $serviceB)
    {
        $this->serviceA = $serviceA;
        $this->serviceB = $serviceB;
    }

    public function build(): void
    {
        // can use $this->serviceA, $this->serviceB here to help build this story
    }
}

If using a standard Symfony Flex app, this will be autowired/autoconfigured. If not, register the service and tag with foundry.story.

Story State

Another feature of stories is the ability for them to remember the objects they created to be referenced later:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Story/CategoryStory.php
namespace App\Story;

use App\Factory\CategoryFactory;
use Zenstruck\Foundry\Story;

final class CategoryStory extends Story
{
    public function build(): void
    {
        $this->addState('php', CategoryFactory::createOne(['name' => 'php']));

        // factories are created when added as state
        $this->addState('symfony', CategoryFactory::new(['name' => 'symfony']));
    }
}

Later, you can access the story's state when creating other fixtures:

1
2
3
4
PostFactory::createOne(['category' => CategoryStory::get('php')]);

// or use the magic method (functionally equivalent to above)
PostFactory::createOne(['category' => CategoryStory::php()]);

Tip

Unlike factories, stories are not tied to a specific type, and then they cannot be generic, but you can leverage the magic method and PHPDoc to improve autocompletion and fix static analysis issues with stories:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Story/CategoryStory.php
namespace App\Story;

use App\Factory\CategoryFactory;
use Zenstruck\Foundry\Persistence\Proxy;
use Zenstruck\Foundry\Story;

/**
 * @method static Category&Proxy<Category> php()
 */
final class CategoryStory extends Story
{
    public function build(): void
    {
        $this->addState('php', CategoryFactory::createOne(['name' => 'php']));
    }
}

Now your IDE will know CategoryStory::php() returns an object of type Category.

Using a magic method also does not require a prior ::load() call on the story, it will initialize itself.

Note

Story state is cleared after each test (unless it is a Global State Story).

Story Pools

Stories can store (as state) pools of objects:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/Story/ProvinceStory.php
namespace App\Story;

use App\Factory\ProvinceFactory;
use Zenstruck\Foundry\Story;

final class ProvinceStory extends Story
{
    public function build(): void
    {
        // add collection to a "pool"
        $this->addToPool('be', ProvinceFactory::createMany(5, ['country' => 'BE']));

        // equivalent to above
        $this->addToPool('be', ProvinceFactory::new(['country' => 'BE'])->many(5));

        // add single object to a pool
        $this->addToPool('be', ProvinceFactory::createOne(['country' => 'BE']));

        // add single object to single pool and make available as "state"
        $this->addState('be-1', ProvinceFactory::createOne(['country' => 'BE']), 'be');
    }
}

Objects can be fetched from pools in your tests, fixtures or other stories:

1
2
3
4
ProvinceStory::getRandom('be'); // random Province|Proxy from "be" pool
ProvinceStory::getRandomSet('be', 3); // 3 random Province|Proxy's from "be" pool
ProvinceStory::getRandomRange('be', 1, 4); // between 1 and 4 random Province|Proxy's from "be" pool
ProvinceStory::getPool('be'); // all Province|Proxy's from "be" pool

#[WithStory] Attribute

2.3

The `#[WithStory]` attribute was added in Foundry 2.3.

Warning

The PHPUnit Extension for Foundry is needed to use #[WithStory] attribute.

You can use the #[WithStory] attribute to load stories in your tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use App\Story\CategoryStory;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Foundry\Attribute\WithStory;

// You can use the attribute on the class...
#[WithStory(CategoryStory::class)]
final class NeedsCategoriesTest extends KernelTestCase
{
    // ... or on the method
    #[WithStory(CategoryStory::class)]
    public function testThatNeedStories(): void
    {
        // ...
    }
}

If used on the class, the story will be loaded before each test method.

Static Analysis

Psalm

A Psalm extension is shipped with the library. Please, enable it with:

1
$ vendor/bin/psalm-plugin enable zenstruck/foundry

PHPUnit Extension

Foundry is shipped with an extension for PHPUnit. You can install it by modifying the file phpunit.xml.dist:

1
2
3
4
5
<phpunit>
    <extensions>
        <bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension"/>
    </extensions>
</phpunit>

This extension provides the following features: - support for the #[WithStory] Attribute - ability to use Factory::create() in PHPUnit Data Providers (along with PHPUnit ^11.4)

2.2

The PHPUnit extension was introduced in Foundry 2.2.

Warning

The PHPUnit extension is only compatible with PHPUnit 10+.

Bundle Configuration

Since the bundle is intended to be used in your dev and test environments, you'll want the configuration for each environment to match. The easiest way to do this is to use YAML anchors with when@dev/when@test. This way, there is just one place to set your config.

1
2
3
4
5
6
# config/packages/zenstruck_foundry.yaml
when@dev: &dev
    zenstruck_foundry:
        # ... put all your config here

when@test: *dev # "copies" the config from above

Full Default Bundle Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
zenstruck_foundry:

    # Configure faker to be used by your factories.
    faker:

        # Change the default faker locale.
        locale:               null # Example: fr_FR

        # Random number generator seed to produce the same fake values every run
        seed:                 null # Example: '1234'

        # Customize the faker service.
        service:              null # Example: my_faker

    # Configure the default instantiator used by your factories.
    instantiator:

        # Use the constructor to instantiate objects.
        use_constructor:      ~

        # Whether or not to allow extra attributes.
        allow_extra_attributes: false

        # Whether or not to skip setters and force set object properties (public/private/protected) directly.
        always_force_properties: false

        # Customize the instantiator service.
        service:              null # Example: my_instantiator
    orm:
        reset:

            # DBAL connections to reset with ResetDatabase trait
            connections:

                # Default:
                - default

            # Entity Managers to reset with ResetDatabase trait
            entity_managers:

                # Default:
                - default

            # Reset mode to use with ResetDatabase trait
            mode:                 schema # One of "schema"; "migrate"
            migrations:

                # Migration configurations
                configurations:       []

    mongo:
        reset:

            # Document Managers to reset with ResetDatabase trait
            document_managers:

                # Default:
                - default

    # Array of stories that should be used as global state.
    global_state:         []

    make_factory:

        # Default namespace where factories will be created by maker.
        default_namespace:    Factory
    make_story:

        # Default namespace where stories will be created by maker.
        default_namespace:    Story
This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version