Auto-configuration of Doctrine repositories as services

Published on 2020-02-19 • Modified on 2020-03-14

In this post, we will see how to use Doctrine repositories as services without adding additional configuration to the services.yaml file. Let's go! 😎

» Published in "A week of Symfony 686" (7-23 February 2020) and Jetbrains PHP Annotated – March 2020.

Prerequisite

I will assume you know what a Doctrine repository is and how to use it in a Symfony application to retrieve data from a database.

Configuration

  • PHP 7.4
  • Symfony 5.2.0-BETA2 with autowiring activated.

Introduction

Sometimes, when developing, when you know how to do something, you just reproduce what you did in the past and you don't ask yourself if there is a better way to do it (baaaad!). I recently migrated this blog to Symfony 5 (not without pain). Then I checked my doctrine.yaml file, its content was: (I have commented the lines because they are not used anymore, I left them for this blog post):

# config/packages/doctrine.yaml
#services:
#    App\Entity\ArticleRepository:
#        factory: ['@doctrine.orm.default_entity_manager', getRepository]
#        arguments:
#            - App\Entity\Article

I said to myself: I have Symfony 5, Flex, and autowiring. Why should I have to declare Doctrine repositories manually? The answer is: we don't have to. Then, I remember I read this excellent blog post of Tomas Vortuba a while ago on this subject. It's two years old, but it's worth reading it (with the comments). He shows different approaches with their associated pros and cons. In the comments, Melyou pointed out the existence of the Doctrine ServiceEntityRepository class and someone added explanations about it. So I had to give it a try.

The goal is to delete this configuration. So, if you still have this kind of setup, let's see how to do this. If you want to create a new entity, run the maker:entity command and you're done (you must have the MakerBundle installed).

Moving repositories from the entity folder

In the past, we used to put the Doctrine repositories in the same directory than the entities. And they aren't autoloaded because of this default autowiring configuration in the config/services.yaml file where the Entity folder is excluded:


    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/*'

So, the first step is to move them in a new directory, let's use src/Repository (the maker command also uses this):

<?php declare(strict_types=1);

namespace App\Repository;

use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * @method Article|null findOneById(int $id)
 * @method Article|null findOneBySlug(string $slug)
 */
final class ArticleRepository extends ServiceEntityRepository
{
    use BaseRepositoryTrait;

    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }

The namespace is now in App\Repository instead of App\Entity. The main difference with our old file is that it now extends the ServiceEntityRepository class. To make things work, we must add a constructor where we configure the entity linked to this repository. It's the equivalent of the factory call we had before in our doctrine configuration file. But it's cleaner here as everything is configured in the same file. The rest of the code remains the same.

Click here to see the Doctrine ServiceEntityRepository class code.
<?php

namespace Doctrine\Bundle\DoctrineBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use LogicException;

/**
 * Optional EntityRepository base class with a simplified constructor (for autowiring).
 *
 * To use in your class, inject the "registry" service and call
 * the parent constructor. For example:
 *
 * class YourEntityRepository extends ServiceEntityRepository
 * {
 *     public function __construct(ManagerRegistry $registry)
 *     {
 *         parent::__construct($registry, YourEntity::class);
 *     }
 * }
 */
class ServiceEntityRepository extends EntityRepository implements ServiceEntityRepositoryInterface
{
    /**
     * @param string $entityClass The class name of the entity this repository manages
     */
    public function __construct(ManagerRegistry $registry, $entityClass)
    {
        $manager = $registry->getManagerForClass($entityClass);

        if ($manager === null) {
            throw new LogicException(sprintf(
                'Could not find the entity manager for class "%s". Check your Doctrine configuration to make sure it is configured to load this entity’s metadata.',
                $entityClass
            ));
        }

        parent::__construct($manager, $manager->getClassMetadata($entityClass));
    }
}

Adding generic methods to your repositories

As the repository now extends the ServiceEntityRepository class, I use a BaseRepositoryTrait to provide some generics functions, let's have a look at it:

<?php declare(strict_types=1);

namespace App\Repository;

use Doctrine\ORM\EntityManagerInterface;

/**
 * @method EntityManagerInterface getEntityManager()
 */
trait BaseRepositoryTrait
{
    /**
     * This is just an example. Use ->count([]) for the same result.
     */
    public function countAll(): int
    {
        return (int) $this->getEntityManager()
            ->createQuery(sprintf('SELECT COUNT(a) FROM %s a', $this->getClassName()))
            ->getSingleScalarResult();
    }

To have autocompletion, the trick here is to add @method or @property annotations so your IDE knows the type of the objects you are dealing with. In this case, the getEntityManager() function returns an EntityManagerInterface. It's something that is also used in the Symfony code base, here is an example (check out the last line in the following snippet):

Click here to see the Symfony CompiledUrlMatcherTrait.php code.
<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\Routing\Matcher\Dumper;

use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\NoConfigurationException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\RedirectableUrlMatcherInterface;
use Symfony\Component\Routing\RequestContext;

/**
 * @author Nicolas Grekas <p@tchwork.com>
 *
 * @internal
 *
 * @property RequestContext $context
 */
trait CompiledUrlMatcherTrait
{
    private $matchHost = false;
    private $staticRoutes = [];
    private $regexpList = [];
    private $dynamicRoutes = [];
    private $checkCondition;

    public function match(string $pathinfo): array
    {
        $allow = $allowSchemes = [];
        if ($ret = $this->doMatch($pathinfo, $allow, $allowSchemes)) {
            return $ret;
        }
        if ($allow) {
            throw new MethodNotAllowedException(array_keys($allow));
        }
        if (!$this instanceof RedirectableUrlMatcherInterface) {
            throw new ResourceNotFoundException(sprintf('No routes found for "%s".', $pathinfo));
        }
        if (!\in_array($this->context->getMethod(), ['HEAD', 'GET'], true)) {

Using the repository as a service

Well, nothing special here. We are done. We can use our repository like any other services. For example, let's have a look at the controller responsible for displaying the homepage of this website:

    /**
     * @Route({"en": "/en", "fr": "/fr"}, name="homepage")
     *
     * @param array<string,int> $goals
     */
    public function homepage(string $_locale, array $goals, ArticleRepository $articleRepository): Response
    {
        $data = [];
        $date = new \DateTime();
        $data['goals'] = $goals;
        $data['article'] = $articleRepository->findLastArticleForLang($_locale);
        $data['snippet'] = $articleRepository->findLastSnippetForLang($_locale);
        $data['done'] = $articleRepository->getDoneGoals();
        $data['year_day'] = (int) $date->format('z')+1;
        $data['year_percent'] = $data['year_day']/365*100;
        $data['week_number'] = (int) $date->format('W');

We inject the service in the method parameters with the correct class type hint. Et voilΓ ! Now, every time you will add a new repository like this it will be available in the same way without having to modify the configuration.

Conclusion

I've just made this change in this project. So, let's see how it goes. But for me, it seems cleaner with what I used to do. Adding the entry in the service file was a pain with no advantage at all. The fact that this approach is used by the MakerBundle shows that it is considered a best practice.

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  More on the web  More on Stackoverflow

They gave feedback and helped me to fix errors and typos in this article, many thanks to jmsche, TimoBakx, gubler. πŸ‘


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil