The blog

Foundry 2 is now available: new features and migration path

Published on June 25, 2024

Foundry 2 has just been released! Let’s explore the new features of this version and the techniques to ensure a smooth migration. The French version of this blog post is also available on our website.

#

What is Foundry? 

Foundry is a fixture generator for Symfony that integrates particularly well with Doctrine (ORM and ODM). It was created in 2020 by Kevin Bond.

Unlike libraries such as doctrine/data-fixtures or nelmio/alice, the philosophy of Foundry is to start from an empty database for each test and create a specific dataset with only the necessary data for the current test. This avoids the pitfalls inherent to a large global database for all tests, where any modification for a given test risks breaking another tests.

Foundry allows you to create "factories" for each entity (ORM) or document (ODM) or even for any DTO or Value Object whose creation you wish to delegate to Foundry.

Example of a factory:

final class BookFactory extends ModelFactory
{
   protected function getDefaults(): array
   {
       return [
           'title' => self::faker()->words(3),
           'author' => AuthorFactory::new()
       ];
   }


   protected static function getClass(): string
   {
       return Book::class;
   }
}

Example of the usage of this factory in a test:

final class BookTest extends KernelTestCase
{
   // needed to initialize Foundry
   use Factories;




   // Resets the database at the beginning of each test
   use ResetDatabase;


   #[Test]
   public function can_do_something_with_one_book(): void
   {
       // creates a “Book” and stores it in database
       // a “Author” will be created as well
       BookFactory::createOne();


       // uses a specific title and author
       BookFactory::createOne([
           'title' => 'Foundation',
           'author' => AuthorFactory::new(['name' => 'Asimov']),
       ]);


       // finds a persisted “Author” for the given attributes
       // or creates a new one in database
       BookFactory::createOne([
           'title' => 'Robots',
           'author' => AuthorFactory::findOrCreate(['name' => 'Asimov'])
       ]);


       // test something...
   }


   #[Test]
   public function can_do_something_with_several_books(): void
   {
       // creates three "Book" and three "Author" in database
       BookFactory::createMany(3);


       // creates three "Book" linked to the same “Author”
       $author = AuthorFactory::createOne(['name' => 'Isaac Asimov']);
       BookFactory::createMany(3, ['author' => $author]);




       // creates three “Book” with different titles
       AuthorFactory::createSequence([
           ['name' => 'Isaac Asimov'],
           ['name' => 'Valérie Despentes'],
           ['name' => 'Amin Maalouf'],
       ]);


       // test something...
   }
}

Foundry includes many other features. Feel free to consult its documentation to learn more.

#

Why a V2? 

#
The case for "proxy" objects

Foundry includes a proxy mechanism that allows interaction with the database directly from Doctrine objects via the Active Record pattern. The object creation methods return a Zenstruck\Foundry\Proxy object which acts as a "wrapper" around the created object:

/** @var Zenstruck\Foundry\Proxy<Book> */
$book = BookFactory::createOne();
dump($book::class); // "Zenstruck\Foundry\Proxy"


$realBook = $book->object();
dump($realBook::class); // "App\Entity\Book"


// calls the “real” method “setName()”
$book->setName('New name');


// updates the object in database
$book->save();


// update the object from database
$book->refresh();


// removes the object from database
$book->remove();

This mechanism, while very convenient (especially for auto-refresh), has several drawbacks:

  • It breaks the type system, as you get an instance of Zenstruck\Foundry\Proxy, and you always have to call the ->object() method if you want to pass your objects to methods with typed parameters (and our methods’ parameters ARE typed 😁).
unction takesBook(Book $book)
{
   // ...
}


$book = BookFactory::createOne();
takesBook($book); 💥
takesBook($book->object()); ✅

And it's even worse when handling lists of objects:

function takesBooks(Book ...$books)
{
   // ...
}


$books = BookFactory::createMany(3);
takesBooks(...$books); 💥
takesBooks(
   ...array_map(
       static fn(Proxy $book) => $book->object(),
       $books
   )
); ✅
  • If the class you want to create defines, for example, a save() method or an object() method or any public method of the Proxy class, there is a conflict between the entity’s methods and the Proxy class methods.

These two problems harm the DX and make the proxy mechanism implementation awkward, so this implementation had to evolve.

In Foundry 2, the proxy mechanism uses the ProxyHelper from the symfony/var-exporter component, introduced in Symfony 6. It is the same "battle-tested" mechanism used in Symfony to create "lazy" objects and in Doctrine to generate entity proxies.

This solution uses "real" proxies: the type returned by BookFactory::create() is now Book&Proxy<Book> (which is the intersection of the "Book" type and the generic "Proxy" type applied to the "Book" class). Consequently, the new mechanism no longer breaks the type system and it is no longer necessary to call the ->object() method everywhere!

To address the second issue, the methods of the Proxy class have been escaped with an underscore to avoid any collision, and/or renamed with more relevant names. Thus, object() becomes _real(), save() becomes _save(), and so on.

#
Foundry V1 is very (too much!) tied to Doctrine

Foundry was initially created solely to store objects in a database, making it heavily dependent on Doctrine ORM. Then ODM support was added, thanks to doctrine/persistence, but supporting another persistence mechanism without hacks everywhere would be very complex.

In Foundry 2, this strong dependency on Doctrine has been removed, and the logic has been reversed: while Foundry V1 was "Doctrine first", Foundry 2 is "object first".

// V1


// ModelFactory est now deprecated in the last version of Foundry V1
final class BookFactory extends ModelFactory {}


// V2


// useful to create simple objects
final class BookFactory extends ObjectFactory {}


// creates “persistable” objects, without Proxy
final class BookFactory extends PersistentObjectFactory {}


// same behavior of the old “ModelFactory”
final class BookFactory extends PersistentProxyObjectFactory {}

It is now possible to inherit from one of the abstract classes offered by Foundry to obtain different functionalities.

This inversion of Foundry’s internal architecture has also allowed us to provide an abstract ArrayFactory class to benefit from Foundry's DX for creating associative arrays.

In the future, it will also be easier to integrate support for other persistence mechanisms or even implement an “in-memory” mechanism to improve our test performance.

#
Syntax Improvements

The switch to a V2 also helped to correct some awkward syntaxes and remove the many deprecations added throughout the development and evolution of V1.

#

"Migration Path" for V2

All these changes inevitably imply some “backward compatibility breaks” (or "BC breaks"), and even a lot of BC breaks!

> So, all my tests will fail when I switch to V2?

Not necessarily! Following Symfony’s compatibility promise, all changes involving BC breaks in V2 are covered by a compatibility layer in V1: all new methods in V2 are present in the latest V1 version, and all deprecated V1 mechanisms trigger E_USER_DEPRECATED errors, which will allow PHPUnit to notify you of the necessary changes to your code.

#
Steps to Follow
  • Update Foundry to the latest V1 version: V1.38.
$ composer update zenstruck/foundry
  • Run the tests with deprecation notices enabled.

If you use Symfony’s bridge for PHPUnit, you need to configure the "deprecation helper":

# .env.test
SYMFONY_DEPRECATIONS_HELPER="max[self]=0&amp;max[direct]=0&amp;quiet[]=indirect&amp;quiet[]=other"

or

<!-- phpunit.xml.dist -->
<phpunit>
   <php>
       <!-- ... -->
       <server name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0&amp;quiet[]=indirect&amp;quiet[]=other"/>
   </php


           <!-- ... -->
</phpunit>

This configuration will inform the Symfony PHPUnit bridge's deprecation helper that you want to display "direct" deprecations (those triggered by your code).

If you use PHPUnit version 10 or 11, the Symfony bridge is not compatible with these versions; you will have to rely on PHPUnit’s deprecation mechanism, which is less efficient than the Symfony bridge’s.

From PHPUnit 10 onwards, you need to add the attribute ignoreSuppressionOfDeprecations=true to the <source> node in the phpunit.xml.dist (or phpunit.xml) file and run the test suite with the --display-deprecations option.

  • Some deprecations are emitted during the compilation phase, so it might also be useful to run the following command:
$ bin/console debug:container --deprecations
  • Once you have collected all the deprecations, it is time to correct them!

Let’s see what this looks like on a project I am currently working on that heavily uses Foundry:

➜ vendor/bin/phpunit

...

OK (917 tests, 3553 assertions)

Remaining direct deprecation notices (5392) # 😱

The number of deprecations to correct obviously depends on the number of tests in the project and how Foundry is used in the project.

Let’s get to work! 😅

> Do I really have to manually correct thousands of deprecations?

That’s the question you must be asking yourself and that I asked myself when I saw the number of deprecations. The answer is of course "no"!

The latest version of Foundry V1 provides a set of Rector rules to facilitate the migration.To use these rules, you need to install Rector (as well as phpstan/phpstan-doctrine if not already installed):

$ composer require --dev rector/rector phpstan/phpstan-doctrine

Next, you will need to create the configuration file rector.php

use Rector\Config\RectorConfig;
use Zenstruck\Foundry\Utils\Rector\FoundrySetList;


return RectorConfig::configure()
   ->withPaths(['tests']) // add all paths where Foundry is used
   ->withSets([FoundrySetList::UP_TO_FOUNDRY_2]);

Finally, you can run Rector:

# you can run Rector un “dry run” mode, in order to review the changes
$ vendor/bin/rector process --dry-run

# actually updates the files
$ vendor/bin/rector process

Sometimes Rector does not fix all possible deprecations in a single pass, and it can be useful to run Rector a second time after clearing the cache:

$ vendor/bin/rector process –clear-cache

The main changes made by Rector are as follows:

  • Replace Zenstruck\Foundry\ModelFactory with Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory for ORM entities and ODM documents, or with \Zenstruck\Foundry\ObjectFactory for all other objects.
  • Remove calls to the Proxy::object() method when the target class is not "persistable".
  • Change all calls to deprecated methods and functions to their replacement methods or functions.
  • Replace all uses of deprecated classes with their replacement classes.

Note that Rector does not correct all deprecations (some complex cases are not considered). You will need to rerun your tests and manually correct the remaining deprecations. Once your tests no longer show deprecations, you can migrate to Foundry V2 with confidence.

PHPStan also allows you to ensure your code does not use deprecated code by using the phpstan/phpstan-deprecation-rules plugin. It’s an additional safety net to avoid unpleasant surprises during the V2 update, but it does require configuring PHPStan to analyze your test classes, which I strongly suggest you do.

You will find the complete list of syntax changes between V1 and V2 in the migration guide on the Foundry repository. Feel free to create an issue on Foundry’s GitHub if you observe problems in the Rector rules or BC breaks in Foundry V2.

Finally, I would like to thank Les-Tilleuls.coop and our client ARTE for allowing me to take the time to work on this open-source project!

The blog

Go further