The framework extends Doctrine's fixture system with a SQL-first approach. Instead of creating entities programmatically in PHP, fixtures execute raw SQL directly — inserting data, creating PostgreSQL functions, and registering triggers. This gives full control over the database state and makes it easy to set up complex database-level logic that Doctrine's ORM layer doesn't naturally express.
This is not a replacement for Doctrine's standard fixture system — it's built on top of it. Standard Doctrine\Bundle\FixturesBundle\Fixture classes work exactly as documented in Symfony's docs and can be used alongside or instead of the SQL-based approach. Use whatever fits the situation.
The base class for SQL-based fixtures. Extend it and implement the data methods — one for table data (required), one for PostgreSQL functions (optional), and one for triggers (optional):
abstract protected function loadTable(): array|string;
protected function loadFunctions(): array|string { return []; } // default returns []
protected function loadTriggers(): array|string { return []; } // default returns []
Only loadTable() is abstract — loadFunctions() and loadTriggers() have concrete return [] defaults so a fixture that only inserts data can leave them alone. Each method is declared array|string: return either a single SQL string (one statement) or a list of strings (multiple statements). The framework's AbstractDatabaseFixture::load() normalises both shapes into an array internally — concrete implementations never need to wrap a single statement in a one-element list themselves:
// Both are valid; the framework wraps the single string into [ $queries ] before execution.
protected function loadTable(): array|string { return 'INSERT INTO users ...'; }
protected function loadTable(): array|string { return [ 'INSERT INTO users ...', 'SELECT setval(...)' ]; }
All queries are executed via Doctrine's createNativeQuery() against whichever EntityManagerInterface is passed to load(ObjectManager $manager). Errors from any of the three steps throw RuntimeException with a clear message indicating which step failed.
SQL files are kept separate from PHP fixture classes for readability. The convention used in the template project:
Doctrine/
└── fixtures/
├── user_groups_prevent_any_changes_function.sql
├── user_groups_prevent_any_changes_trigger.sql
├── user_prevent_admin_deletion_function.sql
└── user_prevent_admin_deletion_trigger.sql
App/
└── DataFixtures/
├── Purgers/
│ ├── UserGroupPurger.php
│ └── UsersPurger.php
├── UserGroupFixtures.php
└── UserFixtures.php
SQL files live in Doctrine/fixtures/ at the project root. PHP fixture classes live in App/DataFixtures/. Purgers live in App/DataFixtures/Purgers/.
There is no enforced directory structure — put files wherever makes sense for your project. The important thing is that your fixture classes can locate the SQL files, which is typically done with __DIR__ relative paths or project_dir().
For simple data insertions, SQL can be written directly in the method:
// App/DataFixtures/UserGroupFixtures.php
namespace App\DataFixtures;
use App\Abstraction\Classes\AbstractDatabaseFixture;
final class UserGroupFixtures extends AbstractDatabaseFixture
{
protected function loadTable(): array|string
{
return <<<SQL
INSERT INTO user_groups (id, name)
VALUES
(-1, 'banned'),
(1, 'administrator'),
(3, 'moderator'),
(6, 'user');
SQL;
}
protected function loadFunctions(): array|string
{
return file_get_contents(
__DIR__ . '/../../Doctrine/fixtures/user_groups_prevent_any_changes_function.sql'
);
}
protected function loadTriggers(): array|string
{
return file_get_contents(
__DIR__ . '/../../Doctrine/fixtures/user_groups_prevent_any_changes_trigger.sql'
);
}
}
For complex functions and triggers, keeping SQL in dedicated .sql files is cleaner — syntax highlighting works, and the files can be version-controlled and reviewed independently of the PHP code:
// App/DataFixtures/UserFixtures.php
final class UserFixtures extends AbstractDatabaseFixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [
UserGroupFixtures::class,
];
}
protected function loadTable(): array|string
{
return [
sprintf(
"INSERT INTO users (id, email, password, user_group_id) VALUES (1, '%s', '%s', %s);",
env( 'ADMIN_EMAIL' ),
password_hash( env( 'ADMIN_PASSWORD' ), PASSWORD_DEFAULT ),
UserGroupEnum::ADMINISTRATOR->getId()
),
];
}
protected function loadFunctions(): array|string
{
return file_get_contents(
__DIR__ . '/../../Doctrine/fixtures/user_prevent_admin_deletion_function.sql'
);
}
protected function loadTriggers(): array|string
{
return file_get_contents(
__DIR__ . '/../../Doctrine/fixtures/user_prevent_admin_deletion_trigger.sql'
);
}
}
If a fixture only inserts data with no database-level logic, return an empty array from the unused methods:
protected function loadFunctions(): array|string
{
return [];
}
protected function loadTriggers(): array|string
{
return [];
}
-- Doctrine/fixtures/user_groups_prevent_any_changes_function.sql
CREATE OR REPLACE FUNCTION prevent_user_groups_modification()
RETURNS TRIGGER AS
$$
BEGIN
RAISE EXCEPTION 'Modifying the "user_groups" table is not allowed.';
END;
$$
LANGUAGE plpgsql;
-- Doctrine/fixtures/user_groups_prevent_any_changes_trigger.sql
CREATE TRIGGER prevent_user_groups_modification
BEFORE INSERT OR UPDATE OR DELETE ON user_groups
FOR EACH STATEMENT
EXECUTE FUNCTION prevent_user_groups_modification();
This pattern of locking the user_groups table with a trigger is deliberate — user groups are a fixed lookup table defined by fixtures. The trigger prevents anyone (including a future developer making a mistake) from accidentally modifying them through ORM operations or direct SQL.
Implement DependentFixtureInterface to declare that a fixture requires another to run first:
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
final class UserFixtures extends AbstractDatabaseFixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [
UserGroupFixtures::class,
];
}
// ...
}
UserFixtures inserts a user with a user_group_id foreign key. UserGroupFixtures must run first or the insert will fail with a foreign key constraint violation. Declaring the dependency ensures the fixture loader orders execution correctly regardless of file discovery order.
Dependencies can chain — if OrderFixtures depends on CustomerFixtures which depends on UserFixtures, the loader resolves the full chain automatically.
Purgers clean the database before fixtures are loaded. Each purger handles one table or a related group of tables — dropping functions, dropping triggers, and truncating data in the correct order to respect foreign key constraints.
abstract class AbstractPurger implements ORMPurgerInterface, CustomPurgerInterface
{
abstract public function getEntityManagerName(): string;
/**
* @return array<string>
*/
abstract protected function getQueries(): array;
private EntityManagerInterface $em;
final public function purge(): void
{
foreach ( $this->getQueries() as $q )
$this->em
->createNativeQuery( $q, new ResultSetMappingBuilder( $this->em ) )
->execute();
}
final public function setEntityManager( EntityManagerInterface $em ): void
{
$this->em = $em;
}
}
AbstractPurger declares two abstract methods: getEntityManagerName(): string and getQueries(): array. The framework's LoadDataFixturesDoctrineCommand calls setEntityManager() once per purger with the EM matching its getEntityManagerName() value, and purge() then runs each query against that EM (not a hardcoded connection). Concrete purgers resolve their connection automatically by inspecting Doctrine metadata at construction — App/DataFixtures/Purgers/UserPurger.php walks ManagerRegistry::getManagerNames() (skipping dummy), finds the EM whose metadata owns the User entity, and stores that name for getEntityManagerName():
// App/DataFixtures/Purgers/UserPurger.php
final class UserPurger extends AbstractPurger
{
public function __construct( private readonly ManagerRegistry $registry )
{
foreach ( $registry->getManagerNames() as $name ) {
if ( $name === 'dummy' )
continue;
$em = $registry->getManager( $name );
if ( $em->getClassMetadata( User::class )->isSingleEntity ? false : true ) {
$this->emName = $name;
$this->quotedTable = $em->getClassMetadata( User::class )
->getQuotedTableName( $em->getConnection()->getDatabasePlatform() );
return;
}
}
throw new \RuntimeException( 'No entity manager found for User entity.' );
}
public function getEntityManagerName(): string
{
return $this->emName;
}
protected function getQueries(): array
{
return [ 'DELETE FROM ' . $this->quotedTable ];
}
}
CASCADE on DROP FUNCTION removes any triggers that depend on the function. CASCADE on TRUNCATE removes rows in dependent tables. Order matters — purge child tables before parent tables, or use CASCADE to let PostgreSQL handle dependencies.
Add RESTART IDENTITY whenever the table has a sequence that fixtures set explicitly (e.g. fixtures INSERT INTO … (id, …) with hardcoded primary keys, then advance the sequence via SELECT setval('…_id_seq', (SELECT MAX(id) FROM …))). Without RESTART IDENTITY, the next row inserted by ORM via nextval() will collide with an already-used ID after a re-purge. Tables that never have explicit IDs in fixtures (purely ORM-managed) only need TRUNCATE … CASCADE.
config/services.yaml declares an app.fixture_purger tag for any class implementing CustomPurgerInterface:
_instanceof:
App\Abstraction\Interfaces\CustomPurgerInterface:
tags: [ 'app.fixture_purger' ]
The tag is consumed — LoadDataFixturesDoctrineCommand injects purgers via #[AutowireIterator('app.fixture_purger')] iterable $purgers in its constructor, then iterates them per-EM in execute().
The template project ships with a custom fixture loader command that replaces Doctrine's built-in doctrine:fixtures:load:
php bin/console doctrine:fixtures:custom-loader
Internally it injects purgers via #[AutowireIterator('app.fixture_purger')] iterable $purgers, runs each purger whose getEntityManagerName() matches the requested EM (calling setEntityManager($em); $purger->purge(); for each), then wraps the fixtures in an ORMExecutor and calls $executor->execute( $fixtures, true ) — the second argument true is Doctrine's append flag, which skips the executor's built-in purge step (the manual purge loop has already handled it).
# Skip confirmation prompt
php bin/console doctrine:fixtures:custom-loader --force
php bin/console doctrine:fixtures:custom-loader -f
# Load only fixtures belonging to a specific group
php bin/console doctrine:fixtures:custom-loader --group=seed
php bin/console doctrine:fixtures:custom-loader --group=seed --group=baseline
# Specify entity manager
php bin/console doctrine:fixtures:custom-loader --em=main
Assign fixtures to groups by implementing getGroups() from Doctrine's FixtureGroupInterface:
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
final class TestEntityFixtures extends AbstractDatabaseFixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return [ 'test_data', 'baseline' ];
}
// ...
}
Run only fixtures in a specific group:
php bin/console doctrine:fixtures:custom-loader -f --group=test_data
This is how you run only some fixtures without running everything. There's no built-in "run a single fixture class" option in the command, but groups give you the same result — put the fixture you want to run in a unique group and load that group.
php bin/console doctrine:fixtures:custom-loader -f
Runs all fixtures in dependency order, after the standard ORMPurgerFactory purger clears the target entity manager.
php bin/console doctrine:fixtures:custom-loader -f --group=seed
Only fixtures in that group are loaded.
The full reset sequence used by install.sh:
php bin/console doctrine:schema:drop -f
php bin/console doctrine:schema:create
php bin/console doctrine:fixtures:custom-loader -f
Nothing about this system prevents using standard Doctrine\Bundle\FixturesBundle\Fixture directly. If you prefer creating entities in PHP rather than writing SQL, or if a fixture doesn't need functions and triggers, use the standard approach:
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class ItemFixtures extends Fixture
{
public function load( ObjectManager $manager ): void
{
$items = [
[ 'name' => 'Widget A', 'price' => 10 ],
[ 'name' => 'Widget B', 'price' => 15 ],
[ 'name' => 'Widget C', 'price' => 50 ],
];
foreach ( $items as $itemData ) {
$item = new Item();
$item->setName( $itemData['name'] );
// ... set other properties
$manager->persist( $item );
}
$manager->flush();
}
}
Standard fixtures work with the custom loader command — doctrine:fixtures:custom-loader uses Symfony's SymfonyFixturesLoader which discovers all fixture classes regardless of whether they extend AbstractDatabaseFixture or the standard Fixture.
Mix both approaches freely. Use AbstractDatabaseFixture for tables that need triggers and functions, use standard Fixture for straightforward entity data.
1. Create SQL files (if needed) in Doctrine/fixtures/:
-- Doctrine/fixtures/products_default_data_function.sql
CREATE OR REPLACE FUNCTION ...
2. Create a purger in App/DataFixtures/Purgers/:
final class ProductsPurger extends AbstractPurger
{
protected function getQueries(): array
{
return [
'DROP FUNCTION IF EXISTS my_products_function() CASCADE',
'TRUNCATE TABLE products CASCADE',
];
}
}
Implementing CustomPurgerInterface (via AbstractPurger) applies the app.fixture_purger tag, which LoadDataFixturesDoctrineCommand consumes via #[AutowireIterator('app.fixture_purger')] iterable $purgers in its constructor — see Registering purgers.
3. Create the fixture class in App/DataFixtures/:
final class ProductFixtures extends AbstractDatabaseFixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [ CustomerFixtures::class ];
}
protected function loadTable(): array|string
{
return "INSERT INTO products ...";
}
protected function loadFunctions(): array|string
{
return file_get_contents(
project_dir() . '/Doctrine/fixtures/products_default_data_function.sql'
);
}
protected function loadTriggers(): array|string
{
return '';
}
}
4. Run it:
php bin/console doctrine:fixtures:custom-loader -f
Wrong purge order — if UsersPurger runs before UserGroupPurger and users have a foreign key to user groups, TRUNCATE TABLE user_groups CASCADE may cascade to users anyway. But if your UsersPurger tries to drop a function that a trigger on users depends on, order matters. Test purge order carefully when adding new tables with dependencies.
Missing CASCADE on TRUNCATE — without CASCADE, truncating a parent table fails if child tables have rows referencing it. Always use TRUNCATE TABLE parent CASCADE for tables with foreign key relationships.
Missing RESTART IDENTITY on TRUNCATE of an explicitly-IDed table — if fixtures INSERT INTO … (id, …) with hardcoded primary keys and don't reset the sequence, a subsequent ORM INSERT via nextval() can collide with the highest hardcoded ID. Add RESTART IDENTITY (or follow the truncate with SELECT setval('table_id_seq', (SELECT MAX(id) FROM table))) for any table whose fixtures write explicit IDs.
Not dropping functions before truncating — if a trigger on a table calls a function, and you truncate the table without dropping the trigger/function first, the next fixture load that tries to CREATE OR REPLACE FUNCTION with the same name may conflict or behave unexpectedly. Always DROP FUNCTION IF EXISTS ... CASCADE in purgers before truncating.
Hardcoded credentials in fixture SQL — UserFixtures uses env('ADMIN_EMAIL') and env('ADMIN_PASSWORD') from .env rather than hardcoded values. Follow this pattern for any sensitive data in fixtures — never commit real credentials in SQL strings.
Missing dependency declaration — if UserFixtures runs before UserGroupFixtures, the foreign key insert fails. Always declare dependencies via DependentFixtureInterface for any fixture that inserts data referencing another table's data.
Forgetting purgers when adding new fixtures — if you add a fixture for a new table but don't add a corresponding purger, re-running fixtures may fail with duplicate key or constraint errors because the previous data is still there. Every fixture table needs a purger.