Update YAML configs to PHP and PHP 7.4 syntax.
Dependency injection with autowiring is super easy since Symfony 3.3. Yet on my mentoring I still meet service locators.
Mostly due to traditional registration of Doctrine repositories.
The way out from service locators to repository as service was described by many before and now we put it into Symfony 3.3 context.
This post is follow up to StackOverflow answer to clarify key points and show the sweetest version yet.
The person who kicked me to do this post was Václav Keberdle - thank you for that.
Our goal is to have clean code using constructor injection, composition over inheritance and dependency inversion principles.
With as simple registration as:
// app/config/services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()
->autowire();
$services->load('App\\Repository\\', __DIR__ . '/../src/Repository');
};
Nothing more, nothing less. Today, we'll try to get there.
namespace App\Repository;
use App\Entity\Post;
use Doctrine\ORM\EntityRepository;
final class PostRepository extends EntityRepository
{
/**
* Our custom method
* @return Post[]
*/
public function findPostsByAuthor(int $authorId): array
{
return $this->findBy([
'author' => $authorId
]);
}
}
findBy()
, findOneBy()
right away ✅Why? Because parent constructor of Doctrine\ORM\EntityRepository
is missing EntityManager
typehint (this is fixed in doctrine/orm 2.7+)
We can't get another dependency, because parent constructor requires EntityManager
and ClassMetadata
instances ❌
namespace App\Repository;
use App\Sorter\PostSorter;
use Doctrine\ORM\EntityRepository;
final class PostRepository extends EntityRepository
{
public function __construct(PostSorter $postSorter)
{
$this->postSorter = $postSorter;
}
}
findBy()
don't have param and return type declarations ❌// param should be "int", but whatever passes
$this->postRepository->find('someString');
$post = $this->postRepository->find(1);
// some object?
$post->whatMethods()!
namespace App\Entity;
use Doctrine\ORM\Entity;
use Doctrine\ORM\EntityRepository;
/**
* @Entity(repositoryClass="App\Repository\PostRepository")
*/
final class Post
{
...
}
This is a code smell of circular dependency. Why should entity know about its repository?
Do you know why we need repositoryClass="PostRepository"
? It's form of static service locator inside Doctrine:
$this->entityManager->getRepository(Post::class);
It basically works like this:
Post
entity@Entity
annotationInstead of registration to Symfony container like any other service, here is uses logic coupled to annotation of specific class. Just a reminder: Occam's razor.
What if I want to have PostRedisRepository
for Redis-related operations and PostFrontRepository
for reading-only? It is not possible to have more repositories for one entity ❌
Would you have one Controller for every operation related to Product
entity?
We're losing all features of our framework's Dependency Injection container (events, autowiring, automated registration, logging etc.). ❌
You have to use this complicated service registration in YAML:
// app/config/services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
return function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()
->autowire();
$services->set('app.post_repository', \Doctrine\ORM\EntityRepository::class)
->factory([service('@doctrine.orm.default_entity_manager'), 'getRepository'])
->args(['App\Entity\Post']);
};
...or just pass EntityManager
.
namespace App\Controller;
use App\Entity\Post;
use App\Repository\PostRepository;
use Doctrine\ORM\EntityManagerInterface;
final class PostController
{
private PostRepository $postRepository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->postRepository = $entityManager->getRepository(Post::class);
}
}
IDE doesn't know it's App\Repository\PostRepository
, so we have add extra typehint for every single method ❌
Example above would work because there is typehinted property, but these would fail ❌
$postRepository = $entityManager->getRepository(Post::class);
// object?
$postRepository->...?;
$post = $this->postRepository->find(1);
// object?
$post->...?;
/** @var App\Entity\Post $post */
$post = $this->postRepository->find(1);
$post->getName();
find()
changes to get()
in one composer update
, your app is down ❌It require few steps, but all builds on single one change. Have you heard about composition over inheritance?
namespace App\Repository;
use App\Entity\Post;
+use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
-final class PostRepository extends EntityRepository
+final class PostRepository
{
+ private EntityRepository $repository;
+
+ public function __construct(EntityManagerInterface $entityManager)
+ {
+ $this->repository = $entityManager->getRepository(Post::class);
+ }
}
Update entity that is now independent on specific repository:
<?php declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Entity;
/**
- * @Entity(repositoryClass="App\Repository\PostRepository")
+ * @Entity
*/
final class Post
{
...
}
Without this, you'd get a segfault error due to circular reference.
That's all! Now you can program the way which is used in the rest of your application:
And how it influenced our 4 steps?
<?php declare(strict_types=1);
namespace App\Repository;
use App\Entity\Post;
use App\Sorter\PostSorter;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
final class PostRepository
{
private EntityRepository $repository;
private PostSorter $postSorter;
public function __construct(EntityManagerInterface $entityManager, PostSorter $postSorter)
{
$this->repository = $entityManager->getRepository(Post::class);
$this->postSorter = $postSorter;
}
public function find(int $id): ?Post
{
return $this->repository->find($id);
}
}
namespace App\Entity;
use Doctrine\ORM\Entity;
/**
* @Entity
*/
class Post
{
...
}
// app/config/services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()
->autowire();
$services->set(App\Repository\ProductRepository::class);
$services->set(App\Repository\ProductRedisRepository::class);
$services->set(App\Repository\ProductBenchmarkRepository::class);
};
namespace App\Controller;
use App\Repository\PostRepository;
final class PostController
{
private PostRepository $postRepository;
public function __construct(PostRepository $postRepository)
{
$this->postRepository = $postRepository;
}
}
services.php
We have a new extra step - registration of services in application container:
// app/config/services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()
->autowire();
$services->load('App\\Repository\\', __DIR__ . '/../src/Repository');
};
All we needed is to apply composition over inheritance pattern.
The main goal of all this was to make work with repositories typehinted, safe and reliable for you to use and easy to extend. It also minimized space for error, because strict types and constructor injection now validates much of your code for you.
The answer is now simple: just create repository in App\Repository
.
Try the same example with your current approach and let me know in the comments.
Happy coding!
Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!