| name | database-migrations |
| description | Create, manage, and apply database schema changes with Doctrine — versioned migrations when the profile's persistence.mapper is doctrine-orm, mapping-driven schema/index sync when it is doctrine-odm. Use when modifying entities or documents, adding fields, managing database schema changes, creating repositories, or troubleshooting schema/mapping issues. |
Database Migrations & Schema Management
Profile keys consumed
persistence.mapper
persistence.engine
framework.name
framework.api_platform
architecture.source_root
architecture.bounded_contexts
architecture.shared_context
make.start
make.tests
make.ci
make.deptrac
quality.infection_msi
Context (Input)
- New entity/document needs database persistence
- Existing entity requires schema changes (add/modify/remove fields)
- Repository implementation needed
- Schema or mapping validation fails
- Need to set up indexes for performance (for which indexes to add,
see query-performance-analysis)
Mapper branching (read this first)
This skill branches on persistence.mapper:
persistence.mapper | Engines (persistence.engine) | Mapping file | Schema-change mechanism | Manager class |
|---|
doctrine-orm (Path A) | mysql | mariadb | postgresql | config/doctrine/<Entity>.orm.xml | versioned migrations (doctrine:migrations:*) | EntityManagerInterface |
doctrine-odm (Path B) | mongodb | config/doctrine/<Document>.mongodb.xml | mapping-driven sync (doctrine:mongodb:schema:*) | DocumentManager |
Key difference: Path A keeps an append-only history of migration files;
Path B has no migration files by default — collections and indexes
are derived from the XML mappings and synced with a console command, and
only data backfills/renames need an explicit script or console command.
persistence:
mapper: doctrine-odm
engine: mongodb
When framework.name is symfony and the service runs in containers,
boot with the target mapped by make.start and run every bin/console
command inside the PHP container
(docker compose exec <php-service> bin/console ...).
Task (Function)
Create entities/documents with XML mappings and repositories following
hexagonal architecture, then apply and verify the schema change via the
mapper-appropriate mechanism.
Success criteria: mappings validate, the test database recreates
without errors, all tests pass, the targets mapped by make.deptrac and
make.ci stay green.
Core Principles
Domain-Driven Design
With <src> = architecture.source_root and <Context> one of
architecture.bounded_contexts:
- Entities/Documents: Domain layer (
<src>/<Context>/Domain/Entity/)
- Repository interfaces: Domain layer (
<src>/<Context>/Domain/Repository/)
- Repository implementations: Infrastructure layer (
<src>/<Context>/Infrastructure/Repository/)
- XML mappings: Infrastructure concern (
config/doctrine/)
See: implementing-ddd-architecture.
Doctrine mapping rules (both paths)
- Use XML mappings for all metadata — never annotations/attributes
in Domain classes (keeps the Domain framework-agnostic)
- Define indexes in the XML mapping so code and schema stay in sync
- Use custom identifier types (see below) instead of auto-increment ids
Custom identifier types
Registered in the shared context's Infrastructure layer
(<src>/<architecture.shared_context>/Infrastructure/DoctrineType/, or
the owning bounded context's own Infrastructure/DoctrineType/ when
architecture.shared_context is null):
| Type | Usage | Purpose |
|---|
ulid | Primary keys, tokens | Sortable, time-ordered, URL-safe (26 chars) |
domain_uuid | Domain identifiers | RFC 4122 UUID v4 |
Prefer ulid for primary keys (time-ordered → efficient indexing) and
domain_uuid where standard UUID compatibility matters. Generate in a
named constructor (Ulid::generate() / Uuid::v4()), never in the DB.
Quick Start: Creating a New Entity/Document
Step 1: Create the class (Domain layer — identical for both paths)
namespace App\Customer\Domain\Entity;
final class Customer
{
public function __construct(
private string $id,
private string $name,
private string $email,
private \DateTimeImmutable $createdAt
) {}
}
Step 2: Create the XML mapping
Path A (doctrine-orm) — config/doctrine/Customer.orm.xml:
<entity name="App\Customer\Domain\Entity\Customer"
repository-class="App\Customer\Infrastructure\Repository\CustomerRepository">
<id name="id" type="domain_uuid"/>
<field name="name" type="string" length="255"/>
<field name="email" type="string" length="255" unique="true"/>
<field name="createdAt" column="created_at" type="datetime_immutable"/>
<indexes>
<index name="idx_customer_email" columns="email"/>
</indexes>
</entity>
Path B (doctrine-odm) — config/doctrine/Customer.mongodb.xml:
<document name="App\Customer\Domain\Entity\Customer" collection="customers">
<id type="ulid" strategy="NONE"/>
<field field-name="name" type="string"/>
<field field-name="email" type="string"/>
<field field-name="createdAt" name="created_at" type="date_immutable"/>
<indexes>
<index>
<key name="email" order="asc"/>
<option name="unique" value="true"/>
</index>
</indexes>
</document>
Step 3: Configure the API resource (when framework.api_platform is a version)
App\Customer\Domain\Entity\Customer:
shortName: Customer
operations:
ApiPlatform\Metadata\GetCollection: ~
ApiPlatform\Metadata\Get: ~
ApiPlatform\Metadata\Post: ~
Ensure the resource directory is registered in api_platform.yaml.
See: api-platform-crud. Skip this
step when framework.api_platform is false.
Step 4: Apply and validate
Path A (doctrine-orm):
bin/console cache:clear
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
bin/console doctrine:schema:validate
Path B (doctrine-odm):
bin/console cache:clear
bin/console doctrine:mongodb:mapping:info
bin/console doctrine:mongodb:schema:create
bin/console doctrine:mongodb:schema:update
Modifying Existing Entities
- Update the class (add/modify fields)
- Update the XML mapping
bin/console cache:clear
- Apply: Path A —
doctrine:migrations:diff → review → migrate;
Path B — doctrine:mongodb:schema:update
- Validate (Path A
doctrine:schema:validate; Path B doctrine:mongodb:mapping:info)
- Path B only: field renames and backfills do not happen automatically —
write an explicit console command/script for the data migration
Creating Repositories
Step 1 — interface (Domain):
interface CustomerRepositoryInterface
{
public function save(Customer $customer): void;
public function findById(string $id): ?Customer;
}
Step 2 — implementation (Infrastructure); the injected manager
branches on persistence.mapper:
final class CustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
// Path A: private readonly EntityManagerInterface $entityManager
// Path B: private readonly DocumentManager $documentManager
private readonly EntityManagerInterface $entityManager
) {}
public function save(Customer $customer): void
{
$this->entityManager->persist($customer);
$this->entityManager->flush();
}
}
Step 3 — register the alias in services.yaml:
App\Customer\Domain\Repository\CustomerRepositoryInterface:
alias: App\Customer\Infrastructure\Repository\CustomerRepository
Mapper-specific modeling features
Path A: ORM (relational)
- Uniqueness:
unique="true" on a field or <unique-constraint>
<indexes> for frequent lookups; always index columns used in
filters/sorting (email, token, foreign keys, timestamps)
- Associations: Doctrine relations (
one-to-one, one-to-many,
many-to-many); persist value objects as embeddables or simple fields
- Never put framework validation inside Domain classes
Path B: ODM (MongoDB)
- Compound indexes are used left-to-right:
{status, type, createdAt}
serves filters on status and status+type, NOT on type alone
- Options:
unique, sparse (index only documents having the field),
expireAfterSeconds (TTL auto-deletion), type=text (text search)
- Keep indexes to roughly 3–5 per collection — each one slows writes
- Value objects → embedded documents (
<embedded-document> mapping +
embed-one/embed-many in the parent), loaded with the parent —
keep them small
- Entity references → IRI strings (e.g.
"/api/customer_types/<id>")
stored as plain string fields, NOT reference-one DBRefs: API Platform
expects IRIs, and DBRefs add lazy-loading complexity and overhead
- Multi-document writes needing atomicity:
$dm->transactional(fn ($dm) => ...)
(requires a replica set)
Constraints (Parameters)
NEVER
- Use Doctrine annotations/attributes in Domain classes
- Modify a migration after it has been applied anywhere (Path A)
- Leave empty migration files in the codebase — delete a generated
migration whose
up()/down() bodies are empty immediately (Path A)
- Skip mapping validation
- Commit without testing the schema change
- Skip recreating the test database before integration tests
- Edit
deptrac.yaml or add suppression annotations to silence layer
violations — fix the code
(see deptrac-fixer)
ALWAYS
- Create XML mappings for all metadata; keep Domain framework-agnostic
- Define indexes for frequently queried fields in the mapping
- Test migrations/schema sync on the dev database before committing
- Use Faker for unique test data (emails, names, etc.)
- Register resource directories in
api_platform.yaml (when
framework.api_platform is enabled)
Testing with the database
Recreate the test database before integration/E2E tests:
bin/console --env=test doctrine:database:drop --force --if-exists
bin/console --env=test doctrine:database:create
bin/console --env=test doctrine:migrations:migrate -n
bin/console --env=test doctrine:mongodb:schema:drop
bin/console --env=test doctrine:mongodb:schema:create
Integration test pattern (run via the target mapped by make.tests):
final class CustomerRepositoryTest extends IntegrationTestCase
{
private CustomerRepositoryInterface $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = $this->getContainer()->get(CustomerRepositoryInterface::class);
}
public function testSaveAndRetrieveCustomer(): void
{
$customer = new Customer();
$this->repository->save($customer);
$this->assertNotNull($this->repository->findById($customer->getId()));
}
}
New repository tests must keep the mutation score at or above
quality.infection_msi (canonical default 100 — raise-only: a profile
may tighten the floor, never lower it).
Migration best practices (Path A) / schema-sync practices (Path B)
- Review every generated migration —
doctrine:migrations:diff
output can include unrelated drift; delete empty ones
- Test before committing: apply on dev, validate, run all tests,
test rollback (
doctrine:migrations:migrate prev) where applicable
- Production safety: back up first, apply, verify the application
works, keep the backup for rollback
- Path B: index changes are online by default (MongoDB 4.2+), but
destructive mapping changes (field renames, drops) silently orphan
data — pair them with an explicit data-migration command
Verification Checklist
Troubleshooting
Database connection errors:
docker compose ps <db-service>
docker compose logs <db-service>
Schema sync issues — re-validate and check state:
bin/console doctrine:schema:validate
bin/console doctrine:migrations:status
bin/console doctrine:mongodb:mapping:info
Migration conflicts (Path A) — check doctrine:migrations:status;
roll back with doctrine:migrations:migrate prev, fix, regenerate.
Index conflict (Path B) — an existing index with the same name but
different options blocks schema:update; drop the old index in mongosh
(db.<collection>.dropIndex("<name>")), then re-run the sync.
Related Skills