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):DeclarationDriverTestcompares 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.DescriptionTestproves it equal to the descriptionOmnibridge\Schema\Builderbuilds from the declarations alone; omnisense/omnibase's harness and lesringostars do the same over their entities, and lesringostars compares it with its database'sinformation_schema.NamingStrategy: Doctrine's naming strategy answered by the family's naming (Omnibridge\Schema\CamelCaseNaming, or theNaminggiven):App\Entity\Thread\TagisthreadTag,App\Entity\User\ProfileuserProfile,Thread::$tagsthe join tablethread_tags (thread_id, threadTag_id), a property its own name. Checked against every table and join table of lesringostars (tests/lesringostars-tables.json). omnibase'sBase\Database\Mapping\NamingStrategyextends it: one place says how a class becomes a table.Types::register():datetimeanddatetimetzwritten and read in UTC,utc_datetimeandutc_datetime_immutable, as omnibase registers them.EntityManagers::create($connection, $paths): an entity manager with both, attribute mapping, PHP's native lazy objects; itsconfigure:closure receives the configuration before the entity manager exists (a repository factory, the driver wrapped in aDeclarationDriver).
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(privatemadeprotected): not needed. omnibase's walkers use public methods only.ClassMetadataFactory(privatemadeprotected): 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.
- omnibase overrides
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
- Installation: an entity manager on SQLite through the declarations, the two descriptions compared, a moment written in UTC - run, with its output.
- The declaration driver: what it reads, what it leaves to the wrapped driver.
- Descriptions:
MetadataDescription, its comparison with the builder's and with the database. - Naming and types:
NamingStrategy, the four UTC types,EntityManagers. - Errors, Testing, Verified, and not.
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 (
DeclarationDriveraround 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; otherwiseMappingException::classIsNotAValidEntityOrMappedSuperClass(), as Doctrine. - The table:
Table(name, schema, options),IndexandUniqueConstraint(columns or fields - both or neither isMappingException::invalidIndexConfiguration()/invalidUniqueConstraintConfiguration()-, flags, options),Cache. - The hierarchy:
InheritanceType,DiscriminatorColumn,DiscriminatorMap;ChangeTrackingPolicy. - Each property, those already mapped where they are declared skipped (Doctrine's
isRepeatedPropertyDeclaration()):ColumnwithId,GeneratedValue,Version,SequenceGenerator,CustomIdGenerator;OneToOne,OneToMany,ManyToOne,ManyToManywithJoinColumns,JoinTable,InverseJoinColumns,OrderBy, the fetch mode (an unknown one isMappingException::invalidFetchMode());Embedded(class, column prefix);Cacheon an association. AssociationOverridesandAttributeOverrides.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'sEntityListenerBuilder::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
OrderBydirection asASCorDESC, where Doctrine keeps itsOrderenumeration (a backed enumeration by its value,Order::Ascendingby its case); - DBAL's scale of 0 where no precision was said is left out (null);
- a foreign key's
NO ACTIONis 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
Types::register().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).setNamingStrategy(new NamingStrategy()).enableNativeLazyObjects(true)on PHP 8.4 and later, when the configuration has the method.$configure($configuration): what can no longer change once the entity manager exists - a repository factory, filters, DQL functions, the driver wrapped in aDeclarationDriver.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)againstBuilder::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])) withEntityManagers::create()andSchemaTool::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.