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.

30 minutes · Doctrine

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

  1. Map stable identity, state, and relationships the use case actually needs. Initialise collections in the constructor and keep both sides of associations consistent.
  2. Use intent-revealing methods such as confirm() or changeDeliveryAddress() instead of public setters. The entity can then reject an invalid transition.
  3. 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.
  4. 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

  1. 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.
  2. Keep column selection, JOINs, sorting, and pagination inside the repository with QueryBuilder or DQL. Callers do not need to know table mappings.
  3. 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.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Official Doctrine transaction documentation

Step 4

Verify mapping and behaviour

Correct attributes are not enough. Test resulting queries, state changes, and the transaction boundary.

  1. Validate mapping against the schema

    The command finds invalid associations and differences between mappings and the test database.

    php bin/console doctrine:schema:validate
  2. Run repository integration tests

    Verify filtering, sorting, an empty result, and pagination boundaries against a real database.

    php bin/phpunit tests/Integration/Repository
  3. 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.

Request a call

I will call you on the next working day between 9:00 and 17:00.

You can also call me directly.

+420 605 181 728

Leave your phone number and send a callback request.

By sending, you agree to processing your data in order to handle your request.