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
{
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;
}
/**
* Must match a key under doctrine.orm.entity_managers in doctrine.yaml.
*/
abstract public function getEntityManagerName(): string;
/**
* @return array<string>
*/
abstract protected function getQueries(): array;
}
Extend it and implement both getEntityManagerName() (to declare which connection this purger belongs to) and getQueries() returning an array of SQL strings to execute in order:
// App/DataFixtures/Purgers/UserGroupPurger.php
final class UserGroupPurger extends AbstractPurger
{
public function getEntityManagerName(): string
{
return 'connection_name';
}
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
{
public function getEntityManagerName(): string
{
return 'connection_name';
}
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.
Purgers are registered as Symfony services via the app.fixture_purger tag in config/services.yaml:
_instanceof:
App\Abstraction\Interfaces\CustomPurgerInterface:
tags: [ 'app.fixture_purger' ]
Any class implementing CustomPurgerInterface is automatically tagged and injected into LoadDataFixturesDoctrineCommand via #[TaggedIterator]. You don't need to manually register each purger — implementing the interface is enough.
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
It differs from the standard command in one important way — purging happens outside Doctrine's transaction. Doctrine's built-in executor wraps everything including the purge step in a transaction. TRUNCATE in PostgreSQL causes an implicit commit, which breaks the transaction. The custom command purges first, then runs fixtures in the executor's own transaction with the built-in purge step disabled (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 purgers, then all fixtures in dependency order.
php bin/console doctrine:fixtures:custom-loader -f --group=game_data
Only purgers tagged for that group run, then only fixtures in that group.
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
{
public function getEntityManagerName(): string
{
return 'connection_name';
}
protected function getQueries(): array
{
return [
'DROP FUNCTION IF EXISTS my_buildings_function() CASCADE',
'TRUNCATE TABLE buildings CASCADE',
];
}
}
Purger is auto-registered via CustomPurgerInterface — no further configuration needed.
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.