How do you write Doctrine repositories, is it something like the code below? I've seen it so many times, unfortunately, so I decided to write about why it's not the best option.

class ProductRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Product::class); } } 

For some, it's muscle memory, make:entity generates it, every codebase has it and nobody questions it too much. The extends gets you find(), findBy(), findOneBy(), findAll(), count() and createQueryBuilder() without writing them, I don't think that's worth what it costs.

Every finder is public

A repository is supposed to be the one place that knows how to query an entity, so commands and controllers share that logic. Once you extend EntityRepository, its public API is every finder Doctrine ships plus yours, so this is one autocomplete away in any controller:

$products = $productRepository->findBy([ 'category' => $category, 'available' => true, ]); 

Now that query lives in a controller instead of the repository, and nothing stops you from writing it. You probably have getAvailableIn(Category) for exactly this, but findBy() is just as easy to reach.

find() can return null

find() returns ?object, so every caller checks for null and throws, or forgets it somewhere. The base class can't help you here, it doesn't know your domain, your repository does.

Two instances of one repository

Doctrine's default repository factory builds repositories with a plain new:

return new $repositoryClassName($entityManager, $metadata); 

That new passes only the entity manager and the metadata. Give your repository a constructor dependency and the first $em->getRepository(Product::class) crashes. Inject it through a setter instead and you get two instances, and the one Doctrine built never got its dependency. And something usually calls getRepository() sooner or later, framework integrations like validators and form fields do.

Symfony's fix is ServiceEntityRepository plus a ContainerRepositoryFactory and a compiler pass that make Doctrine fetch repositories from the container. It works, but it's quite a lot of machinery, and the problem only exists because Doctrine constructs your repository in the first place.

Wrap it instead

Tomas Votruba made the same case back in 2017. Here's my take with PHP 8.4, and what I build on top of it.

Don't extend anything, inject EntityManagerInterface, keep Doctrine's repository in a private property, and expose only the methods you need. A property hook makes the lazy lookup one line:

final class ProductRepository { /** @var EntityRepository<Product> */ private EntityRepository $repository { get => $this->repository ??= $this->em->getRepository(Product::class); } public function __construct( private readonly EntityManagerInterface $em, ) {} public function get(int $id): Product { return $this->repository->find($id) ?? throw new NotFoundException(); } } 

The class is this short thanks to three RFCs: property hooks by Ilija Tovilo and Larry Garfield, Nikita Popov's constructor promotion, and Ilija's throw expression, which turns the null check into ?? throw. Before 8.4, the lazy repository needed a nullable property and a getter you had to remember to call.

Also remove repositoryClass from the entity's #[ORM\Entity] attribute, so getRepository() returns Doctrine's own EntityRepository.

The public API is now get() and nothing else. The finders are still there through $this->repository, only inside the class. get() returns a Product or throws, so the null check is written only once. It's also just a plain service, Doctrine never constructs it, so there's only one instance and no factory swap or compiler pass is needed.

When a caller really needs a maybe, like looking up a product by a slug from the URL, that method starts with find instead: findBySlug(string $slug): ?Product. get methods throw, find methods return null, so the name tells the caller whether to check. Lists are always get, an empty array is not a null. Where I work, we have exactly this as a written rule.

It costs a property and a constructor per repository. If you have many repositories, you can move it to a small base class of your own, just don't go back to extends EntityRepository.

Filters in one place

It's even more useful when an entity has filters that every read must apply. Say products are soft-deleted, with a deletedAt column instead of a real DELETE. The filter lives in one private method and the public ones build on it:

public function get(int $id): Product { return $this->getQueryBuilderForAll() ->andWhere('p.id = :id') ->setParameter('id', $id) ->getQuery() ->getOneOrNullResult() ?? throw new NotFoundException(); } /** @return list<Product> */ public function getAll(): array { return $this->getQueryBuilderForAll() ->getQuery() ->getResult(); } private function getQueryBuilderForAll(): QueryBuilder { return $this->repository->createQueryBuilder('p') ->where('p.deletedAt IS NULL'); } 

get() and getAll() can't return a deleted product, and the next filter (only published products, only the current tenant's) goes in the same place. With the inherited version, anyone can still call find($id) and get a deleted product back.

Reads and writes

In my projects, I also split reads and writes. The repository is abstract and holds the reads, and a final Products class extends it with the writes:

abstract class ProductRepository { public function __construct( protected readonly EntityManagerInterface $em, ) {} // The $repository hook and the read methods from above. } final class Products extends ProductRepository { public function create(string $name, Category $category): Product { $product = new Product($name, $category); $this->em->persist($product); $this->em->flush(); return $product; } public function rename(int $id, string $name): Product { $product = $this->get($id); $product->rename($name); $this->em->flush(); return $product; } } 

Yes, that's also an extends, but the parent is my own small class, not Doctrine's repository with all its methods.

An abstract class can't be registered on its own, so there's exactly one object per entity, Products, and you can't end up with two instances again. You inject Products everywhere, queries and writes sit in separate short files, and the writes reuse the reads: rename() goes through get(), so it can't touch a deleted product either.

About names: I used to call that class ProductFacade, today I'd just call it Products. The entity is singular, Product, and the class that manages all of them is plural.

What about the extra code?

extends ServiceEntityRepository became the default because it's less typing. Instead of writing a constructor and wrapper methods in every repository, you get a lot of methods for free, so most people took that.

Today an assistant writes most of those lines and you review them in seconds. Writing it explicitly was always safer, it just took more typing.

That's how I write repositories today, if you do it differently, or this breaks down somewhere in your project, I'd like to hear about it.