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 three methods — one for table data, one for PostgreSQL functions, and one for triggers:
abstract protected function loadTable(): array|string;
abstract protected function loadFunctions(): array|string;
abstract protected function loadTriggers(): array|string;
Each method returns either a single SQL string or an array of SQL strings. All queries are executed via Doctrine's createNativeQuery() inside a try/catch — 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 CastleFixtures depends on PlayerFixtures 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
{
/**
* @return array<string>
*/
abstract protected function getQueries(): array;
final public function purge(): void
{
foreach ( $this->getQueries() as $q )
em( 'main' )
->createNativeQuery( $q, new ResultSetMappingBuilder( em( 'main' ) ) )
->execute();
}
final public function setEntityManager( EntityManagerInterface $em ): void {}
}
getQueries() is the only abstract method — return an array of SQL strings to execute in order. purge() runs them against the hardcoded em('main') entity manager (there is no $em property and no per-purger connection selection), and setEntityManager() is an empty no-op kept only to satisfy ORMPurgerInterface:
// App/DataFixtures/Purgers/UserGroupPurger.php
final class UserGroupPurger extends AbstractPurger
{
protected function getQueries(): array
{
return [
/** @lang PostgreSQL */
'DROP FUNCTION IF EXISTS raise_user_groups_table_exception() CASCADE',
/** @lang PostgreSQL */
'TRUNCATE TABLE user_groups CASCADE'
];
}
}
// App/DataFixtures/Purgers/UsersPurger.php
final class UsersPurger extends AbstractPurger
{
protected function getQueries(): array
{
return [
/** @lang PostgreSQL */
'DROP FUNCTION IF EXISTS prevent_admin_deletion() CASCADE',
/** @lang PostgreSQL */
'TRUNCATE TABLE users CASCADE'
];
}
}
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.
config/services.yaml declares an app.fixture_purger tag for any class implementing CustomPurgerInterface:
_instanceof:
App\Abstraction\Interfaces\CustomPurgerInterface:
tags: [ 'app.fixture_purger' ]
This tag is currently unused — nothing consumes it. LoadDataFixturesDoctrineCommand's constructor is (SymfonyFixturesLoader, ?ManagerRegistry); there is no #[TaggedIterator] injection of purgers. The command builds a standard Doctrine ORMPurgerFactory purger for the target entity manager instead of wiring in the AbstractPurger subclasses.
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 builds a standard Doctrine ORMPurgerFactory purger for the target entity manager, wraps it in an ORMExecutor, and calls $executor->execute( $fixtures ) — the executor purges and then loads in its normal flow. There is no separate purge-first step and no append: true.
# 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=game_data
php bin/console doctrine:fixtures:custom-loader --group=game_data --group=test_players
# Specify entity manager
php bin/console doctrine:fixtures:custom-loader --em=analytics
Assign fixtures to groups by implementing getGroups() from Doctrine's FixtureGroupInterface:
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
final class TestPlayerFixtures extends AbstractDatabaseFixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return [ 'test_data', 'players' ];
}
// ...
}
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=game_data
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' => 'Iron Sword', 'damage' => 10 ],
[ 'name' => 'Steel Shield', 'defense' => 15 ],
[ 'name' => 'Health Potion', 'heal' => 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/buildings_default_data_function.sql
CREATE OR REPLACE FUNCTION ...
2. Create a purger in App/DataFixtures/Purgers/:
final class BuildingsPurger extends AbstractPurger
{
protected function getQueries(): array
{
return [
'DROP FUNCTION IF EXISTS my_buildings_function() CASCADE',
'TRUNCATE TABLE buildings CASCADE',
];
}
}
Implementing CustomPurgerInterface (via AbstractPurger) applies the app.fixture_purger tag, but note that tag is not currently consumed by the command — see Registering purgers.
3. Create the fixture class in App/DataFixtures/:
final class BuildingFixtures extends AbstractDatabaseFixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [ PlayerFixtures::class ];
}
protected function loadTable(): array|string
{
return "INSERT INTO buildings ...";
}
protected function loadFunctions(): array|string
{
return file_get_contents(
project_dir() . '/Doctrine/fixtures/buildings_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.
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.