Endoctrinement à l'ORM @php[architect]
Volume 25 - Numéro 9 (09/2026) par Oscar Merida / Traduit par Jon
Ce mois-ci, voyons comment intégrer Doctrine ORM 3.x dans une application PHP autonome, sans Symfony, comme notre interface web Spacetraders. Nous pouvons laisser Doctrine ORM se charger des requêtes courantes sur la base de données, ce qui nous évite d'écrire beaucoup de SQL, voire d'en écrire tout court. Je présenterai les principaux schémas de mise en place, de l'utilisation des attributs de PHP 8 et de la visibilité asymétrique (public private(set)) à la gestion d'objets valeur au moyen de types de mapping DBAL personnalisés. Enfin, je vous montrerai comment alléger les contrôleurs de l'application en encapsulant la logique des requêtes dans des repositories personnalisés, et en isolant le comportement propre aux gabarits d'affichage grâce au patron Presenter.
La persistance en base de données est une décision structurante pour toute application PHP. Depuis le début, nous nous en sommes sortis en utilisant simplement les résultats de l'API Spacetraders, mais je veux maintenant ajouter des fonctionnalités qui exploitent les données qu'elle renvoie, ou qui en rendent compte. Les requêtes SQL directes, avec DBAL ou PDO, donnent un contrôle complet sur l'exécution. En revanche, elles amènent vite du code répétitif, de l'analyse manuelle de tableaux et une logique métier éparpillée à mesure que l'application grandit. Pire, à la moindre inattention, nous pourrions nous exposer à une injection SQL. Dépasser le SQL brut ne veut pas dire abandonner l'architecture de notre application aux conventions d'un framework complet. Voyons comment utiliser Doctrine ORM comme un composant autonome. Il se charge de faire correspondre des modèles objet riches aux tables d'une base de données relationnelle.
Pourquoi Doctrine
Doctrine est l'éléphant au milieu de la pièce dès qu'il est question d'ORM (Object-Relational Mapper) pour les applications PHP. Il a fait ses preuves, il est maintenu, et il s'intègre à Symfony comme il s'utilise seul dans d'autres applications. J'avais déjà fait ce choix en intégrant le paquet DBAL de Doctrine pour travailler directement avec la base de données. Nous pouvons toujours écrire du SQL brut là où la performance l'exige, ou pour des requêtes SELECT complexes. Nous verrons cependant comment l'ORM simplifie la lecture et l'écriture en base en prenant en charge le SQL sous-jacent.
Il existe d'autres ORM. Deux d'entre eux sont activement maintenus :
- Propel, qui existait avant Doctrine. Propel 2 est toujours développé activement. Il vous demande de définir vos schémas en XML, et génère des classes Active Record pour gérer les migrations de tables et les requêtes.
- Laravel Eloquent peut s'utiliser comme composant autonome dans une application qui n'est pas écrite avec Laravel. Il suit lui aussi le modèle Active Record.
La communauté Doctrine fournit des extensions utiles, comme Doctrine Behavioral Extensions, qui permet d'ajouter des comportements de journalisation pour suivre l'historique des objets, des champs horodatés automatiquement, la gestion d'arborescences, la suppression logique, et bien d'autres. Commençons par installer Doctrine ORM. J'ai utilisé la version 3.6.7 pour le code de cet article. Je ne couvrirai pas ici toutes les bases de Doctrine ORM : consultez leur guide « Getting Started » pour les notions de base et la mise en place. Je donnerai des exemples de son intégration dans l'application existante, et je signalerai les surprises.
composer require doctrine/orm
Les entités
Les entités sont de simples objets PHP, pour tout ensemble de données que vous devez enregistrer dans une base de données et y relire. Il n'y a pas d'objet entité « de base » à étendre. À la place, ce sont des attributs qui décrivent l'entité à l'ORM. Le listing 1 en montre une, que j'ai créée dans le namespace Phparch\SpaceTraders\Entity pour enregistrer des EventRecords, qui journalisent les évènements du jeu. Les attributs y font beaucoup de choses.
<?php
namespace Phparch\SpaceTraders\Entity;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Phparch\SpaceTraders\Repository;
#[ORM\Table(name: 'event_record')]
class EventRecord
{
/**
* @readonly
*/
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id;
#[ORM\Column(
name: 'created_at',
type: Types::DATETIME_IMMUTABLE
)]
private DateTimeImmutable $createdAt;
/**
* Stores data as a JSON object/array in the
* database, but accepts/returns a JSON string
* via the getters and setters.
*
* @var array<string, mixed>|null $data
*/
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $data = null;
public function __construct(
#[ORM\Column(type: Types::STRING, length: 512)]
private string $name,
#[ORM\Column(type: Types::STRING, length: 512)]
private string $source,
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null
) {
$this->createdAt = new DateTimeImmutable();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->createdAt = new DateTimeImmutable();
}
// --- Getters & Setters ---
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSource(): ?string
{
return $this->source;
}
public function setSource(string $source): self
{
$this->source = $source;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(
?string $description
): self
{
$this->description = $description;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
/**
* Gets the data property converted back into
* a JSON string.
* @throws \JsonException
* @return array<string,mixed>
*/
public function getData(): array
{
if ($this->data === null) {
return [];
}
return $this->data;
}
/**
* @throws \JsonException If the provided
* string is invalid JSON
*/
public function setDataFromString(
?string $json
): self
{
if ($json === null || trim($json) === '') {
$this->data = null;
return $this;
}
/** @var array<string, mixed> $decoded */
$decoded = json_decode(
$json, true, 512, JSON_THROW_ON_ERROR
);
$this->data = $decoded;
return $this;
}
/**
* Convenience method if you prefer to set the
* decoded array directly.
* @return array<string, mixed>
*/
public function getDataAsArray(): ?array
{
return $this->data;
}
/**
* @param array<string, mixed>|null $data
* @return $this
*/
public function setDataFromArray(?array $data): self
{
$this->data = $data;
return $this;
}
}
L'attribut ORM\Table posé sur la classe indique quelle table de la base de données contient nos entités EventRecord. Chaque propriété que nous voulons enregistrer dans une colonne de la table porte un attribut ORM, qui fixe le nom de la colonne, le type de données qu'elle contient, et bien d'autres choses, par exemple si elle doit être indexée. Voyez « Basic Mapping » dans la documentation de Doctrine pour en comprendre le fonctionnement.
Remarquez que nous avons quelques types particuliers, en plus des colonnes habituelles comme les entiers, les booléens, les chaînes de caractères et le texte. Doctrine enregistre les différents types de date et d'heure avec le type natif équivalent de la base de données, puis renvoie une valeur DateTime ou DateTimeImmutable à la lecture d'une ligne. De même, si vous donnez à une colonne l'un des types JSON, Doctrine encode et décode les valeurs de façon transparente quand vous affectez un tableau ou un objet à cette propriété. Vous n'avez pas à appeler json_encode() ni json_decode() vous-même. Laissez Doctrine s'en occuper.
Si vous regardez attentivement le listing, vous verrez que je passe les valeurs fournies par l'utilisateur au __construct() de l'entité. Deux propriétés sont renseignées automatiquement. La colonne id porte l'attribut spécial #[ORM\Id], qui définit notre clé primaire. L'attribut #[ORM\GeneratedValue] signale que cette valeur est générée à l'insertion par la séquence ou la colonne auto-incrémentée de la base de données. Nous pouvons aussi renseigner automatiquement la valeur d'une propriété : createdAt est initialisée à la date courante quand nous instancions un nouvel objet.
Si la table d'une entité existe déjà, vous pouvez utiliser doctrine-entity-generator.com pour créer une classe PHP à partir de l'instruction CREATE TABLE. Pensez à cocher la case « Use Doctrine attributes (PHP 8+) instead of XML mapping ». Le mois prochain, nous verrons comment passer d'une classe d'entité à une instruction CREATE TABLE.
Enregistrer des données
Il nous faut une instance de l'EntityManager pour lire et écrire nos entités. L'entrée ci-dessous le déclare dans le conteneur de services. Elle indique à l'EntityManager d'utiliser les attributs PHP pour configurer une entité, lui donne le chemin de nos classes d'entités, et le place en mode développement, ce qui désactive la mise en cache des métadonnées. Il faudra en faire un réglage dépendant de l'environnement. Les Lazy Objects initialisent les objets de façon plus performante. Enfin, nous injectons la connexion de notre couche d'accès à la base de données (voir le listing 2).
ORM\EntityManagerInterface::class =>
static function(): EntityManager {
$config = ORMSetup::createAttributeMetadataConfig(
paths: [__DIR__ .'/../src/Entity'],
isDevMode: true,
);
$config->enableNativeLazyObjects(true);
$connection = ServiceContainer::get(
DBAL\Connection::class
);
return new EntityManager($connection, $config);
},
Le listing 3 montre comment enregistrer un nouveau journal EventRecord quand un joueur accepte un contrat. Nous créons une nouvelle entité, et nous y plaçons les données qui décrivent l'évènement à journaliser. Pour l'enregistrer en base, il faut appeler persist() pour mettre l'insertion en attente, puis flush() pour l'exécuter réellement. N'oubliez pas le flush() ! Cette approche permet de préparer un lot d'insertions et de les enregistrer toutes en une fois.
use Doctrine\ORM\EntityManagerInterface;
use Phparch\SpaceTraders\Entity\EventRecord;
//...
public function onContractAccepted(
ContractAccepted $event
): void {
$contract = $event->accepted->contract;
$agent = $event->accepted->agent->symbol;
$type = $contract->type->value;
$expires = $contract->expiration
->format(\DateTime::ATOM);
$record = new EventRecord(
name: "Contract Accepted",
source: $event::class,
description: <<<EOF
{$agent} accepted a {$type} contract.
The contract expires on {$expires}.
EOF
);
$record->setDataFromArray([
'id' => $contract->id,
'terms' => $contract->terms,
'expiration' => $expires,
'type' => $type,
]);
$this->entityManager->persist($record);
$this->entityManager->flush();
}
Types de mapping personnalisés
J'ai rencontré des erreurs en voulant enregistrer l'activité des marchandises sur une place de marché. L'emplacement d'une place de marché est indiqué par son symbole de waypoint. Un symbole est une chaîne de caractères au format particulier, que nous représentons par un objet valeur qui vérifie que la chaîne respecte ce format.
J'ai d'abord utilisé Types::STRING pour la colonne dans l'entité. Comme la propriété était typée avec notre classe Symbol, Doctrine avait du mal à la faire correspondre automatiquement à une chaîne de caractères simple de la base de données, en lecture comme en écriture.
Pour aider Doctrine à comprendre comment traiter cette colonne et cette conversion, j'ai dû ajouter un type de mapping personnalisé (listing 4). Cette classe prend directement en charge la conversion d'une chaîne en objet valeur, et inversement. Le « cookbook » de Doctrine détaille les étapes pour ajouter un type de mapping personnalisé. Vous pourriez en avoir besoin si vous utilisez des objets valeur pour des types métier comme les numéros de téléphone ou les adresses email.
<?php
namespace Phparch\SpaceTraders\Doctrine\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
use Phparch\SpaceTradersRest\Value\Waypoint\Symbol;
class WaypointSymbolType extends Type
{
public const NAME = 'waypoint_symbol';
public function getSQLDeclaration(
array $column,
AbstractPlatform $platform
): string
{
return $platform
->getStringTypeDeclarationSQL($column);
}
public function convertToPHPValue(
$value,
AbstractPlatform $platform
): ?Symbol
{
if ($value === null) {
return null;
}
if (is_scalar($value)) {
return new Symbol((string) $value);
}
throw new \InvalidArgumentException(
'The value must be a scalar value.'
);
}
public function convertToDatabaseValue(
$value,
AbstractPlatform $platform
): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof Symbol) {
return $value->waypoint;
}
if (is_scalar($value)) {
return (string) $value;
}
throw new \InvalidArgumentException(
'The value must be a scalar value.'
);
}
public function getName(): string
{
return self::NAME;
}
}
Interroger les données
Récupérer des entités avec l'EntityManager est tout aussi simple. J'ai utilisé la méthode findBy() pour obtenir les 50 dernières entrées. Il existe d'autres méthodes pratiques pour récupérer des lignes : find() en récupère une par sa clé primaire, findAll() les récupère toutes, et findOneBy() renvoie le premier résultat correspondant.
/**@varEventRecord[]$events */
$events = $this->entityManager
->getRepository(EventRecord::class)
->findBy(
criteria: [],
orderBy: ['id' => 'DESC'],
limit: 50,
);
Les repositories
J'ai utilisé l'entityManager directement dans mes classes de contrôleurs quand j'ai ajouté une première page pour consulter les objets EventRecord journalisés, et dans la classe ListenerService pour capturer les nouvelles données de marché. Cela encombre les méthodes de ces classes, et nous pouvons faire mieux en encapsulant ces requêtes dans une autre classe. Doctrine propose les repositories dans ce but. Nous pouvons les utiliser chaque fois que nous voulons lire ou écrire des entités.
Le listing 5 est un exemple de classe repository pour MarketTradeGoodsActivity. D'abord, nous étendons Doctrine\ORM\EntityRepository et nous implémentons une méthode save(), qui regroupe nos appels à persist() et à flush(). Cette entité avait une exigence de plus pour l'enregistrement d'un nouveau lot d'entités : nous voulons enregistrer l'information une seule fois par jour, et non à chaque visite d'un vaisseau. La méthode saveNewData() s'en charge : elle génère l'heure de minuit, puis vérifie avec la méthode ifExists() si nous avons déjà une ligne pour une marchandise donnée ce jour-là. S'il y a des données à enregistrer, nous appelons persist() sur chacune, puis flush() une seule fois pour toutes.
<?php
namespace Phparch\SpaceTraders\Repository;
use Doctrine\ORM\EntityRepository;
use Phparch\SpaceTraders\Entity;
/**
* @extends EntityRepository<MarketTradeGoodsActivity>
*/
class MarketTradeGoodsActivity extends EntityRepository
{
public function save(
Entity\MarketTradeGoodsActivity $entity,
bool $flush = false
): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function ifExists(
Entity\MarketTradeGoodsActivity $entity,
\DateTimeImmutable $ts,
): false|Entity\MarketTradeGoodsActivity
{
$exists = $this->findOneBy(
criteria: [
'waypointSymbol' =>
$entity->waypointSymbol->waypoint,
'symbol' => $entity->symbol->value,
'timestamp' => $ts,
]
);
if ($exists instanceof
Entity\MarketTradeGoodsActivity) {
return $exists;
}
return false;
}
/**
* Return false if nothing saved or the count
* of new items
* @param Entity\MarketTradeGoodsActivity[] $goods
*/
public function saveNewData(array $goods): false|int
{
$ts = new \DateTimeImmutable('midnight today');
$saved = 0;
foreach ($goods as $good) {
if (!$this->ifExists($good, $ts)) {
$this->save($good);
$saved++;
}
}
if ($saved > 0) {
$this->getEntityManager()->flush();
return $saved;
}
return false;
}
}
Désormais, ListenerService n'a plus qu'à transmettre les nouvelles données de marché au repository. Les lignes concernées sont dans le listing 6.
// prepare to save trade good activity
// timestamped for today
$goods = Entity\MarketTradeGoodsActivity
::fromTradeGoodsValue(
$marketData->market->symbol,
$marketData->market->tradeGoods,
$ts = new \DateTimeImmutable('midnight')
);
/**
* @var Repository\MarketTradeGoodsActivity $repo
*/
$repo = $this->entityManager->getRepository(
Entity\MarketTradeGoodsActivity::class
);
// only saves if we don't already have a row
// with same timestamp
$saved = $repo->saveNewData($goods, $ts);
if ($saved) {
// Log it
}
Il reste deux choses à faire pour qu'un repository fonctionne. Chaque repository de notre application doit être déclaré comme service, et c'est l'EntityManager qui doit renvoyer la classe propre à une entité. Avec beaucoup de repositories dans l'application, ce code répétitif deviendrait pesant. Nous pourrions ajouter une fonction utilitaire pour le simplifier, ou bien récupérer l'entityManager automatiquement dans le constructeur des classes repository. Je préfère l'approche explicite, dans la configuration des services, plutôt que de devoir me souvenir d'une formule magique pour les méthodes __construct() (voir le listing 7).
Repository\EventRecord::class =>
static function(): Repository\EventRecord {
$em = ServiceContainer::get(
ORM\EntityManagerInterface::class
);
return $em->getRepository(
Entity\EventRecord::class
);
},
Repository\MarketTradeGoodsActivity::class =>
static function():
Repository\MarketTradeGoodsActivity {
$em = ServiceContainer::get(
ORM\EntityManagerInterface::class
);
return $em->getRepository(
Entity\MarketTradeGoodsActivity::class
);
},
L'autre exigence est un attribut dans nos entités, pour que le manager sache quelle classe repository renvoyer.
namespace Phparch\SpaceTraders\Entity;
use ...
#[ORM\Entity(
repositoryClass: MarketTradeGoodsActivity::class
)]
#[ORM\Table(name: 'market_trade_goods_activity')]
class MarketTradeGoodsActivity
{
Le patron Presenter
J'ai ajouté une page qui affiche au joueur les 50 derniers évènements enregistrés, en utilisant un repository pour récupérer les enregistrements concernés. Au moment de créer le gabarit Twig, j'ai voulu que le lien mène à une URL différente selon le type d'évènement journalisé. Si une ligne concerne un contrat, elle doit mener au détail du contrat. Si elle concerne une place de marché, elle doit mener au détail de cette place de marché.
Ajouter des conditions if-then-else complexes au gabarit Twig, juste pour changer l'attribut href dans un tableau, l'aurait rendu difficile à lire et à maintenir. Je ne voulais pas non plus ajouter une méthode getLink() à la classe EventRecord, car elle n'a pas à savoir comment ni où elle sera affichée. À la place, j'ai utilisé le patron Presenter, qui se charge d'afficher un objet dans un contexte donné.
Le listing 8 montre un presenter pour les entités EventRecord. Notez que c'est en fait un proxy (ou un décorateur) des propriétés existantes. Il ajoute la méthode getUrl(), qui renvoie un chemin local différent selon la source de l'évènement. Toute notre logique alambiquée de lien vers une page web se trouve ici, et non directement dans notre entité ORM.
<?php
namespace Phparch\SpaceTraders\Presenter;
use Phparch\SpaceTraders\Entity;
class EventRecord
{
public function __construct(
private Entity\EventRecord $event,
) {
}
// Delegate getters to the underlying entity
public function getId(): ?int
{
return $this->event->getId();
}
public function getName(): string
{
return $this->event->getName();
}
public function getCreatedAt(): \DateTimeInterface
{
return $this->event->getCreatedAt();
}
public function getSource(): ?string
{
return $this->event->getSource();
}
public function getDescription(): ?string
{
return $this->event->getDescription();
}
// Context-aware URL resolver logic
public function getUrl(): ?string
{
$data = $this->event->getData();
switch ($this->event->getSource()) {
case 'Phparch\SpaceTraders\Event'
. '\ListenerService::onSystemMarketData':
if ($id = $data['waypointSymbol']) {
assert(is_string($id));
return sprintf('/systems/market?id=%s', $id);
}
break;
case 'Phparch\SpaceTradersRest'
. '\Event\ContractAccepted':
if ($id = $data['id']) {
assert(is_string($id));
return sprintf('/contracts/get/?id=%s', $id);
}
break;
}
return null;
}
}
Quand Twig évalue des expressions comme record.id ou record.url, il essaie d'abord d'appeler getId() ou getUrl(), avant de se rabattre sur les propriétés publiques. Avec un presenter, le gabarit Twig reste concis et facile à lire, comme le montre le listing 9. La seule logique à écrire dans le gabarit consiste à vérifier s'il faut une balise de lien.
{% import 'macros/helpers.html.twig' as helpers %}
{% extends 'base.html.twig' %}
{% set breadcrumbs = [
{label: 'Events', url: '.'},
]
%}
{% block content %}
<h1>Latest Events</h1>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Date</th>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for record in events %}
<tr>
<td>{{ record.id }}</td>
<td>
{{ record.createdAt|date('Y-m-d H:i:s') }}
</td>
<td>
{% if record.url is not null %}
{{ helpers.modal_link_large(
record.url, record.name
) }}
{% else %}
{{ record.name }}
{% endif %}
</td>
<td>{{ record.description }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">No event records found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Conclusion
Intégrer Doctrine ORM dans une application PHP sans framework demande un peu de câblage initial dans le conteneur, mais les bénéfices architecturaux à long terme en valent la peine. Et je ne dis pas cela seulement parce que je veux éviter d'écrire du SQL. Vous pouvez vous appuyer sur Doctrine en toute confiance. Laissez-le gérer l'hydratation des objets, le suivi de l'unité de travail et le mapping relationnel : vous pourrez vous concentrer sur la logique propre à votre application.
Oscar Merida travaille avec PHP depuis la sortie de la version 4. Il apprend sans cesse de nouvelles choses à son sujet, et se souvient encore d'en avoir installé l'une des premières versions sur Apache. Quand il ne code pas et n'écrit pas, il aime les jeux de rôle, le football et le dessin. @omerida