Practical guide
How to use Doctrine ORM correctly
Keep entities focused on the domain, repositories focused on queries, and flush changes once at the use-case boundary.
First, the short version
An ORM maps objects, not responsibilities
Doctrine ORM maps entities to tables and tracks changes through a unit of work. An entity still carries domain state and rules; it must not locate services, send email, or query the database by itself.
A repository concentrates entity queries and names them after application needs. The application use case loads the required objects, performs the change, and lets the unit of work save it at a clear boundary.
Get ready
What you need
Start with one real use case and its data model, not a universal abstraction over Doctrine.
- A Symfony project with Doctrine ORM and a configured database connection.
- One domain operation, such as confirming an order, and the rules it must preserve.
- A test database and a way to run repository integration tests.
- Database migrations in version control. Do not update the production schema manually from entity mappings.
Steps 1 to 3
Divide responsibility between entities, repositories, and use cases
Each layer has one straightforward job. Entities protect state, repositories find data, and application services coordinate operations.
1. Design entities around domain rules
- Map stable identity, state, and relationships the use case actually needs. Initialise collections in the constructor and keep both sides of associations consistent.
- Use intent-revealing methods such as confirm() or changeDeliveryAddress() instead of public setters. The entity can then reject an invalid transition.
- Do not inject a service locator, EntityManager, repository, or the Symfony container into an entity. If a rule needs external data, obtain it in the application service and pass the result explicitly.
- Do not traverse lazy collections blindly during serialization. A targeted query and an output DTO often serve a read screen better.
composer require symfony/orm-pack Official Doctrine association mapping documentation 2. Write repositories for specific queries
- Let a repository handle simple entity lookup by identity. Name a more complex query after the application need, such as findPayableOrders(), rather than its SQL implementation.
- Keep column selection, JOINs, sorting, and pagination inside the repository with QueryBuilder or DQL. Callers do not need to know table mappings.
- A repository must not coordinate the complete workflow, send notifications, or call flush. Its job is to load or add entities and perform specialised data queries.
- Consider a DTO or scalar result for read queries. Do not load a large entity graph merely to read three values from it.
php bin/console debug:container --tag=doctrine.repository_service Official Symfony repository documentation 3. Close the unit of work at the use-case boundary
- The EntityManager tracks managed entities. Call persist() for a new entity; changing an already managed entity through its domain method usually needs no additional persist call.
- Call flush once after the application operation is complete, not inside an entity or every repository. The use-case boundary then defines which changes belong together.
- A single flush uses a database transaction. Add an explicit transaction when the use case includes multiple flushes, direct DBAL operations, or locks that must remain atomic.
- Do not wait for a remote API inside a transaction. Handle external events after commit or through an outbox; after an exception, account for rollback and a closed EntityManager.
Step 4
Verify mapping and behaviour
Correct attributes are not enough. Test resulting queries, state changes, and the transaction boundary.
-
Validate mapping against the schema
The command finds invalid associations and differences between mappings and the test database.
php bin/console doctrine:schema:validate -
Run repository integration tests
Verify filtering, sorting, an empty result, and pagination boundaries against a real database.
php bin/phpunit tests/Integration/Repository -
Check migration status
Mappings and the database schema must be deployable through versioned migrations.
php bin/console doctrine:migrations:status
If something goes wrong
Common problems
An entity change was not saved
Check that the entity came from the same open EntityManager and that the use case reached flush. Reload a detached entity; do not carry managed entities through a queue or a long-running process.
One request executes too many queries
Enable the profiler and look for lazy loading inside a loop. Fetch required relationships with a targeted JOIN or use a DTO query, but do not load the entire object graph automatically.
php bin/console debug:config doctrine A lazy collection fails after the application operation ends
Code is reading the entity outside the EntityManager lifecycle. Load the required data and map it to an output DTO inside the use case instead of serializing a detached entity.
A repository calls flush after every change
Move flush to the application use-case boundary. One operation can then change multiple entities atomically, and the test knows exactly when work is committed.
Done
Doctrine has clear boundaries in the application.
Doctrine ORM now maps domain entities, repositories concentrate queries, and the application use case decides on flush and transactions. As the system grows, keep watching query counts and the size of loaded graphs.