Aller au contenu

omnibridge/doctrine

The storage glue of omnibase sites, framework-free: Doctrine reading the structure described outside of it (omnisense/omnibridge's Omnibridge\Schema), the naming strategy that turns their entities into their tables, the UTC date types, and an entity manager built with them, for any framework reading the same database; and omnibase's persistence on Doctrine (Omnibridge\Doctrine\Omnibase\: its repositories, finder compiler, mapping, DBAL types, walkers, filters and listeners), which omnisense/omnibase requires.

Étage
Pont
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibridge/doctrine:1.x-dev

The storage glue of omnibase sites, framework-free (plan 0, rule 13): what any framework needs to map an omnibase site's database with Doctrine ORM and find the same tables - and Doctrine reading the structure described outside of it (audit "lesringostars face au master plan", R14; omnisense/omnibridge's Omnibridge\Schema).

composer require omnibridge/doctrine
  • DeclarationDriver: a Doctrine mapping driver that builds a class's metadata from its declaration (Omnibridge\Schema\Declaration: what the class declares of its mapping, read without the ORM - or kept as data, a JSON file) instead of reading the attributes itself. It wraps the application's driver (an attribute driver, DoctrineBundle's chain), which still says which classes exist and maps a class the declarations do not hold. The metadata are those Doctrine's own attribute driver builds, to the property (the same calls on the ClassMetadata, in the same order): DeclarationDriverTest compares every class of the fixtures both ways, from the attributes and from declarations that went through JSON.
  • MetadataDescription::of($entityManager): what an entity manager maps, in the neutral form (Omnibridge\Schema\Description): each class as its metadata hold it, and the tables its SchemaTool would create. DescriptionTest proves it equal to the description Omnibridge\Schema\Builder builds from the declarations alone; omnisense/omnibase's harness and lesringostars do the same over their entities, and lesringostars compares it with its database's information_schema.
  • NamingStrategy: Doctrine's naming strategy answered by the family's naming (Omnibridge\Schema\CamelCaseNaming, or the Naming given): App\Entity\Thread\Tag is threadTag, App\Entity\User\Profile userProfile, Thread::$tags the join table thread_tags (thread_id, threadTag_id), a property its own name. Checked against every table and join table of lesringostars (tests/lesringostars-tables.json). omnibase's Base\Database\Mapping\NamingStrategy extends it: one place says how a class becomes a table.
  • Types::register(): datetime and datetimetz written and read in UTC, utc_datetime and utc_datetime_immutable, as omnibase registers them.
  • EntityManagers::create($connection, $paths): an entity manager with both, attribute mapping, PHP's native lazy objects; its configure: closure receives the configuration before the entity manager exists (a repository factory, the driver wrapped in a DeclarationDriver).
use Omnibridge\Doctrine\DeclarationDriver;
use Omnibridge\Doctrine\EntityManagers;

$entityManager = EntityManagers::create($connection, [$projectDir.'/src/Entity'], configure: function ($configuration) {
    $configuration->setMetadataDriverImpl(new DeclarationDriver($configuration->getMetadataDriverImpl()));
});

Requires omnisense/omnibridge (the Schema), doctrine/orm ^3.4 and doctrine/dbal ^4.2 (tested on 3.7.5 and 4.5.0), symfony/cache 7 or 8 (the in-memory metadata cache).

Who uses it

  • Under Laravel, omnibridge/laravel builds the site's entity manager with EntityManagers::create() (OmnibridgeServiceProvider).
  • Under Symfony, DoctrineBundle builds the entity manager; omnisense/omnibase gives it its naming strategy (Base\Database\Mapping\NamingStrategy, a subclass of this one) and its types.
  • Any script: the Installation page's.

glitchr/base-plugin's Doctrine hook (audited 2026-10-17)

The hook rewrites three files of doctrine/orm:

  • ObjectHydrator: its anchor has been gone since doctrine/orm 3.6.7. The hook no longer patches anything there.
  • SqlWalker (private made protected): not needed. omnibase's walkers use public methods only.
  • ClassMetadataFactory (private made protected): still needed.
    • omnibase overrides getShortName(), private upstream, so that default discriminator maps are keyed by class names. On doctrine/orm as published, its schema creation stops on a duplicated discriminator entry (user).
    • It also reads $em, which a setter override could replace.

This package needs no patch: an application mapping the tables with its own entities and explicit discriminator maps runs on doctrine/orm as published.

How this manual is organised

Licence

MIT.

Proposer une modification docs/index.md

Installation

Requirements

Package Version
PHP 8.2 or later (8.4 for native lazy objects, used when available)
omnisense/omnibridge 1.x-dev (the Schema)
doctrine/orm ^3.4
doctrine/dbal ^4.2
symfony/cache ^7.0 or ^8.0 (ArrayAdapter, the metadata cache by default)
composer require omnibridge/doctrine

An entity manager in plain PHP

Two entities in src/Entity (the same as the core's schema example), an in-memory SQLite database:

<?php
// src/Entity/Thread.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Thread
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    public ?int $id = null;

    #[ORM\Column(unique: true)]
    public string $slug = '';

    #[ORM\Column(type: 'utc_datetime_immutable', nullable: true)]
    public ?\DateTimeImmutable $publishedAt = null;

    #[ORM\ManyToMany(targetEntity: Thread\Tag::class)]
    public iterable $tags = [];
}
<?php
// src/Entity/Thread/Tag.php
namespace App\Entity\Thread;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Tag
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    public ?int $id = null;

    #[ORM\Column(length: 64)]
    public string $name = '';
}
<?php

require 'vendor/autoload.php';

use App\Entity\Thread;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\Tools\SchemaTool;
use Omnibridge\Doctrine\DeclarationDriver;
use Omnibridge\Doctrine\EntityManagers;
use Omnibridge\Doctrine\MetadataDescription;
use Omnibridge\Schema\Builder;

$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
$entityManager = EntityManagers::create($connection, [__DIR__.'/src/Entity'], configure: function ($configuration) {
    $configuration->setMetadataDriverImpl(new DeclarationDriver($configuration->getMetadataDriverImpl()));
});

$metadata = $entityManager->getClassMetadata(Thread::class);
echo get_class($entityManager->getConfiguration()->getMetadataDriverImpl()), "\n";
echo $metadata->getTableName(), ' ', $metadata->associationMappings['tags']->joinTable->name, "\n";

$doctrine = MetadataDescription::of($entityManager);
$builder = (new Builder())->build([Thread::class, Thread\Tag::class]);
echo $doctrine->toJson() === $builder->toJson() ? "the same description\n" : "different descriptions\n";
echo implode(', ', array_keys($doctrine->tables)), "\n";

(new SchemaTool($entityManager))->createSchema($entityManager->getMetadataFactory()->getAllMetadata());
$thread = new Thread();
$thread->slug = 'le-fuji';
$thread->publishedAt = new DateTimeImmutable('2026-10-27 09:00', new DateTimeZone('Asia/Tokyo'));
$entityManager->persist($thread);
$entityManager->flush();
echo $connection->fetchOne('SELECT publishedAt FROM thread'), "\n";
$entityManager->clear();
$read = $entityManager->find(Thread::class, $thread->id);
echo $read->publishedAt->format(DATE_ATOM), "\n";

Its output (PHP 8.4.27, doctrine/orm 3.7.5, dbal 4.5.0, in the image lesringostars-laravel-app, 2026-10-27):

Omnibridge\Doctrine\DeclarationDriver
thread thread_tags
the same description
thread, threadTag, thread_tags
2026-10-27 00:00:00
2026-10-27T00:00:00+00:00
  • The metadata come from the declarations (DeclarationDriver around ORMSetup's attribute driver), named by the family's naming (threadTag, thread_tags).
  • The description of Doctrine's metadata and tables is the one the builder makes without Doctrine, byte for byte as JSON.
  • 09:00 in Tokyo is stored and read back as 00:00 UTC (utc_datetime_immutable).

Proposer une modification docs/installation.md

The declaration driver

Omnibridge\Doctrine\DeclarationDriver (final) implements Doctrine's MappingDriver: Doctrine's metadata of a class built from its declaration (Omnibridge\Schema\Declaration\ClassDeclaration, read without the ORM), no longer from its attributes - Doctrine reads the structure described outside of it (the bridge principle of 2026-10-19).

new DeclarationDriver(
    MappingDriver $driver,                               // the application's: an AttributeDriver, DoctrineBundle's chain
    Declarations $declarations = new AttributeReader(),  // or a DeclarationSet read from a file
);
Method What it does
getAllClassNames() the wrapped driver's: which classes exist stays its answer
isTransient($className) the wrapped driver's
loadMetadataForClass($className, $metadata) reads the class's declaration; none (an XML mapping, a class the declaration source does not hold) leaves the class to the wrapped driver; otherwise maps it as below
getDriver(), getDeclarations() what it was built with
__call() any other method of the wrapped driver (MappingDriverChain::getDrivers()...)

What it maps

The declaration is read as doctrine/orm 3's AttributeDriver::loadMetadataForClass() reads the attributes - the same calls on the ClassMetadata, in the same order - with each attribute instantiated from its arguments as written (a nested one, a JoinTable's columns, in turn), so the classes of Doctrine\ORM\Mapping must be installed here (they are: doctrine/orm is required).

  • The class: Entity (repository class, read-only), MappedSuperclass (repository class), Embeddable; otherwise MappingException::classIsNotAValidEntityOrMappedSuperClass(), as Doctrine.
  • The table: Table (name, schema, options), Index and UniqueConstraint (columns or fields - both or neither is MappingException::invalidIndexConfiguration() / invalidUniqueConstraintConfiguration() -, flags, options), Cache.
  • The hierarchy: InheritanceType, DiscriminatorColumn, DiscriminatorMap; ChangeTrackingPolicy.
  • Each property, those already mapped where they are declared skipped (Doctrine's isRepeatedPropertyDeclaration()): Column with Id, GeneratedValue, Version, SequenceGenerator, CustomIdGenerator; OneToOne, OneToMany, ManyToOne, ManyToMany with JoinColumns, JoinTable, InverseJoinColumns, OrderBy, the fetch mode (an unknown one is MappingException::invalidFetchMode()); Embedded (class, column prefix); Cache on an association.
  • AssociationOverrides and AttributeOverrides.
  • EntityListeners: each listener class (missing: MappingException::entityListenerClassNotFound()), its public methods' lifecycle attributes read on the listener itself - a listener is behaviour, not structure, so it is not part of the declaration -, or bound by Doctrine's EntityListenerBuilder::bindEntityListener() when it carries none.
  • HasLifecycleCallbacks: the declaration's public methods with lifecycle attributes, one callback per attribute name, in the order first written.

An attribute that is invalid on an embeddable (Index, InheritanceType, an association...) is MappingException::invalidAttributeOnEmbeddable(), as Doctrine.

Wiring it

With EntityManagers (plain PHP, Laravel):

$entityManager = EntityManagers::create($connection, $paths, configure: function ($configuration) {
    $configuration->setMetadataDriverImpl(new DeclarationDriver($configuration->getMetadataDriverImpl()));
});

From declarations kept as data (a JSON file written once by DeclarationSet::toArray()):

use Omnibridge\Schema\Declaration\DeclarationSet;

$declarations = DeclarationSet::fromArray(json_decode(file_get_contents('var/declarations.json'), true));
$configuration->setMetadataDriverImpl(new DeclarationDriver($configuration->getMetadataDriverImpl(), $declarations));

Under Symfony, a compiler pass or a decoration of DoctrineBundle's driver chain service wraps it the same way; this package ships no bundle.

The proof that the metadata are the attribute driver's: DeclarationDriverTest maps eight fixture classes (a mapped superclass, a joined hierarchy with a generated map, a single-table one with a declared map, to-one and to-many relations, a nested join table, an enumeration, lifecycle callbacks, a repository class) both ways and compares them class by class, from the attributes and from declarations gone through JSON; omnisense/omnibase's MetadataSnapshot does it for every class of lesringostars, on both hosts.

Proposer une modification docs/declaration-driver.md

Descriptions

Omnibridge\Doctrine\MetadataDescription (final) writes what an entity manager maps in the neutral form, Omnibridge\Schema\Description (the core's schema): each class as its metadata hold it, and the tables its SchemaTool would create.

MetadataDescription::of(
    EntityManagerInterface $entityManager,
    ?array $classNames = null,     // the classes; those the metadata driver knows when null
    ?\Closure $values = null,      // fn (Doctrine\DBAL\Types\Type $type): ?list<string> - an enumeration's or a set's values
): Description
Static method Returns
of($entityManager, $classNames, $values) the whole description: the classes sorted, each entity(); the tables of SchemaTool::getSchemaFromMetadata() over the entities (not the mapped superclasses nor the embeddables), each table(), sorted by name
entity(ClassMetadata $metadata) one Entity: table (null for a mapped superclass or an embeddable), kind, parent (parentClasses[0]), root (when not itself), inheritance (type, discriminator column, map sorted, value), identifier, fields, relations, subclasses sorted
table(Doctrine\DBAL\Schema\Table $table, ?\Closure $values = null) one Table: columns in order (type by DBAL's name, nullable, length, precision, scale, fixed, unsigned, auto-increment, a scalar default, the values $values gives), primary key, indexes but the primary (flags lower case), foreign keys

What is written as Doctrine keeps it, and what is normalised so the two descriptions compare:

  • the generator: auto, sequence, identity, custom, on the identifier fields only; none is null;
  • an OrderBy direction as ASC or DESC, where Doctrine keeps its Order enumeration (a backed enumeration by its value, Order::Ascending by its case);
  • DBAL's scale of 0 where no precision was said is left out (null);
  • a foreign key's NO ACTION is null, other rules upper case;
  • a join column's nullable defaults to true when Doctrine keeps null.

Comparing descriptions

use Omnibridge\Doctrine\MetadataDescription;
use Omnibridge\Schema\Builder;
use Omnibridge\Schema\Database\MySqlSchema;

$doctrine = MetadataDescription::of($entityManager);
$neutral = (new Builder(rules: $rules))->build($classes);

$doctrine->toJson() === $neutral->toJson();                          // Doctrine and the builder agree
MySqlSchema::read($pdo, ignore: ['doctrine_migration_versions'])->differences($doctrine);   // [] when the database is the schema

DescriptionTest proves the builder's description equal to MetadataDescription's, entity by entity and table by table, through the attribute driver and through the DeclarationDriver, and the tables SchemaTool creates on SQLite equal to the description's. omnisense/omnibase's harness and lesringostars run the same comparison over their own entities; lesringostars compares the result with its MySQL database's information_schema. The Installation page's script makes the comparison on two classes.

Proposer une modification docs/descriptions.md

Naming, types, entity managers

NamingStrategy

Omnibridge\Doctrine\NamingStrategy (not final) implements Doctrine\ORM\Mapping\NamingStrategy by asking a Omnibridge\Schema\Naming (default new CamelCaseNaming()): classToTableName(), propertyToColumnName(), embeddedFieldToColumnName(), referenceColumnName(), joinColumnName(), joinTableName(), joinKeyColumnName() each delegate; naming() gives the Naming. Constants TABLE_NAME_SIZE (64) and TABLE_I18N_SUFFIX (Intl) are CamelCaseNaming's.

Doctrine asks Answer (CamelCaseNaming)
a class's table App\Entity\Thread\Tag threadTag, App\Entity\User\Profile userProfile; a #[Table(name)] kept
a property's column the property, first letter lower case
a join column <property>_id
a join table thread_tags for Thread::$tags
a join table's key column <table>_id: thread_id, threadTag_id

The rules are the core's (Naming). NamingStrategyTest checks them against every table and join table of lesringostars (tests/lesringostars-tables.json). omnisense/omnibase's Base\Database\Mapping\NamingStrategy extends this class.

$configuration->setNamingStrategy(new Omnibridge\Doctrine\NamingStrategy());
// another prefix list: new NamingStrategy(new CamelCaseNaming(['Acme\\']))

Types

Types::register() registers the DBAL types of omnibase's schema, as omnibase registers them (BaseBundle::boot(), BaseExtension::prependDoctrineTypes()); call it before any metadata is read (a field's type is asked while it is). EntityManagers::create() calls it.

Type name Class Behaviour
datetime, datetimetz (overridden) Type\DateTimeType (extends DBAL's DateTimeType) written in UTC - the \DateTime given is moved to UTC in place (setTimezone() on the object itself) -, read as UTC; an unreadable value is DBAL 4's InvalidFormat (a ConversionException)
utc_datetime Type\UtcDateTimeType a \DateTime written in UTC (a copy moved, the value given untouched), read in UTC by the platform's format, else by date_create(), else DBAL's own conversion
utc_datetime_immutable Type\UtcDateTimeImmutableType the same with \DateTimeImmutable

The two utc_* types are added only when no type of that name exists, and are not final (omnibase's core extends them). Type\Utc::zone() is the shared UTC zone. Their getName() and requiresSQLCommentHint() answer DBAL 3's questions; DBAL 4 asks neither.

EntityManagers

EntityManagers::create(
    Doctrine\DBAL\Connection $connection,
    array $paths,                                  // the entities' folders
    bool $development = false,
    ?Psr\Cache\CacheItemPoolInterface $cache = null,
    ?\Closure $configure = null,                   // fn (Doctrine\ORM\Configuration $configuration): void
): Doctrine\ORM\EntityManager
  1. Types::register().
  2. ORMSetup::createAttributeMetadataConfig($paths, $development, null, $cache ?? new ArrayAdapter()): the attribute driver over the folders; without a cache the metadata are kept in memory for the process (ORMSetup's own default, outside development, looks for APCu, Memcached, then a Redis on 127.0.0.1).
  3. setNamingStrategy(new NamingStrategy()).
  4. enableNativeLazyObjects(true) on PHP 8.4 and later, when the configuration has the method.
  5. $configure($configuration): what can no longer change once the entity manager exists - a repository factory, filters, DQL functions, the driver wrapped in a DeclarationDriver.
  6. new EntityManager($connection, $configuration).

Proposer une modification docs/naming-types.md

Errors

The package has no exception class of its own: it throws Doctrine's, as Doctrine would.

Exception Thrown by When
Doctrine\ORM\Mapping\MappingException DeclarationDriver::loadMetadataForClass() a declaration that is no entity, mapped superclass nor embeddable (classIsNotAValidEntityOrMappedSuperClass); an index or unique constraint with both or neither of columns and fields; an attribute invalid on an embeddable; an entity listener class not found; an unknown fetch mode or generated mode - the same factories as Doctrine's attribute driver
Error DeclarationDriver an attribute class of the declaration that doctrine/orm does not have (a declaration written for another version): new $className(...) fails
Doctrine\DBAL\Types\Exception\InvalidFormat (a ConversionException) Type\DateTimeType::convertToPHPValue() a stored value that is not in the platform's date-time format
Doctrine\DBAL\Types\ConversionException UtcDateTimeType, UtcDateTimeImmutableType a value that neither the platform's format nor date_create() reads, through DBAL's own conversion
\LengthException NamingStrategy::classToTableName() (CamelCaseNaming) a table name longer than 64 characters
\OutOfBoundsException the Description returned by MetadataDescription::of() a class or table asked that it does not hold

Proposer une modification docs/errors.md

Testing

Testing an application's mapping

  • The two descriptions: MetadataDescription::of($entityManager) against Builder::build() over the same classes, compared as JSON (Descriptions).
  • The database: MySqlSchema::read($pdo)->differences(MetadataDescription::of($entityManager)) must be [] after the migrations.
  • An entity manager on SQLite in memory (DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true])) with EntityManagers::create() and SchemaTool::createSchema(), as the Installation page does.

The package's suite

No Docker harness of its own: the suite runs in lesringostars-laravel's image, which has PHP 8.4, pdo_sqlite, intl and mbstring, with the checkout's vendor/ installed:

docker run --rm -v "$PWD":/app -w /app lesringostars-laravel-app vendor/bin/phpunit
Test class What it checks
Schema\VocabularyTest every attribute of the core's Vocabulary is doctrine/orm's with the same parameters, in order, the same defaults and repeatability; every mapping attribute of doctrine/orm is in the vocabulary; the lifecycle callbacks are Doctrine's events
Schema\DeclarationDriverTest the metadata are the attribute driver's, class by class; the same from declarations kept as data (through JSON); a class the declarations do not hold is left to the wrapped driver; a class that is no entity is refused as Doctrine refuses it
Schema\DescriptionTest the description built without Doctrine is Doctrine's; the same through the declaration driver, and the tables SchemaTool creates (SQLite)
NamingStrategyTest every table of lesringostars; every join table and its columns; the rules on their own
TypesTest a date written and read in UTC; an unreadable value is a ConversionException as in DBAL 4; the UTC types may be extended
EntityManagersTest it names and keeps moments in UTC; without a cache the metadata are kept in memory, not looked for on a Redis; the configuration is handed over before the entity manager exists

The CI (GitHub Actions, glitchr-studio/ci's package workflow) runs PHPUnit and PHPStan at the highest level against phpstan-baseline.neon.

Proposer une modification docs/testing.md

Verified, and not

What the tests prove

Run on 2026-10-27 in lesringostars-laravel-app (PHP 8.4.27), the checkout's own vendor/ (doctrine/orm 3.7.5, dbal 4.5.0), no network: 18 tests, 464 assertions, green (Testing). The Installation page's script was run in the same image that day and its output pasted: the metadata through the declaration driver, the two descriptions equal, a moment stored in UTC.

Verified elsewhere, as the commits and the README state

  • omnisense/omnibase's harness: the builder's description equal to Doctrine's on every class it maps (202 classes when the Schema was written).
  • lesringostars: MetadataSnapshot (the declaration driver's metadata equal to the attribute driver's) on both hosts, the description compared with its MySQL database (MySQL 8.4.11), the naming checked on its tables (tests/lesringostars-tables.json).

What is not verified

  • doctrine/orm and dbal versions other than 3.7.5 and 4.5.0 (composer.json admits ^3.4 and ^4.2).
  • Databases other than SQLite (the suite) and MySQL (lesringostars): no PostgreSQL run.
  • XML or PHP mappings through the wrapped driver: the fallback is tested with a class the declarations do not hold, not with another mapping format.

Proposer une modification docs/verified.md