Test Data and Isolation
Give tests their own database, load known rows with fixtures, and reset state so no test depends on another running first.
php bin/console --env=test doctrine:fixtures:load --no-interactionA test that reads the database needs to know what is in it. Two problems follow: getting known rows in, and getting them out again before the next test.
A database of its own
Never point tests at your development database. Set DATABASE_URL in .env.test to a separate name, create it once with php bin/console --env=test doctrine:database:create, then build the schema by running your migrations against it with --env=test. Running migrations rather than doctrine:schema:create also tests the migrations.
Fixtures
A fixture is a class that writes a known set of rows. Install doctrine/doctrine-fixtures-bundle, extend Doctrine\Bundle\FixturesBundle\Fixture, and build entities in load(ObjectManager $manager). persist() schedules an object; flush() writes everything scheduled. A fixture that never flushes saves nothing.
Fixtures that need each other's rows share them by reference: $this->addReference('editor', $user) in one, $this->getReference('editor', User::class) in another, with DependentFixtureInterface::getDependencies() fixing the load order.
Resetting between tests
Reloading fixtures before every test method is correct and slow. dama/doctrine-test-bundle is the usual answer: registered as a PHPUnit extension, it opens a transaction before each test and rolls it back afterwards, so writes vanish without a reload.
Why order matters
If test B passes only because test A created a row, the suite is lying. It breaks the day someone reorders a file, runs one test alone, or turns on parallel execution. Each test must set up what it needs and assume nothing else ran.
Example
<?php
// src/DataFixtures/ArticleFixtures.php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Article;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
final class ArticleFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
// The row UserFixtures created, fetched by the name it registered.
$author = $this->getReference(UserFixtures::EDITOR, User::class);
// Fixed dates, so an assertion on ordering is the same every run.
$dates = [
'First' => '2026-01-01 09:00:00',
'Second' => '2026-02-01 09:00:00',
];
foreach ($dates as $title => $publishedAt) {
$article = new Article();
$article->setTitle($title);
$article->setAuthor($author);
$article->setPublishedAt(new \DateTimeImmutable($publishedAt));
// persist() only schedules the insert.
$manager->persist($article);
}
// flush() is what actually writes. Without it, nothing is saved.
$manager->flush();
}
/**
* @return list<class-string>
*/
public function getDependencies(): array
{
return [UserFixtures::class];
}
}
When to use it
- Create a test database and schema from scratch in a CI job.
- Give every test the same two users and two articles to assert against.
- Stop a test suite from failing when PHPUnit runs the files in a different order.
- Speed up a slow repository suite by rolling back instead of reloading fixtures.
More examples
The fixture that owns the shared user
Fixtures are services, so dependencies arrive through the constructor; here that is the password hasher, because a user whose password is stored unhashed cannot log in and a login test would fail for the wrong reason. The reference constants are what let ArticleFixtures attach articles to a known author without querying for it.
<?php
// src/DataFixtures/UserFixtures.php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class UserFixtures extends Fixture
{
// A constant, not a loose string, so a typo is a compile-time problem.
public const EDITOR = 'user-editor';
public const ADMIN = 'user-admin';
public function __construct(
private readonly UserPasswordHasherInterface $hasher,
) {
}
public function load(ObjectManager $manager): void
{
$people = [
self::EDITOR => ['[email protected]', ['ROLE_EDITOR']],
self::ADMIN => ['[email protected]', ['ROLE_ADMIN']],
];
foreach ($people as $reference => [$email, $roles]) {
$user = new User();
$user->setEmail($email);
$user->setRoles($roles);
$user->setPassword($this->hasher->hashPassword($user, 'test-password'));
$manager->persist($user);
// Let other fixtures ask for this exact row.
$this->addReference($reference, $user);
}
$manager->flush();
}
}
Wiring the test database and the rollback
Running migrations against the test database instead of doctrine:schema:create means a broken migration fails your suite rather than production. These three commands belong in a script your CI job calls, so a fresh checkout reaches a usable state with one step. Note the purge behaviour: doctrine:fixtures:load is destructive by default.
# .env.test
# A different database name from development. The suffix is the point.
DATABASE_URL="postgresql://app:[email protected]:5432/app_test?serverVersion=17&charset=utf8"
# ---------------------------------------------------------------
# One-time setup, and after any new migration:
#
# php bin/console --env=test doctrine:database:create --if-not-exists
# php bin/console --env=test doctrine:migrations:migrate --no-interaction
# php bin/console --env=test doctrine:fixtures:load --no-interaction
#
# doctrine:fixtures:load purges every table first unless you pass
# --append. That is why it must never run against development.
# ---------------------------------------------------------------
Rolling back instead of reloading
With this extension registered you load fixtures once, then every test starts from that same state and its writes are discarded. The fail-on flags are worth turning on at the same time: they make a deprecation notice break the build while the upgrade is cheap, instead of surfacing as a fatal error on the next major version.
<!-- phpunit.dist.xml -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true">
<!-- Wraps every test in a transaction and rolls it back afterwards.
Install with: composer require --dev dama/doctrine-test-bundle -->
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Discussion