🐘How PHP Generics Can Save You from Rewriting Doctrine Repositories

Ismaile ABDALLAH
4 min readOct 20, 2024

--

Photo by AltumCode on Unsplash

⚠️ !important: Before diving into this article, I recommend checking out this post on Generics by Example for additional context and a helpful example.

Every time we create a new entity, we have to set up a corresponding repository and implement the usual methods like findOne() and findAll(). It gets really frustrating copying and pasting the same code from one repository to another. Plus, in some projects, you'll even find inconsistencies in method names—like findOne() in one place and just find() in another. It’s a small thing, but it adds up and can get pretty annoying

What if I told you that with PHP generics, we can implement all the most useful repository methods just once? No more repeating yourself for every entity. Let’s dive in and see how to make it happen:

Step 1: Create an interface that will represent your usual repository methods

/**
* @template T
*/
interface RepositoryInterface
{
/**
* @param T $entity
*/
public function save(mixed $entity): void;

/**
* @param array<mixed> $criteria
* @param null|array<mixed> $orderBy
*
* @return ?T
*/
public function findOneByOrNull(array $criteria, array $orderBy = null);

/**
* @param array<mixed> $criteria
* @param null|array<mixed> $orderBy
*
* @return T
*/
public function findOneBy(array $criteria, array $orderBy = null);

/**
* @return ?T
*/
public function findOneByIdOrNull(int $id);

/**
* @return T
*/
public function findOneById(int $id);

/**
* @return array<T>
*/
public function findAll(): array;

/**
* @param array<mixed> $criteria
* @param null|array<mixed> $orderBy
*
* @return array<T>
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;

/**
* @param T $entity
*/
public function remove(mixed $entity): void;

public function store(): void;
}

T will represent the type object that will use next.

Step 2: Let create our unique implemention for this methods

**
* @template T
*
* @implements RepositoryInterface<T>
*/
abstract class DoctrineRepository implements RepositoryInterface
{
/**
* @param EntityManagerInterface $entityManager
* @param $repository
*/
public function __construct(
private readonly EntityManagerInterface $entityManager,
private $repository
) {
}

/**
* @param T $entity
*
* @return void
*/
public function save(mixed $entity): void
{
$this->entityManager->persist($entity);
}

/**
* @return T|null
*/
public function findOneByOrNull(array $criteria, array $orderBy = null)
{
return $this->repository->findOneBy($criteria, $orderBy);
}

/**
* @return T|null
*/
public function findOneByIdOrNull(int $id)
{
return $this->findOneByOrNull(['id' => $id]);
}

/**
* @return T
*/
public function findOneById(int $id)
{
return $this->findOneBy(['id' => $id]);
}

/**
* @throws EntityNotFound
*
* @return T
*/
public function findOneBy(array $criteria, array $orderBy = null)
{
$entity = $this->repository->findOneBy($criteria, $orderBy);
if ($entity === null) {
throw new EntityNotFound();
}

return $entity;
}

/**
* @return array<T>
*/
public function findAll(): array
{
return $this->repository->findAll();
}

/**
* @return array<T>
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}

/**
* @param T $entity
*/
public function remove(mixed $entity): void
{
$this->entityManager->remove($entity);
}

public function store(): void
{
$this->entityManager->flush();
}
}

As you can see, the __construct() method is just injecting your actual EntityRepository through composition.

Now, you might be wondering, "Will my IDE be able to figure out what my methods are returning? 🤔" No need to worry—things will become clear in the next step. Let’s go ahead and create our first entity along with its repository!

Step 3: create your entity and doctrine repository

let’s create an entity “Message” :

use App\Repository\MessageRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: MessageRepository::class)]
class Message
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;

#[ORM\Column(type: 'string')]
private string $message;
}

.........

use App\Entity\Message;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;


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

⚠️ 👀 !important: The MessageRepository is an auto-generated repository when an entity is created. It already includes methods like findOne and find(id). However, in most projects, we prefer using a repository interface rather than interacting directly with the repository itself. This approach makes it easier to create Fake or Stub repositories for unit testing.

Step 4: implement our usual method

Here’s how to create your MessageRepositoryInterface and implement implementation

/**
* @implements RepositoryInterface<Message>
*/
interface MessageRepositoryInterface extends RepositoryInterface
{
}

Next, provide a simple implementation:

/**
* @extends DoctrineRepository<Message>
*/
class MessageDoctrineRepository extends DoctrineRepository implements MessageRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly MessageRepository $messageRepository
) {
parent::__construct($this->entityManager, $this->messageRepository);
}
}

As you can see, we replace the generic type T with the specific Message only without adding code related to the implementation.

Now, PHPStorm correctly understands the return type of my methods when I inject the MessageRepositoryInterface.

Conclusion

One big advantage is that generics make your code more reusable, allowing you to create flexible solutions without rewriting the logic for different types. Unfortunately, PHP’s native support for generics is still somewhat limited or will never be supported, and you often have to rely on annotations to get the most out of them.

So be careful,

--

--

Responses (3)