While working with legacy projects, I often encountered this anti-pattern of misusing repositories. Instead of easy-to-inject service, projects are locked into a service locator.
This makes code hard to upgrade and locks your project heavily to the Doctrine ODM packages. And there are plenty of them. Each extra package bites off its share of upgrade costs.
Today, we look at how to refactor the ODM service locator to independent services and separate your project from ODM. We also get a few advantages in strict type coverage.
What do I mean by service locator?
final class ProductController
{
// ...
public function __construct(Container $container)
{
$this->gptClient = $container->get(GptAPIClient);
$this->dynamicPriceResolver = $container->get(DynamicPriceResolver);
}
}
A service locator is a class that contains all the services. It's an anti-pattern that leaks too much and makes the service able to do everything. It used to be the way to use containers in the 2010s before we discovered dependency injection in PHP. Unfortunately, it's still in official docs on "First Steps" tutorial.
Nowadays, we use constructor injection to make service design clean, minimalistic, and neat.
Yet, ODM ships with such a service locator out of the box:
final class ProductController
{
// ..
public function productDetail()
{
$productRepository = $this->documentManager->getRepository(Product::class);
$categoryRepository = $this->documentManager->getRepository(Category::class);
// ...
}
}
The documentManager
is a service locator for Doctrine ODM. If we inject this service everywhere, we can get any repository we want - existing or on-the-fly. That's why legacy projects are filled with documentManager
in every single possible place:
What does the code actually do?
->getRepository()
runs docblock/attribute reflection on the Product
entity@Document(repositoryClass="App\Repository\ProductRepository")
Doctrine\ODM\MongoDB\Repository\DocumentRepository
class - generic, without any types, do-it-all serviceNow:
With great power comesgreat responsibility
lot of wasted time and money to keep the code up to date
We should never use Doctrine\ODM\MongoDB\DocumentManager
outside repository services.
The best practice nowadays is to have single service that handles the task we give it to - whether it's data transformation, calling external API, or sorting data based on user input filter:
final class ProductRepository
{
private $repository;
public function __construct(private DocumentManager $documentManager)
{
// the only allowed call of getRepository()
$this->repository = $documentManager->getRepository(Product::class);
}
public function findByName(string $name): ?Product
{
$this->repository->findOneBy(['name' => $name]);
}
}
Why is this ideal design?
/vendor
,Rule of thumb: Your controllers and services should not know about the database layer you use. Only repositories should care about that.
If we upgrade ODM 1 to 2, then to 2 to 3, 3 to 4..., we don't have to change anything. The cost of such an upgrade is the time to change composer.json
, run composer update
, and fix bundle configuration here and there.
Let's list all the work we have to do:
documentManager
from all the places it lives inDocumentManager
in the constructor->getRepository()
to clean constructor injection...to sum up: a lot!
We have to get rid of these obstacles. Only that way, our next upgrade will be close to $ 0.
The service pattern is also more adaptable in case your framework DI container changes (and it will).
Doctrine ODM holds the service locator pattern tight and may discourage you from moving on. But if we apply the following steps, it will go down one by one like snowflakes in a sunbeam.
use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
/**
* @Document(
- * repositoryClass="App\Repository\ProductRepository"
* )
*/
class Product
/vendor
+use App\Entity\Product;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
+use Doctrine\ODM\MongoDB\DocumentManager;
-final class ProductRepository extends DocumentRepository
+final class ProductRepository
{
+ private DocumentRepository $repository;
+
+ public function __construct(private DocumentManager $documentManager)
+ {
+ $this->repository = $documentManager->getRepository(Product::class);
+ }
// ...
}
find*()
method you need, with type declarationsInstead of docblock + DocumentRepository
magic, we can now use actual PHP code and native type declarations:
-/**
- * @method Product|null find(string $id)
- */
final class ProductRepository
{
// ...
+ public function find(string $id): ?Product
+ {
+ return $this->repository->find($id);
+ }
}
Now, we've just improved our IDE, Rector, and PHPStan support. Also, if we pass an integer where the string should be $this->productRepository->find(1)
, we'll get an error report.
->getRepository(...)
with service injection-use Doctrine\ODM\MongoDB\DocumentManager;
final class ProductController
{
public function __construct(
- private DocumentManager $documentManager,
+ private ProductRepository $productRepository,
) {
}
public function productDetail(string $id)
{
- $productRepository = $this->documentManager->getRepository(Product::class);
- $product = $productRepository->get($id);
+ $product = $this->productRepository->get($id);
// ...
}
}
That's it!
It's tempting to do a big bang jump and refactor all the repositories at once. But it's most likely that your project has more than 5 repositories used throughout most of the codebase.
To make this change happen safely and fast, handle only one repository per pull request. Change it, do all 4 steps, and create a pull request. Update tests and make CI pass. Merge.
Rinse and repeat.
Finish this upgrade challenge, and you'll get a sweet reward:
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!