Skip to content
Snippets Groups Projects

Ne soyez plus l'esclave de Doctrine


Hello!

Grégoire Paris
Software Engineer
PhotoDeGregoire
LogoUniversCine
➡️
LogoManomano
Maxime Veber
Backend developer
PhotoDeMaxime

  • Conçu par Benjamin Eberlei
  • Maintenu par Marco Pivetta et Guilherme Blanco
  • Boosté par Michael Moravec et Luís Cobucci

👏

Notes: Juste pour dire que nous n'avons rien à voir avec la team de Doctrine et qu'il faut les remercier.


Vous avez dit entité ?

class Article
{
    private $id;
    private $content;

    public function getId()
    {
        return $this->id;
    }

    public function getContent()
    {
        return $this->content;
    }

    public function setContent($content)
    {
        $this->content = $content;

        return $this;
    }
}

👆 modèle anémique

Notes:

  • Pas de validation
  • Pas de règles métier
  • Aucune logique (pas de tests nécessaires)
  • Pas de setter pour id, il est setté par Doctrine après la persistence


️❤️ Domain Driven Design ❤️

  • Représenter les règles métier dans les entités
  • Avoir une API expressive
  • Respecter l'encapsulation
  • Séparer le domaine de l'infrastructure

Notes:

  • Il existe d'autres architectures, cf le talk sur le clean code de Romain Kuzniak

Doctrine n'a pas besoin de setters

class Article
{
    private $id;
    private $content;

    public function __construct(string $content)
    {
        $this->content = $content;
    }

    public function getId()
    {
        return $this->id;
    }

    public function getContent()
    {
        return $this->content;
    }
}

Notes:

  • intégrité du domaine
  • Pas de setters par défaut, ni Doctrine ni sf n'en ont besoin

Les règles métier

class Article
{
    private $id;
    private $content;

    public function __construct(string $content)
    {
        if (empty($content)) {
            throw new ArticleHasNoContent();
        }
        $this->content = $content;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getContent(): string
    {
        return $this->content;
    }
}

Note:

  • Impossible de persister une entité invalide
  • Validation compliquée quand on a trop de propriétés
  • Pour qu'on puisse utiliser des constructeurs sans que ça crée de conflit avec Doctrine, Doctrine utilise de la réflection ou de la désérialisation pour hydrater les entités.

Les UUID

  • Universally unique
  • Indevinables
  • Ne nécessitent pas de base de données
  • ramsey/uuid à la rescousse

Notes:

  • Idéalement, prendre une clé naturelle.
  • Doctrine a un type guid qui correspond au type uuid dans Postgres, jetez-y un oeil.

Les value objects

class ArticleContent
{
    private $content;

    public function __construct(string $content)
    {
        if (empty($content)) {
            throw new ArticleHasNoContent();
        }
        $this->content = $content;
    }
}

Note:

  • Déportation de la validation dans les value objects
  • Début d'arborescence
  • Doctrine Embeddables
  • Custom types

Instanciation avec des value object

Instanciation:

use App\Domain\Article\Article;
use App\Domain\Article\ArticleContent;
use App\Domain\Article\ArticleTitle;

new Article(
  new ArticleTitle('my title'),
  new ArticleContent('This is a very short but nice article'),
  new \DateTimeImmutable(),
);

Notes:

  • ça peut vite devenir très pénible

Les constructeurs nommés

class Article
{
    public static function createFromNative(string $title, string $content): self
    {
        return new self(
          new ArticleTitle($title),
          new ArticleContent($content),
          new \DateTimeImmutable()
        );
    }
}

Note:

  • On peut faire plusieurs constructeurs nommés
  • On peut passer le constructeur en privé pour encourager l'utilisation des constructeurs nommés.

Persister des VO avec Doctrine

  • les custom types
  • les embeddables

Les custom types

use Doctrine\DBAL\Types\Type;

final class ArticleContentType extends Type
{
    public function convertToPHPValue($value, AbstractPlatform $platform): ArticleContent
    {
        return new ArticleContent($value);
    }

    public function convertToDatabaseValue($value, AbstractPlatform $platform): string
    {
        return (string) $value;
    }

    public function getName()
    {
        return 'article_content';
    }
}
# config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            article_content:  App\Infrastructure\Persistence\ArticleContentType

Note:

  • Permet de contrôler qu'on respecte toujours les règles métier à l'hydratation. Si ça crashe, c'est qu'il manque des migrations.
  • La méthode getName() fait doublon avec le nom utilisé lors de l'enregistrement du type dans le registre de type, et disparaître dès Doctrine 3

Les embeddables

use Doctrine\ORM\Annotation as ORM;

class Article
{
    private $uuid;

    /** @ORM\Embedded(class = "ArticleContent") */
    private $articleContent;

    public function __construct(ArticleContent $articleContent)
    {
        $this->uuid = Uuid::generate();
        $this->articleContent = $articleContent;
    }
}
/** @ORM\Embeddable() **/
class ArticleContent
{
    /** @ORM\Column() **/
    private $content;

    /** @ORM\Column() **/
    private $lastModification;
}

Note:

  • À utiliser en cas de Value Object composite
  • Des soucis avec la nullabilité, contournables avec un package
  • Ne peuvent contenir des colonnes complexes

Les embeddables nullables

use Doctrine\ORM\Annotation as ORM;

class Article
{
    private $uuid;

    /** @ORM\Embedded(class = "ArticleContent", nullable=true) */
    private $articleContent;
}


tarifhaus/doctrine-nullable-embeddable

Nécessite un setter.


Les repositories

  • Vos repositories en service facilement
  • Les repositories non pollués par toutes les méthodes par défaut

sisi, c'est possible


Repository as a Service

Depuis DoctrineBundle 1.8.0 (novembre 2017) :

class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(RegistryInterface $registry)
    {
        parent::__construct($registry, Article::class);
    }

    // your methods
}

✔️ Enregistrement en tant que service simple


Les repositories

final class DoctrineArticleRepository implements ArticleRepository, ServiceEntityRepositoryInterface
{
    private $entityManager;

    public function __construct(ManagerRegistry $registry)
    {
        $this->entityManager = $registry->getManagerForClass(Article::class);
    }

    // your methods…
}

✔️ Repository simplifié

Note:

  • Repository, c'est un pattern, et c'est vous qui devriez en définir l'interface.
  • EntityRepository et ses méthodes magiques à éviter si vous voulez des type hints de retour et donc de l'autocompletion. Il est maintenant possible d'en faire des services: https://github.com/doctrine/DoctrineBundle/pull/727, mais ça ne résout pas le problème. Si vous tenez à les utiliser, injectez les dans vos repositories plutôt que de les étendre.
  • Plus l'interface est grosse, plus elle devient difficile à implémenter, et le code qui consomme l'API a rarement besoin de faire beaucoup d'appels, du coup… slide suivant

// Récupère votre repository d'article custom !
$repository = $entityManager->getRepository(Article::class);

Les repositories, ça peut grossir

interface ArticleRepository
{
    public function latestArticles(int size): iterable;
    public function mostReadArticles(int size): iterable;
    public function mostCommentedArticles(int size): iterable;
    public function byTopic(ArticleTopic $topic): iterable;
    public function findRelated(Article $article): iterable;

    // more and more methods…
}

Les Query functions

Quand les repositories ne respectent pas l'ISP

final class DoctrineGetLatestArticles implements GetLatestArticles
{
    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function __invoke(int $size): iterable
    {
        $this->entityManager->createQueryBuilder()
          ->select('a.*')
          ->from(Article::class)
          ->orderBy('a.createdAt', 'DESC')
          ->setMaxResults($size)
          ->getQuery()
          ->getResults();
    }
}

Note:

  • Tout de suite beaucoup plus simple à réimplémenter en elasticsearch
  • Interface Segregation Principle

Aliasing d'interfaces

services:
    App\Domain\Article\GetLatestArticles:
        '@App\Infrastructure\Article\DoctrineGetLatestArticles'

Note:

  • Inutile si il n'y a qu'une seule implémentation et que la feature de discovery est configurée sur un namespace commun.

Et les collections ?

  • Elles permettent à Doctrine de repérer les changements
  • Elles permettent à Doctrine de faire du lazy-loading

Ignorez les collections

  • Champs initialisés en tableaux
  • Utilisez le type iterable compatible avec Collection et array
class Article
{
    private $comments;

    public function __construct()
    {
        $this->comments = [];
    }

    public function getComments(): iterable
    {
        return $this->comments;
    }
}

Éviter le mapping One-To-Many

  • Permet d'éviter des collections inutiles
  • Leur utilisation est très rare
  • Elles ont un impact important sur les performances

Note:

  • Ne pas faire de relations par réflexe, surtout bidirectionnelles

Faire une jointure avec une relation uni-directionnelle

Récupérer tous les articles dont les commentaires contiennent Doctrine sans les commentaires.

$queryBuilder
  ->select('a.*')
  ->from(Article::class)
  ->innerJoin(Comment::class, 'c', Expr\Join::WITH, 'c.article_id = a.id')
  ->where("c.content LIKE '%Doctrine%'")
  ->getQuery()
  ->getResults();

 

Note:

  • utile si on a besoin de rajouter des conditions sans nécessiter d'hydrater des objets de la classe jointe.

Doctrine impose peu de choses

  • final possible pour les méthodes en version 3
  • __clone & __wakeup implémentables librement en version 3


Protips


Quelle API pour interroger la base de données?

Clause conditionnelle `Doctrine\ORM\QueryBuilder` `Doctrine\DBAL\Query\QueryBuilder`
Clause non conditionnelle DQL SQL
Résultat Objets Scalaires

Note:

  • Si vous n'avez pas besoin d'objets, le SQL peut s'avérer plus simple, plus puissant, et plus performant (pas d'hydratation).
  • Si vous voulez des objets mais ne pouvez pas utiliser du DQL, tournez vous vers le ResultSetMapping
  • Si vous n'avez pas besoin de faire des requêtes dynamiques, vous pouvez vous passer du Query Builder.

Result Set Mapping

$rsm = new ResultSetMappingBuilder($entityManager);

$rsm->addRootEntityFromClassMetadata(Comment::class, 'c');

$rsm->addJoinedEntityFromClassMetadata(
  Article::class,
  'a',
  'c',
  'article',
  ['uuid' => 'article_uuid']
);

$query = $entityManager->createNativeQuery(
<<<'SQL'
    SELECT *, a.uuid AS article_uuid, c.uuid AS comment_uuid
    FROM comment c
    INNER JOIN article ON c.article_uuid = a.uuid
    WHERE article_uuid = ?
SQL
, $rsm);
$query->setParameter(1, $article->getUuid());

// Comment instances
$comments = $query->getResult();

SQL + Objets


Les dépendances circulaires entre paquets

Problème :

  • Je développe un système d'adresse pour tous mes projets
  • L'adresse est liée à mon utilisateur par une relation

Resolve target entities

doctrine:
    orm:
        resolve_target_entities:
            Lib\Address\UserInterface: My\Project\Model\User

What's next


Support de MariaDB dans Doctrine 3


La configuration YAML est dépréciée


Thanks!

Grégoire Paris greg0ire
Maxime Veber nekdev