The core of the omnibridge family: the contracts any host answers for an application that runs on
more than one framework, and the registry the application reaches them through. No framework, no
ORM, no template engine: the package requires PHP and three PSR interfaces (container, clock, event
dispatcher), and its BareTest proves that every class loads with nothing else.
composer require omnisense/omnibridge:1.x-dev
The bridges, omnibridge/<host>, answer these contracts with their host's services and fill the
registry at boot: omnibridge/symfony
(Symfony), omnibridge/laravel (Laravel),
omnibridge/twig (the contract a Twig page
asks of its host), omnibridge/doctrine
(Doctrine ORM over the neutral schema), omnibridge/react
(a React page over a screen's data), omnibridge/static
(a site exported to files) and omnibridge/composer
(patches and stubs at install). A bridge is only the adapter of the contracts for one technique: it
chooses no versions, and an application that speaks only to the contracts changes host by changing
bridge and configuration.
use Omnibridge\Runtime;
Runtime::urls()->path('article', ['slug' => 'le-fuji']); // the host's router
Runtime::translations()->trans('menu.home'); // the host's catalogues
Runtime::access()->isGranted('ROLE_ADMIN'); // the host's security
Runtime::clock()->now(); // the host's clock, else the machine's
Published as omnisense/omnibridge, formerly omniscient/omnibridge: the package replaces the old
name, so a composer.json that still requires omniscient/omnibridge resolves to this package and
never installs both. The namespace is Omnibridge\.
When to use it
- Code that must not know its framework: an entity's getter that makes a link, a value object that
needs the current locale, a screen's builder that asks whether the visitor is an administrator.
It asks
Runtime::urls(),Runtime::locales(),Runtime::access()instead of a router, a request stack or an authorization checker. - An application rendered by two hosts from one code base and one database (lesringostars: Symfony and Laravel), where each host fills the same registry with its own services.
- A persistence layer that must find the same tables as Doctrine without Doctrine: the schema describes the entities' tables from their attributes alone.
It is not a framework, a service container or a dependency-injection library: it holds one service per contract, given by the host. Code that runs inside a framework and can be autowired should keep being autowired - the bridges bind every contract under its interface's name in the host's container too.
The contracts
Omnibridge\Contract\*, each generic to any host (Contracts has every method):
| Contract | What it answers | Why it is generic |
|---|---|---|
Urls |
a route's path and whole URL by its name, a public file's address | every host has named routes and serves public files |
Translations |
a message from the catalogues (ICU), whether a key exists | every host has message catalogues and can tell whether one holds a key |
Locales |
the current, default and available locales (fr-FR) |
every host has a current and a default locale |
Access |
whether the visitor holds an attribute, on a subject; the user | Symfony's authorization checker, Laravel's gate: the same two questions |
Sessions |
a user signed out when it is the one signed in | every host with sign-in has a sign-out |
Tokens |
a form's token for an intent (CSRF), and whether a token sent back is valid | every host protects its forms; the token is checked by the host that made it |
Files |
a stored file's public path and URL, its local path, whether its storage is remote | storages named by the application's configuration, mapped by each bridge to its host's (Flysystem, disks) |
Routes |
a route's declared path, the route of an address, its icons | every router knows a route's pattern and matches an address; icons are metadata, null where the host keeps none |
Environment |
the environment's name, debug, the secret | every host runs in an environment with a secret |
Also here: PSR-20's clock (Runtime::clock(), SystemClock when the host gave none) and PSR-14's
event dispatcher (Runtime::events()), which need no contract of their own.
Which bridge answers which contract:
| Contract | omnibridge/symfony | omnibridge/laravel |
|---|---|---|
Urls |
Omnibridge\Symfony\RouterUrls |
Omnibridge\Laravel\RouterUrls |
Translations |
Omnibridge\Symfony\TranslatorTranslations |
Omnibridge\Laravel\CatalogueTranslations |
Environment |
Omnibridge\Symfony\Contract\KernelEnvironment |
Omnibridge\Laravel\Contract\AppEnvironment |
Access |
Omnibridge\Symfony\Contract\SecurityAccess |
Omnibridge\Laravel\Contract\GateAccess |
Sessions |
Omnibridge\Symfony\Contract\SecuritySessions |
Omnibridge\Laravel\Contract\GuardSessions |
Tokens |
Omnibridge\Symfony\Contract\CsrfTokens |
Omnibridge\Laravel\Contract\SessionTokens |
Locales, Files, Routes |
not bound: omnisense/omnibase binds them (Base\Bridge\Symfony\*) |
AppLocales, DiskFiles, RouterRoutes |
| PSR-20 clock, PSR-14 events | the framework's own services, when the container has them | DateClock, DispatcherEvents |
The registry
Omnibridge\Runtime holds one service per contract, asked by the interface's name. A host fills it
once, at boot: with a PSR-11 container whose ids are those names (setContainer(): Laravel's
application, a Symfony service locator), or one service at a time (set(), which wins). A contract
asked and not filled throws Omnibridge\Exception\MissingContractException (never a silent null),
but the clock; find() answers null for a caller that can do without. A different container
forgets what the previous one gave; what was set by hand stays.
A contract is the same whatever name it is asked by. A family keeps a former name with
Runtime::alias($contract, $former) (a class_alias, and the registry told), declared when
Composer's autoloader is loaded: a service set or bound under either name is the same object, and
the container is asked by the own name, then by the name asked, then by the former names - a host
that still binds only a former name is found by the new one. formerNames() lists them; a missing
contract's exception names them.
The registry is not final: an application family adds its own contracts' accessors in a class of
its own over the same state, as omnisense/omnibase's Base\Contract\Runtime does. The whole API is
in The registry.
The data structure: Omnibridge\Schema
The structure of an application's data - tables, columns and their types, indexes and unique constraints, relations and their join columns, hierarchies and their discriminators, the names the naming gives - described once, outside any ORM (the bridge principle of 2026-10-19: several persistence techniques, the same tables), so that Doctrine reads it today and another persistence (Eloquent or other) maps the same tables from it tomorrow.
Schema\Declaration\*: what the entity classes declare, read from their attributes by reflection and without instantiating them - the attributes areDoctrine\ORM\Mapping's, the vocabulary omnibase's entities are written in, but their classes need not exist:Vocabularyholds each attribute's parameters and defaults as data (omnibridge/doctrine'sVocabularyTestkeeps it equal to doctrine/orm's constructors).AttributeReaderreads a class into aClassDeclaration(its attributes, its properties with the class that declares each, its lifecycle methods);DeclarationSetkeeps declarations as data (a JSON file,toArray()/fromArray()). A persistence bridge maps the classes from aDeclarationssource and never reads the attributes itself.Schema\Description: the neutral description -Entity(table, kind, parent and root,Inheritance, identifier,Fields,Relations with theirJoinColumns orJoinTable), and theTables they make (Columns with their portable type, primary key,Indexes,ForeignKeys). Plain data too (toJson()/fromJson()).Schema\Builder: builds the description from the declarations alone, the way Doctrine completes its metadata (a parent's fields inherited, a type guessed from the property's, default join columns and join tables, default discriminator maps) and the way its SchemaTool makes the tables (one table per joined class, one for a single-table hierarchy, a join table per owning many-to-many, the implicit indexes behind foreign keys, DBAL's generated names).Builder\Ruleis an application's own convention, applied at the three moments an ORM gives its listeners (before the declaration is read, once read, once the class is whole): omnibase's translations, discriminator entries, vaults and order columns are rules in omnisense/omnibase.Schema\Naming/CamelCaseNaming: how the unnamed is named (omnibase's camel case:App\Entity\Thread\TagisthreadTag,Thread::$tagsthe join tablethread_tags (thread_id, threadTag_id)), and a generated index or foreign key name as DBAL generates it. omnibridge/doctrine'sNamingStrategyanswers Doctrine with it.Schema\Database\MySqlSchema: a MySQL schema read frominformation_schema(PDO, read-only) and compared with a description, column by column - the independent proof that the description is the database.
use Omnibridge\Schema\Builder;
$description = (new Builder(rules: $applicationRules))->build($entityClasses);
$description->table('thread')->column('slug'); // string(255), not null
$description->entity(Thread::class)->relation('tags'); // many_to_many, join table thread_tags
file_put_contents('schema.json', $description->toJson());
omnibridge/doctrine reads the same declarations into Doctrine's metadata (DeclarationDriver) and
writes an entity manager's metadata back in this form (MetadataDescription): the two descriptions
are compared in its tests and on each site (omnibase's harness, lesringostars), and the site's with
its database's information_schema. Everything about it is in The schema.
What stays with omnisense/omnibase
The contracts that describe omnibase's own models, not a host, stay Base\Contract\* in
omnisense/omnibase:
| Contract | Why it is omnibase's |
|---|---|
Nouns |
what an omnibase entity is called, by omnibase's rule over its "entities" catalogues (namespaces, politeness, gender) |
Media |
omnibase's image route (ux_image) and its obfuscated data |
Pictures |
omnibase's |picture filter: a stored image at a size, by omnibase's thumbnails |
Texts |
omnibase's stored texts (Editor.js, Quill) and their |wysiwyg rendering |
Base\Screen\ContextProvider |
the frame of omnibase's screens (site, links, viewer, theme) |
SecurityUser, TwoFactorUser, Framework |
what omnibase's User declares to a framework's security, and the rule that decides it |
Link |
a helper over Urls with Symfony's reference types, for omnibase's entities' __toLink() |
Runtime::parameters(), ::mailer() |
omnibase's parameter bag (database settings over the configuration), symfony/mailer's interface |
Former names
Each contract and class here was moved from another package, and kept reachable under its former
name by Runtime::alias() (a class_alias: an alias, not a subclass - one interface, one
reflection name), declared by the package that owned the name, eagerly (Composer's files),
until omnibase 4's final pass:
| Here | Former names |
|---|---|
Omnibridge\Contract\Urls |
Base\Contract\Urls, Omnibridge\Twig\Contract\Urls |
Omnibridge\Contract\Translations |
Base\Contract\Translations, Omnibridge\Twig\Contract\Translations (which asked trans() alone) |
Omnibridge\Contract\{Locales, Access, Sessions, Tokens, Files, Routes, Environment} |
Base\Contract\{...} |
Omnibridge\SystemClock |
Base\Contract\SystemClock |
Omnibridge\Exception\MissingContractException |
Base\Exception\MissingContractException |
That pass is done: omnisense/omnibase removed its aliases (commit 02682623, its
src/Resources/Aliases.php) and omnibridge/twig its own (commit 976de23), once no site and no
package named them. Name the classes of the left column. No package declares a former name today;
the mechanism stays, for the next move (The registry).
Base\Contract\Runtime was never an alias: it is omnibase's registry over this one, with omnibase's
own accessors.
How this manual is organised
- Installation: requirements, a plain PHP script and its output, the hosts.
- Contracts: each interface, every method, what a bridge must answer.
- The registry:
Runtimemethod by method - filling it, asking it, former names, a family's own registry. - The schema: declarations, vocabulary, the builder and its rules, the description's classes field by field, the naming, the MySQL comparison.
- Errors: every exception and when it is thrown.
- Testing: testing code that asks the registry, the package's own suite and its Docker harness.
- Verified, and not.
Licence
MIT.
Proposer une modification docs/index.md
Installation
Requirements
| Requirement | Version | Why |
|---|---|---|
| PHP | 8.2 or later | readonly properties, enumerations, first-class callables |
psr/container |
^1.1 or ^2.0 | Runtime::setContainer() takes a PSR-11 container |
psr/clock |
^1.0 | Runtime::clock(), SystemClock |
psr/event-dispatcher |
^1.0 | Runtime::events() |
Nothing else is required: no framework, no ORM, no extension that composer.json declares. Two optional needs:
Schema\Database\MySqlSchema::read()takes a\PDOconnected to MySQL (ext-pdo,ext-pdo_mysqlin the application).Schema\CamelCaseNamingcallsmb_strtolower(),mb_substr()andmb_strtoupper().
composer require omnisense/omnibridge:1.x-dev
The branch 1.x is published as 1.x-dev (extra.branch-alias). The package replaces
omniscient/omnibridge, its former name.
Plain PHP
The registry needs no host: set the services by hand. A complete script, with an Urls of its own:
<?php
require 'vendor/autoload.php';
use Omnibridge\Contract\Urls;
use Omnibridge\Exception\MissingContractException;
use Omnibridge\Runtime;
final class PlainUrls implements Urls
{
public function path(string $name, array $parameters = []): string
{
return '/'.$name.($parameters ? '/'.implode('/', $parameters) : '');
}
public function url(string $name, array $parameters = []): string
{
return 'https://example.org'.$this->path($name, $parameters);
}
public function asset(string $path): string
{
return '/build/'.ltrim($path, '/');
}
}
Runtime::alias(Urls::class, 'Legacy\Contract\Urls');
Runtime::set(Urls::class, new PlainUrls());
echo Runtime::urls()->path('article', ['slug' => 'le-fuji']), "\n";
echo Runtime::get('Legacy\Contract\Urls')->url('article', ['slug' => 'le-fuji']), "\n";
echo get_class(Runtime::clock()), "\n";
var_dump(Runtime::find(\Omnibridge\Contract\Access::class));
try {
Runtime::translations();
} catch (MissingContractException $e) {
echo $e->getMessage(), "\n";
}
Its output, run in the package's harness image (PHP 8.2.34, 2026-10-27):
/article/le-fuji
https://example.org/article/le-fuji
Omnibridge\SystemClock
NULL
No Omnibridge\Contract\Translations in the runtime: the host fills it at boot (its omnibridge bridge, omnisense/omnibase's bundle under Symfony), or Omnibridge\Runtime::set() / setContainer() before it is asked.
- The former name
Legacy\Contract\Urlsis the same interface (class_alias): asked by it, the registry answers the service set under the new name. - No clock was given: the machine's (
SystemClock). find()answers null for a contract nobody filled;translations()(like every accessor butclock()) throws.
A container works the same way: any PSR-11 container whose ids are the interfaces' names.
Runtime::setContainer($container); // $container->has(Urls::class), ->get(Urls::class)...
Under a framework
The application does not fill the registry itself: its bridge does, at boot.
| Host | Package | What fills the registry |
|---|---|---|
| Symfony | omnibridge/symfony |
OmnibridgeBundle::boot() hands Runtime the service locator omnibridge.contracts; on an omnisense/omnibase site, BaseBundle does it with omnibase's own contracts added (omnibridge/symfony) |
| Laravel | omnibridge/laravel |
OmnibridgeServiceProvider binds each contract under its interface's name and hands Runtime the application itself (omnibridge/laravel) |
| Twig, anywhere | omnibridge/twig |
nothing: its extensions ask the contracts they are given, or Runtime |
Each bridge's manual has its own installation page. A host bridge requires this package; this package requires none of them.
Proposer une modification docs/installation.md
Contracts
Nine interfaces under Omnibridge\Contract\. Each says what an application asks of its host, never
how the host answers: a bridge implements it over its framework's services
(which bridge answers which). Every one of them was
Base\Contract\<Name> in omnisense/omnibase before; those former names were removed at omnibase 4's
final pass (Former names).
The application reaches a contract through the registry (Runtime::urls()...,
The registry) or, inside a framework, by autowiring the interface: both bridges bind
every adapter under its interface's name.
Urls
Addresses, as an application asks for them: a route by its name, as a path or whole, and a public file of the build.
| Method | Returns | Meaning |
|---|---|---|
path(string $name, array $parameters = []) |
string |
the route's path (/articles/le-fuji), the parameters filled in as the host's router fills them |
url(string $name, array $parameters = []) |
string |
the same route whole, with the scheme and the host (a mail, a feed, a link shared elsewhere) |
asset(string $path) |
string |
the public address of a file of the build (build/app.css), through the host's asset packages |
A route that does not exist is the host router's error (Symfony's RouteNotFoundException,
Laravel's RouteNotFoundException or InvalidArgumentException): the contract adds none of its
own. Answered by Omnibridge\Symfony\RouterUrls and Omnibridge\Laravel\RouterUrls; a Twig page
asks the same object through omnibridge/twig's path(), url() and asset().
Translations
The application's texts: a message in the page's language, from its catalogues (ICU messages:
{0} and {name} placeholders, plurals), and whether the catalogues hold a key at all.
| Method | Returns | Meaning |
|---|---|---|
trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null) |
string |
the message, its parameters substituted; the page's locale when none is given; the host's default domain when none is given |
exists(string $id, ?string $domain = null, ?string $locale = null) |
bool |
whether the catalogue of that locale (the page's when none is given) holds the key in that domain, its fallback catalogues included |
exists() is for code that tries several keys in turn (an entity's name in a namespace, then in
its parent's). Formerly two interfaces: Omnibridge\Twig\Contract\Translations (with trans()
alone) and Base\Contract\Translations (with exists()), both removed since; now one, so one object answers a page and
the application's code alike. Answered by Omnibridge\Symfony\TranslatorTranslations and
Omnibridge\Laravel\CatalogueTranslations (which reads ICU catalogues with symfony/translation).
Locales
The languages of the site, language and country joined by a dash (fr-FR, en-GB). A bridge
whose host names them otherwise (Laravel's fr) gives them in this form. Named in the plural so as
not to shadow PHP's \Locale where it is imported.
| Method | Returns | Meaning |
|---|---|---|
getLocale() |
string |
the locale of the request being answered, or of the command |
getDefaultLocale() |
string |
the site's default locale, the language its texts are written in first |
getAvailableLocales() |
list<string> |
every locale the site speaks, the default first |
Answered by Omnibridge\Laravel\Contract\AppLocales; under Symfony by omnisense/omnibase's
Base\Bridge\Symfony\LocalizerLocales (omnibridge/symfony binds none: an application's languages
are its own).
Access
What the visitor may do: an attribute (a role, ROLE_ADMIN, or a permission a policy answers,
edit on a subject) granted or not, and who the visitor is.
| Method | Returns | Meaning |
|---|---|---|
isGranted(mixed $attribute, mixed $subject = null) |
bool |
whether the visitor holds the attribute, on the subject when one is given; false for nobody signed in unless the attribute is granted to anyone |
getUser() |
?object |
the visitor signed in (the application's user), null for nobody |
Symfony's authorization checker and token storage (Omnibridge\Symfony\Contract\SecurityAccess),
Laravel's gate and guard (Omnibridge\Laravel\Contract\GateAccess) answer the same two questions.
Sessions
The visitor's session, as the application ends it.
| Method | Returns | Meaning |
|---|---|---|
signOut(object $user) |
bool |
signs the user out if it is the visitor signed in here (the session forgotten, a remember-me cookie cleared) and answers true; false when it is not (nobody, or someone else), and nothing is done |
omnisense/omnibase's User::logout() asks it first, and kicks the user out of its sessions elsewhere when it answers false. Symfony's token storage
(SecuritySessions), Laravel's guard logout() (GuardSessions).
Tokens
The token a form carries for an intent, against cross-site requests: what a page's POST (or a script's request) sends back and its action checks.
| Method | Returns | Meaning |
|---|---|---|
csrf(string $intent) |
string |
the token of that intent (profile_edit, delete_12...) for the visitor's session |
isValid(string $intent, ?string $token) |
bool |
whether a token sent back is the visitor's token of that intent: what an action checks before acting; a missing or empty token is never valid |
Symfony's CSRF token manager has one token per intent (CsrfTokens); Laravel's session token is
one for every intent (SessionTokens). A token made by one host is checked by that host only: a
page rendered by Symfony posts to Symfony.
Files
The stored files as the application sees them: where a file is served from and where PHP can read it, by its path in a storage. Nothing of how it is stored (Flysystem, a disk, a bucket).
A storage is named as the application's configuration names it (local.uploads, s3.private...),
null for the default one; each bridge maps those names to its host's storages.
| Method | Returns | Meaning |
|---|---|---|
publicPath(string $path, ?string $storage = null) |
?string |
the file's path under the public directory the web server serves, or for a remote storage the address it is served from; null when it has neither (a missing file, a private storage) |
publicUrl(string $path, ?string $storage = null) |
?string |
the web address of the file (/uploads/...); null as publicPath() |
localPath(string $path, ?string $storage = null) |
?string |
the file's path on this machine (a local storage, its root prefixed); null where no local file is behind it (a remote storage) |
isRemote(?string $storage = null) |
bool |
whether the storage is remote: no file on this machine behind its paths |
Answered by Omnibridge\Laravel\Contract\DiskFiles (Laravel's disks); under Symfony by
omnisense/omnibase's Base\Bridge\Symfony\FlysystemFiles.
Routes
What the routes are beyond their addresses (Urls makes those).
| Method | Returns | Meaning |
|---|---|---|
pattern(string $name) |
?string |
the path the route is declared with (/articles/{slug}), null for no such route |
match(string $url) |
?string |
the name of the route an address leads to - a localized route's with its language (article.en) when it is not the default one -, null when none does |
icons(string $name) |
list<string>|null |
the icons the route is declared with, in the page's language when it has several; null for none |
Icons are a route's metadata where the host keeps some (omnibase's #[Iconize] under Symfony); a
host without answers null. Answered by Omnibridge\Laravel\Contract\RouterRoutes; under Symfony by
omnisense/omnibase's Base\Bridge\Symfony\RouterRoutes.
Environment
What the application runs as.
| Method | Returns | Meaning |
|---|---|---|
name() |
string |
the environment's name: prod, dev, test... |
isDebug() |
bool |
whether it runs in debug |
secret() |
string |
the application's secret, which salts what it hashes |
Symfony's kernel.environment, kernel.debug and kernel.secret (KernelEnvironment); Laravel's
APP_ENV, app.debug and app.key (AppEnvironment).
The clock and the events
No contract of their own: the PSR interfaces are already generic.
Psr\Clock\ClockInterface, asked byRuntime::clock(). A host gives its own (symfony/clock's, which a test can freeze; omnibridge/laravel'sDateClockover Laravel's dates). When none was given, the registry answersOmnibridge\SystemClock, whosenow()isnew \DateTimeImmutable()- the machine's time in PHP's default time zone.Psr\EventDispatcher\EventDispatcherInterface, asked byRuntime::events(): Symfony'sevent_dispatcher, omnibridge/laravel'sDispatcherEventsover Laravel's dispatcher. There is no fallback: asked and not given, it throwsMissingContractException.
Writing an adapter
A host that has no bridge yet implements the interfaces it can answer and fills the registry (The registry). An adapter:
- answers for the current request or command, not for the one it was built in: a long-running worker builds it once (read the request, the locale, the user when called, not in the constructor);
- throws its framework's own exception for what the framework refuses (an unknown route); the contract adds none;
- gives locales as
xx-XX, whatever its host's own form.
An application that runs on two hosts asserts the same answers on both: lesringostars' parity suite renders the same pages through both bridges and compares them.
Proposer une modification docs/contracts.md
The registry
Omnibridge\Runtime is the registry through which an application's framework-free code reaches the
contracts its host answers: an entity's getter, a value object, a screen's builder asks
Runtime::urls(), ::access()... instead of a framework's services. Every method is static; the
state is the class's own (private static properties), shared by any class that extends it.
Filling the registry
A host fills it once, at boot, in one of two ways - or both.
With a container
Runtime::setContainer(?Psr\Container\ContainerInterface $container): void
Runtime::getContainer(): ?Psr\Container\ContainerInterface
A PSR-11 container whose ids are the contracts' interface names. Under Symfony it is a service
locator (omnibridge.contracts, made by omnibridge/symfony's ContractsPass; base.contracts on an
omnisense/omnibase site); under Laravel the application itself, whose bindings are named by the
interfaces (omnibridge/laravel's OmnibridgeServiceProvider).
- Each contract is resolved from the container the first time it is asked, then kept.
- A different container (another kernel booted, a test's new application) forgets every service resolved from the previous one. Setting the same container again keeps them.
setContainer(null)detaches the container: omnibridge/symfony's bundle does it at shutdown, when the registry still holds its locator.
One service at a time
Runtime::set(string $contract, ?object $service): void
A service given by hand wins over the container's. It must implement the contract, or
MissingContractException is thrown at once ("The runtime's X is a Y, which does not implement
it."). set($contract, null) takes it back, and forgets what the container gave for it too, so the
next call resolves again.
Asking the registry
| Method | Returns | When nothing answers |
|---|---|---|
Runtime::get(string $contract) |
the service (T for class-string<T>) |
throws MissingContractException, naming the contract and its former names |
Runtime::find(string $contract) |
the service or null |
null, for a caller that can do without |
Runtime::has(string $contract) |
bool |
false; true when set by hand, already resolved, or the container has an id for it |
The order of the lookup: the service set by hand, then the one already resolved, then the
container, asked by the contract's own name, then by the name asked, then by each former name
(the first id the container has() is used). A service the container gives that does not implement
the contract throws MissingContractException::notAn().
Any interface name can be asked: get() is not limited to the nine contracts. A family keeps its
own contracts in the same registry (omnibase's Base\Contract\Nouns...).
The accessors
Typed shortcuts for get():
| Accessor | Contract |
|---|---|
Runtime::urls() |
Omnibridge\Contract\Urls |
Runtime::translations() |
Omnibridge\Contract\Translations |
Runtime::locales() |
Omnibridge\Contract\Locales |
Runtime::access() |
Omnibridge\Contract\Access |
Runtime::sessions() |
Omnibridge\Contract\Sessions |
Runtime::tokens() |
Omnibridge\Contract\Tokens |
Runtime::files() |
Omnibridge\Contract\Files |
Runtime::routes() |
Omnibridge\Contract\Routes |
Runtime::environment() |
Omnibridge\Contract\Environment |
Runtime::clock() |
Psr\Clock\ClockInterface; Omnibridge\SystemClock when nobody gave one (never throws) |
Runtime::events() |
Psr\EventDispatcher\EventDispatcherInterface |
The SystemClock fallback is kept as resolved: a container set afterwards (a different one) forgets
it, and the host's clock is asked again.
Resetting
Runtime::reset(): void
Everything forgotten: no container, nothing set, nothing resolved. The former names stay: they are
declarations made when the autoloader loaded, not state. A test's tearDown() calls it
(Testing).
Former names
Runtime::alias(string $contract, string $former): void
Runtime::formerNames(string $contract): list<string>
alias() keeps a contract (or any class of a family) reachable under a former name:
class_alias($contract, $former)- an alternate name, not a subclass: one interface, one reflection name. Skipped when a class or interface of that name is already loaded.- The registry told: a service bound in the container under the former name only is found when
the new name is asked, and
MissingContractExceptionlists the former names.
The package that owned the name declares it, when Composer's autoloader loads (its autoload.files),
never on demand: PHP autoloads nothing for a parameter's type or an instanceof, so a lazy alias
would leave a type hint of the former name unknown. Declaring the same pair twice is harmless.
A name is always resolved to its own: Runtime::get('Base\Contract\Urls') and
Runtime::get(Urls::class) answer the same object, set under either name.
A family's own registry
Runtime is not final. An application family adds its own contracts' accessors in a class of its
own over the same state:
namespace Acme\Contract;
use Omnibridge\Runtime as Registry;
final class Runtime extends Registry
{
public static function invoices(): Invoices
{
return self::get(Invoices::class);
}
}
Acme\Contract\Runtime::urls() and Omnibridge\Runtime::urls() answer the same service; a service
set through either class is seen by both (the core's tests check it with a fixture
FamilyRuntime). omnisense/omnibase's Base\Contract\Runtime is such a class: its parameters(),
nouns(), media(), pictures(), texts() and mailer().
Where to ask it, and where not
- Ask it in code that cannot be given its dependencies: an entity, a value object, a static helper, a Twig page's data built outside a controller.
- In a service a framework builds, prefer a constructor argument typed by the contract: both bridges bind each adapter under its interface's name, and the test needs no registry.
- Ask at the moment of use, not in a constructor or a static initialiser: a long-running worker (FrankenPHP, Octane) may replace the container between two requests.
Proposer une modification docs/registry.md
The schema
Omnibridge\Schema describes the structure of an application's data once, outside any ORM: the
entities - each class, its table, its fields and relations, its inheritance - and the tables they
make, with their columns, keys, indexes and foreign keys. Three steps:
- Declarations (
Schema\Declaration\*): what the entity classes declare, read from their attributes without instantiating them, or kept as data. - The builder (
Schema\Builder): the declarations completed the way Doctrine completes its metadata, an application's rules applied, then the tables Doctrine's SchemaTool would create. - The description (
Schema\Description): plain, readonly data - entities and tables - that a persistence bridge maps, a JSON file keeps, andSchema\Database\MySqlSchemacompares with a database.
omnibridge/doctrine is the Doctrine side: its DeclarationDriver gives Doctrine its metadata from the
same declarations, its MetadataDescription reads an entity manager's metadata back into a
Description, and its tests compare the two.
A complete example
Two entities, a many-to-one and a many-to-many relation, described with no ORM installed (the
Doctrine\ORM\Mapping attribute classes need not exist):
<?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(nullable: true)]
public ?\DateTimeImmutable $publishedAt = null;
#[ORM\ManyToOne]
public ?Thread\Tag $mainTag = null;
#[ORM\ManyToMany(targetEntity: Thread\Tag::class)]
public iterable $tags = [];
}
}
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;
}
}
namespace {
require 'vendor/autoload.php';
use Omnibridge\Schema\Builder;
$description = (new Builder())->build([App\Entity\Thread::class, App\Entity\Thread\Tag::class]);
foreach ($description->tables as $table) {
echo $table->name, ' (', implode(', ', $table->primaryKey), ")\n";
foreach ($table->columns as $column) {
echo ' ', $column->name, ' ', $column->type, $column->length ? '('.$column->length.')' : '', $column->nullable ? ' null' : ' not null', $column->autoincrement ? ' auto_increment' : '', "\n";
}
foreach ($table->indexes as $index) {
echo ' index ', $index->name, $index->unique ? ' unique' : '', ' (', implode(', ', $index->columns), ")\n";
}
foreach ($table->foreignKeys as $key) {
echo ' foreign key ', $key->name, ' (', implode(', ', $key->columns), ') -> ', $key->table, ' (', implode(', ', $key->referencedColumns), ')', $key->onDelete ? ' on delete '.$key->onDelete : '', "\n";
}
}
$tags = $description->entity(App\Entity\Thread::class)->relation('tags');
echo $tags->kind, ' ', $tags->target, ' join table ', $tags->joinTable->name, "\n";
echo json_encode($description->table('thread')->column('slug')->toArray()), "\n";
}
Its output (the package's harness image, PHP 8.2.34, 2026-10-27):
thread (id)
id integer not null auto_increment
slug string(255) not null
publishedAt datetime_immutable null
mainTag_id integer null
index IDX_31204C83941A39FD (mainTag_id)
index UNIQ_31204C83989D9B62 unique (slug)
foreign key FK_31204C83941A39FD (mainTag_id) -> threadTag (id)
threadTag (id)
id integer not null auto_increment
name string(64) not null
thread_tags (thread_id, threadTag_id)
thread_id integer not null
threadTag_id integer not null
index IDX_EB0D88A83DC29580 (threadTag_id)
index IDX_EB0D88A8E2904019 (thread_id)
foreign key FK_EB0D88A83DC29580 (threadTag_id) -> threadTag (id) on delete CASCADE
foreign key FK_EB0D88A8E2904019 (thread_id) -> thread (id) on delete CASCADE
many_to_many App\Entity\Thread\Tag join table thread_tags
{"name":"slug","type":"string","length":255}
What the builder completed: the column types from the properties' types (int integer,
string string of 255, ?\DateTimeImmutable datetime_immutable), the table names by the camel
case naming (App\Entity\Thread\Tag is threadTag), the many-to-one's target from the property's
type and its join column mainTag_id, the join table thread_tags with a column toward each side
(on delete cascade), the auto-increment of an AUTO generated identifier, the unique index of the
unique column, the index behind each foreign key, and every generated name as DBAL generates it.
The plain form of a column leaves out what its constructor would give anyway (nullable: false).
Declarations
Reading the attributes: AttributeReader
use Omnibridge\Schema\Declaration\AttributeReader;
$declaration = (new AttributeReader())->read(App\Entity\Thread::class); // ?ClassDeclaration
read(string|\ReflectionClass $class): ?ClassDeclaration reads a class by reflection: each
attribute's name and arguments (ReflectionAttribute::getArguments()), never newInstance(), so the
attribute classes need not exist and doctrine/orm need not be installed.
- Only the attributes of the vocabulary are read (
Doctrine\ORM\Mapping\*with a name listed inVocabulary::PARAMETERS); every other attribute is ignored. - The properties are read in the order reflection lists them, inherited ones included, each with the class that declares it; a property without a mapping attribute is left out. Of the methods, only public ones that carry a mapping attribute (the lifecycle callbacks).
- A positional argument is named after the parameter it fills (
#[ORM\Column('slug')]isname: 'slug'); a nested attribute written as an object (new ORM\JoinColumn(...)inside aJoinTable) becomes anAttributeof its initialised properties, those equal to their default left out. That object is instantiated by PHP when the arguments are read: its class must exist. - A constant written as an argument (
UtcDateTimeImmutableType::NAME) is evaluated by PHP, so its class must load. - A class that is neither
Entity,MappedSuperclassnorEmbeddablereadsnull. Each class is read once per reader.
What a declaration holds
| Class | Public API | Meaning |
|---|---|---|
ClassDeclaration |
$class, $attributes (list<Attribute>), $properties (list<Member>), $methods (list<Member>) |
what a class declares and nothing resolved: no default name, no type guessed |
kind(): ?string |
Entity, MappedSuperclass or Embeddable, null for a class the mapping does not hold |
|
attribute(string $name): ?Attribute |
the class attribute of that name, the last written | |
attributes(string $name): list<Attribute> |
every one of that name (a repeatable Index) |
|
property(string $name): ?Member |
one property | |
Member |
$name, $class (the declaring class), $attributes |
a property or a method with its mapping attributes |
attribute(), attributes() |
as above | |
Attribute |
$name (Column, ManyToOne...), $arguments (by parameter name, as written) |
one attribute |
has(string $argument): bool |
whether it was written | |
get(string $argument): mixed |
as written, else the vocabulary's default | |
all(): array |
every parameter: as written, else its default (the required ones without a default left out) |
Each has toArray() and fromArray(): the plain form, where a nested attribute is
{"@attribute": {"name": ..., "arguments": ...}} and empty lists are left out.
Keeping declarations as data: DeclarationSet
use Omnibridge\Schema\Declaration\AttributeReader;
use Omnibridge\Schema\Declaration\DeclarationSet;
$set = DeclarationSet::of(new AttributeReader(), $entityClasses);
file_put_contents('declarations.json', json_encode($set->toArray()));
$again = DeclarationSet::fromArray(json_decode(file_get_contents('declarations.json'), true));
$builder = new Omnibridge\Schema\Builder($again);
DeclarationSet implements Declarations over a list of ClassDeclarations: of() gathers the
declarations of classes from another source (the classes it does not map left out), read() answers
by class name, all() lists them by class, toArray()/fromArray() are the plain form. A set can
come from a file written once, or from another vocabulary turned into this one.
The builder still reflects on the classes themselves (their parents, their properties' types): a
DeclarationSet replaces the attributes, not the classes.
The Declarations interface
interface Declarations
{
public function read(string|\ReflectionClass $class): ?ClassDeclaration;
}
Where the builder and omnibridge/doctrine's DeclarationDriver get the declarations from.
AttributeReader and DeclarationSet are the two implementations here.
The vocabulary
Vocabulary holds the mapping attributes as data, kept equal to doctrine/orm 3's constructors
(omnibridge/doctrine's VocabularyTest compares them parameter by parameter):
| Constant or method | Meaning |
|---|---|
NAMESPACE |
Doctrine\ORM\Mapping\ - the namespace whose attributes are read |
PARAMETERS |
attribute => parameter => default, in constructor order; REQUIRED for a parameter without a default |
REPEATABLE |
Index, InverseJoinColumn, JoinColumn, UniqueConstraint |
CALLBACKS |
the lifecycle attributes by the event they name: PrePersist => prePersist, PostPersist, PreUpdate, PostUpdate, PreRemove, PostRemove, PostLoad, PreFlush |
REQUIRED |
the marker "\0required" |
nameOf(string $className): ?string |
Doctrine\ORM\Mapping\Column => Column, null outside the vocabulary |
parameters(string $name): list<string> |
the parameters in constructor order |
defaults(string $name): array |
the optional parameters and their defaults |
defaultOf(string $name, string $parameter): mixed |
one default, null for a required parameter |
isRepeatable(string $name): bool |
The attributes it knows: AssociationOverride, AssociationOverrides, AttributeOverride,
AttributeOverrides, Cache, ChangeTrackingPolicy, Column, CustomIdGenerator,
DiscriminatorColumn, DiscriminatorMap, Embeddable, Embedded, Entity, EntityListeners,
GeneratedValue, HasLifecycleCallbacks, Id, Index, InheritanceType, InverseJoinColumn,
JoinColumn, JoinColumns, JoinTable, ManyToMany, ManyToOne, MappedSuperclass,
OneToMany, OneToOne, OrderBy, the eight lifecycle attributes, SequenceGenerator, Table,
UniqueConstraint, Version. All are read into declarations; the builder
uses a subset of them.
The builder
use Omnibridge\Schema\Builder;
use Omnibridge\Schema\CamelCaseNaming;
use Omnibridge\Schema\Declaration\AttributeReader;
$builder = new Builder(
declarations: new AttributeReader(), // or a DeclarationSet
naming: new CamelCaseNaming(), // how the unnamed is named
rules: [], // list<Builder\Rule>, the application's conventions
autoGenerator: 'identity', // what GeneratedValue(strategy: 'AUTO') becomes
);
$description = $builder->build($classNames);
| Constructor argument | Type | Default | Effect |
|---|---|---|---|
declarations |
Declarations |
new AttributeReader() |
where the declarations come from |
naming |
Naming |
new CamelCaseNaming() |
table, column, join column, join table and generated index names |
rules |
list<Builder\Rule> |
[] |
applied to each class, in order, at three moments |
autoGenerator |
string |
'identity' |
the generator an AUTO identifier becomes: identity is an auto-increment column (MySQL's) |
| Method | Returns | Meaning |
|---|---|---|
build(iterable $classNames) |
Description |
the classes given that the declarations map (others skipped), each entity sorted by class name, then their tables; each call starts afresh |
classes() |
list<string> |
the classes being described, in the order given (for a rule that needs the whole set) |
naming() |
Naming |
|
declaration(string $className) |
?ClassDeclaration |
a class's declaration, null for a class that does not exist |
draft(string $className) |
Builder\Draft |
the class as described so far, its parents first: what an ORM's metadata factory hands out; built on demand, kept for the rest of the build |
What the builder reads
Following Doctrine's ClassMetadataFactory and attribute driver, for each class, its mapped parents
first:
- The parent's state: inheritance type, discriminator column and map, generator, every field and
relation (marked
inheritedfrom an entity parent,declaredin the class that declares it), identifier, subclasses. A single-table child takes its root's table name, indexes, unique constraints and options; a mapped superclass's indexes are copied into its children. - The class:
MappedSuperclass,Embeddable;Table(name, backquotes stripped, andoptions); eachIndexandUniqueConstraintwritten on the class (columnsorfields,flags,options, by name or by position);InheritanceType,DiscriminatorColumn(defaultdtype,string, 255) andDiscriminatorMap. - Each property not inherited: a
Columnis a field (type,length,precision,scale,unique,nullable,index,options,enumType,namewith backquotes stripped;Idand itsposition;GeneratedValue'sstrategy); else aOneToOne,OneToMany,ManyToOneorManyToManyis a relation (targetEntity,mappedBy,inversedBy,indexBy,OrderBy,JoinColumns,JoinTableandInverseJoinColumns,Id). - Completion (
Draft::mapField(),mapRelation()):- a field's type from its property's type when it names none:
\DateIntervaldateinterval,\DateTimedatetime,\DateTimeImmutabledatetime_immutable,arrayjson,boolboolean,floatfloat,intinteger,stringstring; a backed enumeration its backing type, withenumTypeset (a pure enumeration is aLogicException); otherwisestring; - a column name from the naming; a duplicate column (or one named as the discriminator) is a
LogicException; - a to-one relation's target from its property's type when it names none; a target without a
namespace is taken in the class's namespace; a relation without target is a
LogicException; - the owning side: no
mappedBy, and never a one-to-many; - an owning to-one's join columns: one by default,
<property>_idtowardid, nullable unless the relation is part of the identifier, unique for a one-to-one outside a single-table hierarchy; - an owning many-to-many's join table: its name from the naming, its join columns toward each
side (
<table>_id, not null, on deleteCASCADEwhen the join table names none; a relation of a class to itself names them<table>_sourceand<table>_target); - the identifier in
Idposition order.
- a field's type from its property's type when it names none:
- The generator: a child of an entity keeps its root's; an
AUTOroot's becomesautoGenerator. - The default discriminator map, for a root of a hierarchy that declares none: the class and each of its subclasses among the classes given, each under its short name in lower case.
Then the tables, following SchemaTool over DBAL: one table per class of a joined hierarchy (a
child's identifier its primary key and a foreign key to the root's table, on delete CASCADE, not
auto-increment), one table for a single-table hierarchy (the children's columns nullable, the
discriminator in it), the discriminator column in the root's table, a join table per owning
many-to-many (its primary key both sides' columns), a join column per owning to-one of its referenced
column's type, a unique index per unique column, an index per index: true column, the declared
indexes and unique constraints (a unique constraint replacing a plain index of the same columns),
an index behind each foreign key that no index of the same columns already backs (and dropped when
a later index fulfils it), an index the primary key spans dropped. A string column without a length
is 255 long. A default option is kept when it is a scalar (a backed enumeration's value).
Not described, so a description differs from Doctrine's tables when they are used:
Embeddedproperties (no column for them;Naming::embeddedFieldToColumnName()is there but the builder never asks it),AttributeOverridesandAssociationOverrides, theindexesanduniqueConstraintsarguments of#[Table](write#[Index]and#[UniqueConstraint]on the class),Table'sschema,SequenceGeneratorandCustomIdGenerator's classes, and the collations, charsets and comments of columns. omnibase's entities use none of them.
Rules
interface Rule
{
public function before(Draft $class, ClassDeclaration $declaration, Builder $builder): void;
public function declared(Draft $class, ClassDeclaration $declaration, Builder $builder): void;
public function loaded(Draft $class, ClassDeclaration $declaration, Builder $builder): void;
}
An application's own convention over what its classes declare - what an ORM's listeners add to its metadata. Applied to each class, every rule in order, at the three moments an ORM gives its listeners:
| Moment | State of the draft | Doctrine's equivalent |
|---|---|---|
before() |
the parent's state copied, the class's declaration not read yet | before the driver loads the class |
declared() |
the declaration read (fields, relations, table) | after loadMetadataForClass() |
loaded() |
the generator and the discriminator map settled: the class is whole | loadClassMetadata |
A rule changes the draft through its public properties and mapField(), mapRelation(),
setDiscriminatorMap(), addSubClass(), setIdentifier(); $builder->draft() gives another class
(a translation's parent), $builder->classes() the whole set. omnisense/omnibase's rules (its
translations' relations, its discriminator entries, its vaults' unique keys, its order columns) are
rules of this kind.
use Omnibridge\Schema\Builder;
use Omnibridge\Schema\Builder\Draft;
use Omnibridge\Schema\Builder\Rule;
use Omnibridge\Schema\Declaration\ClassDeclaration;
/** Every entity gets a nullable `locale` column, unless it maps one. */
final class LocaleRule implements Rule
{
public function before(Draft $class, ClassDeclaration $declaration, Builder $builder): void {}
public function declared(Draft $class, ClassDeclaration $declaration, Builder $builder): void
{
if (!$class->isMappedSuperclass && !$class->hasField('locale')) {
$class->mapField(['fieldName' => 'locale', 'type' => 'string', 'length' => 5, 'nullable' => true]);
}
}
public function loaded(Draft $class, ClassDeclaration $declaration, Builder $builder): void {}
}
Given to the example's builder (new Builder(rules: [new LocaleRule()])), it
adds locale string(5) null to thread and to threadTag (run in the harness image, 2026-10-27).
The draft
Builder\Draft is a class while it is built, its mappings in plain arrays:
| Property | Type | Meaning |
|---|---|---|
name |
string (readonly) |
the class |
parentClasses |
list<string> |
the entities above it, the nearest first (not the mapped superclasses) |
rootEntityName |
string |
the root of its hierarchy (itself for a root) |
isMappedSuperclass, isEmbeddedClass |
bool |
|
table |
array{name, indexes?, uniqueConstraints?, options?} |
the table name defaults to the naming's |
inheritanceType |
string |
none (Draft::NONE), single_table or joined |
discriminatorColumn |
?array{name, type, length} |
|
discriminatorMap |
array<string, string> |
value => class |
discriminatorValue |
?string |
this class's value in the map |
subClasses |
list<string> |
|
generatorType |
string |
none, auto, identity, sequence, custom... |
identifier, identifierPositions |
list<string>, array<string, int> |
|
fieldMappings |
array<string, array> |
property => {fieldName, columnName, type, length, precision, scale, nullable, unique, index, options, id, enumType, inherited, declared} |
fieldNames |
array<string, string> |
column => property |
associationMappings |
array<string, array> |
property => {fieldName, type, targetEntity, sourceEntity, mappedBy, inversedBy, isOwningSide, joinColumns, joinTable, orderBy, indexBy, id, inherited, declared} |
Methods: naming(), isInheritanceTypeNone(), isInheritanceTypeSingleTable(),
isInheritanceTypeJoined(), isRootEntity(), hasField(), hasAssociation(), mapField(array),
mapRelation(array), setDiscriminatorMap(array), addSubClass(string) (only a real subclass),
setIdentifier(array $identifier, array $positions).
The description
Every class of the description is final, readonly, and carries toArray() and fromArray()
(the Exports trait: each property by its name, a value equal to its constructor's default left
out, an enumeration by its value or name). Description adds toJson() (pretty printed, slashes and
Unicode unescaped, zero fractions kept, a final new line) and fromJson(); both throw
\JsonException on invalid JSON.
Description
| Member | Meaning |
|---|---|
$entities (array<string, Entity>) |
by class, sorted by class name |
$tables (array<string, Table>) |
by name, sorted |
entity(string $class): Entity |
\OutOfBoundsException for a class it does not hold |
table(string $name): Table |
\OutOfBoundsException for a table it does not hold |
Entity
| Property | Type | Meaning |
|---|---|---|
class |
string |
|
table |
?string |
its table; null for a mapped superclass or an embeddable |
kind |
string |
Entity::ENTITY (entity), MAPPED_SUPERCLASS (mapped_superclass), EMBEDDABLE (embeddable) |
parent |
?string |
the nearest entity above it |
root |
?string |
the root of its hierarchy, null for the root itself or a class outside a hierarchy |
inheritance |
?Inheritance |
how its hierarchy is stored, null without one |
identifier |
list<string> |
the identifier's properties |
fields |
array<string, Field> |
by property, inherited ones included |
relations |
array<string, Relation> |
by property |
subclasses |
list<string> |
the entities below it, sorted |
field(string) and relation(string) throw \OutOfBoundsException for a property that is none.
Field
| Property | Type | Default | Meaning |
|---|---|---|---|
property |
string |
||
column |
string |
the column's name | |
type |
string |
the portable type (string, integer, datetime_immutable, json... or an application's own, thread_state) |
|
nullable |
bool |
false |
|
length, precision, scale |
?int |
null |
|
unique |
bool |
false |
|
options |
array |
[] |
what else the column says: default, unsigned, fixed, comment, charset... |
id |
bool |
false |
part of the identifier |
generated |
?string |
null |
the identifier's generator (identity...), for an identifier field |
enum |
?string |
null |
the PHP enumeration its values are read into |
inheritedFrom |
?string |
null |
the entity it is inherited from; null for the entity's own, a mapped superclass's or a trait's |
declaredIn |
?string |
null |
the class that declares the property, when it is not the entity |
Relation
| Property | Type | Default | Meaning |
|---|---|---|---|
property |
string |
||
kind |
string |
Relation::ONE_TO_ONE, MANY_TO_ONE, ONE_TO_MANY, MANY_TO_MANY (one_to_one...) |
|
target |
string |
the target class | |
owning |
bool |
the side that keeps the link | |
mappedBy |
?string |
null |
the other side's property, on the inverse side |
inversedBy |
?string |
null |
the other side's property, on the owning side |
joinColumns |
list<JoinColumn> |
[] |
an owning to-one's |
joinTable |
?JoinTable |
null |
an owning many-to-many's |
orderBy |
array<string, string> |
[] |
property => ASC or DESC (an enumeration's Ascending/Descending read as such) |
indexBy |
?string |
null |
the property a collection is keyed by |
id |
bool |
false |
part of the identifier |
inheritedFrom, declaredIn |
?string |
null |
as a field's |
isToOne(): bool is true for one-to-one and many-to-one.
JoinColumn: name, referencedColumn (default id), nullable (default true), unique
(false), onDelete (?string). JoinTable: name, joinColumns (toward the owner),
inverseJoinColumns (toward the target).
Inheritance
type (Inheritance::SINGLE_TABLE single_table or JOINED joined), column (a Column: the
discriminator), map (value => class, sorted by value), value (this class's value).
Table, Column, Index, ForeignKey
| Class | Properties |
|---|---|
Table |
name; columns (array<string, Column>, in the order the database holds them); primaryKey (list<string>); indexes (array<string, Index>, the unique ones among them, sorted by name); foreignKeys (array<string, ForeignKey>, sorted). column(string): Column throws \OutOfBoundsException |
Column |
name, type (portable), nullable (false), length, precision, scale (?int), fixed (false), unsigned (false), autoincrement (false), default (string|int|float|bool|null), values (?list<string>, an enumeration's or a set's, given by an application's rule; the builder never fills it) |
Index |
name, columns (in order), unique (false), flags (list<string>, lower case: fulltext, spatial) |
ForeignKey |
name, columns, table (the referenced one), referencedColumns, onDelete (?string, upper case), onUpdate (?string) |
Naming
interface Naming
{
public function classToTableName(string $className): string;
public function propertyToColumnName(string $propertyName, string $className): string;
public function embeddedFieldToColumnName(string $propertyName, string $embeddedColumnName, string $className, string $embeddedClassName): string;
public function referenceColumnName(): string;
public function joinColumnName(string $propertyName, string $className): string;
public function joinTableName(string $sourceEntity, string $targetEntity, string $propertyName): string;
public function joinKeyColumnName(string $entityName, ?string $referencedColumnName): string;
public function generatedName(string $table, array $columns, string $prefix): string;
}
The questions Doctrine's naming strategy asks, plus generatedName() (an index, unique index or
foreign key nobody named; prefix idx, uniq or fk). omnibridge/doctrine's NamingStrategy
answers Doctrine with a Naming, so one class says how a class becomes a table.
CamelCaseNaming
The omni family's names. Not final; constructed with the namespaces a table name leaves out
(default ['App\\', 'Base\\']).
| Question | Answer | Example |
|---|---|---|
classToTableName() |
the #[Table(name: ...)] the class carries (the last one that names one); else the prefixes off (again and again), the first Entity\ off, the rest joined in camel case, a repeated word once, a trailing Intl kept |
App\Entity\Thread\Tag threadTag; App\Entity\User\Profile userProfile; Base\Entity\Layout\Widget\Slot layoutWidgetSlot; App\Entity\User\UserProfile userProfile |
propertyToColumnName() |
the property, first letter lower case | publishedAt |
embeddedFieldToColumnName() |
<property>_<column> |
address_city |
referenceColumnName() |
id |
|
joinColumnName() |
<property>_id |
mainTag_id |
joinTableName() |
<source table>_<property as a table name> (the target's table when the property is empty) |
Thread::$tags thread_tags |
joinKeyColumnName() |
<entity's table>_<referenced column or id> |
threadTag_id |
generatedName() |
the prefix, _, the CRC32 of the table and each column in hexadecimal, upper case, cut at 64 characters - DBAL's form |
IDX_31204C83941A39FD |
Constants: TABLE_NAME_SIZE and IDENTIFIER_SIZE (64, MySQL's limit), TABLE_I18N_SUFFIX
(Intl). A table name longer than 64 characters is a \LengthException ("Table name will be
truncated for ...").
Comparing with a database: MySqlSchema
Schema\Database\MySqlSchema reads a MySQL database's schema from information_schema (through
PDO, read-only, no ORM) and lists every difference with a description - an independent check that
the description is the database: nothing of the persistence that made the tables is asked.
use Omnibridge\Schema\Database\MySqlSchema;
$pdo = new \PDO('mysql:host=database;dbname=app', 'app', $password);
$schema = MySqlSchema::read(
$pdo,
database: null, // DATABASE() when null
ignore: ['doctrine_migration_versions'], // tables the description does not hold
types: ['thread_state' => MySqlSchema::enumeration(...)], // an application's own types
);
foreach ($schema->differences($description) as $difference) {
echo $difference, "\n"; // "thread.slug: type "varchar(255)" / "varchar(191)""
}
| Method | Meaning |
|---|---|
read(\PDO $pdo, ?string $database = null, array $ignore = [], array $types = []) |
the base tables of the database (views left out): columns in ordinal order (COLUMN_TYPE, nullable, default, auto-increment), primary key, indexes (unique, fulltext/spatial flags), foreign keys (referenced table and columns, delete and update rules; RESTRICT and NO ACTION read as null) |
of(array $tables, array $types = []) |
a schema kept as data (read()'s $tables, written once to a file) or written by hand |
$tables |
the tables read, readonly |
differences(Description $description): list<string> |
every difference, one a line; none when the description is the database's schema |
columnType(Column $column): string |
the SQL type MySQL 8 writes for a column of the description |
sameType(string $expected, string $actual): bool (static) |
equal once an integer's display width is dropped (tinyint(1) and tinyint, int(11) and int) |
enumeration(Column $column), set(Column $column) (static) |
enum('A','B'), set('A','B') of the column's values, for $types |
What differences() compares, and the lines it writes:
- tables on one side only:
<table>: a table of the description, not of the database, or the reverse; - the column lists:
<table>: columns [missing] missing, [more] more; - each column's SQL type, nullable, default (a boolean as
1/0) and auto-increment:<table>.<column>: <what> <expected> / <actual>; - the primary key; each index by name (columns, unique, flags) and each foreign key by name
(columns, table, referenced columns, on delete, on update):
missing,more, or<expected> / <actual>.
Collations, character sets and comments are not compared. The portable types and their SQL types:
| Portable type | MySQL 8 |
|---|---|
string, ascii_string |
varchar(<length or 255>), char(...) when fixed |
text |
tinytext up to 255, text up to 65535, mediumtext up to 16777215, else (or no length) longtext |
integer, smallint, bigint |
int, smallint, bigint, unsigned when unsigned |
boolean |
tinyint(1) |
float, smallfloat |
double, float |
decimal |
decimal(<precision or 10>,<scale or 0>), unsigned when unsigned |
datetime, datetime_immutable, datetimetz, datetimetz_immutable, utc_datetime, utc_datetime_immutable |
datetime |
date, date_immutable |
date |
time, time_immutable |
time |
json |
json |
guid |
char(36) |
binary |
varbinary(<length or 255>), binary(...) when fixed |
blob |
longblob |
array, simple_array, object |
longtext |
dateinterval |
varchar(255) |
| anything else | $types[<type>]($column), else an \UnexpectedValueException ("No SQL type for ... give it in $types") |
A run with no database, on a schema given as data (the harness image, 2026-10-27):
<?php
require 'vendor/autoload.php';
use Omnibridge\Schema\Column;
use Omnibridge\Schema\Database\MySqlSchema;
use Omnibridge\Schema\Description;
use Omnibridge\Schema\Table;
$description = new Description([], [
'page' => new Table('page', [
'id' => new Column('id', 'integer', autoincrement: true),
'title' => new Column('title', 'string', length: 120),
'state' => new Column('state', 'page_state', values: ['draft', 'published']),
], ['id']),
]);
$database = MySqlSchema::of([
'page' => [
'columns' => [
'id' => ['type' => 'int(11)', 'nullable' => false, 'default' => null, 'autoincrement' => true],
'title' => ['type' => 'varchar(255)', 'nullable' => false, 'default' => null, 'autoincrement' => false],
'state' => ['type' => "enum('draft','published')", 'nullable' => false, 'default' => null, 'autoincrement' => false],
],
'primaryKey' => ['id'],
'indexes' => [],
'foreignKeys' => [],
],
'doctrine_migration_versions' => ['columns' => [], 'primaryKey' => [], 'indexes' => [], 'foreignKeys' => []],
], ['page_state' => MySqlSchema::enumeration(...)]);
print_r($database->differences($description));
Array
(
[0] => doctrine_migration_versions: a table of the database, not of the description
[1] => page.title: type "varchar(120)" / "varchar(255)"
)
int(11) matched int (the display width), the enum matched the application's type.
Proposer une modification docs/schema.md
Errors
The package has one exception class of its own. The schema throws PHP's SPL exceptions, each with a message that names what is wrong.
MissingContractException
Omnibridge\Exception\MissingContractException, final, extends \LogicException. Formerly
Base\Exception\MissingContractException, an alias removed at omnibase 4's final pass. Two factories:
| Factory | Thrown by | Message |
|---|---|---|
for(string $contract, list<string> $formerNames = []) |
Runtime::get() and every accessor but clock(), when neither a service set by hand nor the container answers |
No <contract> (formerly <names>) in the runtime: the host fills it at boot (its omnibridge bridge, omnisense/omnibase's bundle under Symfony), or Omnibridge\Runtime::set() / setContainer() before it is asked. |
notAn(string $contract, object $service) |
Runtime::set() with a service that does not implement the contract; Runtime::find()/get() when the container's service does not |
The runtime's <contract> is a <type>, which does not implement it. |
It is a LogicException on purpose: a missing contract is a wiring mistake (a bridge not installed,
a package of SecurityBundle missing, a test that forgot Runtime::set()), not a condition to recover
from at run time. Code that can do without a contract asks Runtime::find() (null) or
Runtime::has() first, never catches the exception.
Under Symfony a contract whose framework service is absent is removed from the locator at compile
time (omnibridge/symfony's ContractsPass): with no SecurityBundle, Runtime::access() throws this
exception rather than a container error.
The schema's exceptions
| Exception | Thrown by | When |
|---|---|---|
\LogicException |
Builder |
No declaration for <class>.: a class in a hierarchy that the declarations do not map (an entity extending a class the declaration source does not hold) |
\LogicException |
Draft::mapField() |
Duplicate column "<column>" in <class>.: two fields on one column, or a field on the discriminator column |
\LogicException |
Draft::mapRelation() |
No target for <class>::$<property>.: a relation that names no target and whose property's type gives none |
\LogicException |
Draft::mapField() |
<class>::$<property>: a backed enumeration is required.: a field typed by a pure enumeration and naming no type |
\LogicException |
Builder\Tables |
No column "<column>" in <class>.: a join column referencing a column its target does not map |
\LengthException |
CamelCaseNaming::classToTableName() |
Table name will be truncated for "<class>": more than 64 characters |
\OutOfBoundsException |
Description::entity(), ::table(), Entity::field(), ::relation(), Table::column() |
a class, table, property or column the description does not hold |
\UnexpectedValueException |
MySqlSchema::columnType(), so differences() |
No SQL type for "<type>" (column <name>): give it in $types.: an application's own type not given |
\JsonException |
Description::toJson(), fromJson() |
invalid JSON (JSON_THROW_ON_ERROR) |
\ReflectionException |
AttributeReader::read(), Builder::build() |
a class name that does not exist |
\PDOException |
MySqlSchema::read() |
whatever the connection throws in its error mode |
Proposer une modification docs/errors.md
Testing
Testing code that asks the registry
The registry is static state: a test sets what the code under test asks, and forgets it after.
use Omnibridge\Contract\Access;
use Omnibridge\Contract\Urls;
use Omnibridge\Runtime;
use PHPUnit\Framework\TestCase;
final class ArticleLinkTest extends TestCase
{
protected function setUp(): void
{
Runtime::reset();
}
protected function tearDown(): void
{
Runtime::reset();
}
public function testTheLinkIsTheRoutesPath(): void
{
$urls = $this->createMock(Urls::class);
$urls->method('path')->with('article', ['slug' => 'le-fuji'])->willReturn('/articles/le-fuji');
Runtime::set(Urls::class, $urls);
self::assertSame('/articles/le-fuji', (new Article('le-fuji'))->link());
}
public function testAnAdministratorSeesTheEditButton(): void
{
Runtime::set(Access::class, new class implements Access {
public function isGranted(mixed $attribute, mixed $subject = null): bool { return 'ROLE_ADMIN' === $attribute; }
public function getUser(): ?object { return new \stdClass(); }
});
self::assertTrue((new Article('le-fuji'))->isEditable());
}
}
Runtime::reset()forgets the container, the services set and those resolved; the former names stay.- A frozen clock:
Runtime::set(Psr\Clock\ClockInterface::class, $clock)with any PSR-20 clock (symfony/clock'sMockClock, or an anonymous class). - A service that does not implement the contract is refused at
set(), so a mock must be of the interface. - Inside a framework's functional test (Symfony's
KernelTestCase, Laravel'sTestCase), the bridge fills the registry when the kernel or the application boots; a new kernel is a new container, and the registry forgets the previous one's services by itself.
Testing a description
A description is plain data: build it from the application's entities and assert on it, or keep it as a JSON file and compare.
$description = (new Omnibridge\Schema\Builder(rules: $rules))->build($classes);
self::assertSame('thread_tags', $description->entity(Thread::class)->relation('tags')->joinTable->name);
self::assertJsonStringEqualsJsonFile(__DIR__.'/schema.json', $description->toJson());
Against the real database, MySqlSchema::read($pdo, ignore: [...])->differences($description)
must be [] (The schema). omnibridge/doctrine adds
the comparison with Doctrine's own metadata.
The package's own suite
docker compose run --rm test
The service test of compose.yml: the image omnibridge-core:php8.2 (docker/Dockerfile: PHP
8.2-cli, the lowest this package declares, with git, unzip and Composer 2, no extension added), the
package mounted at /package, a named volume composer-cache for Composer's cache; it runs
composer update then vendor/bin/phpunit. On a checkout whose vendor/ is already installed, the
image runs the suite with no network:
docker run --rm --network none -v "$PWD":/package:ro omnibridge-core:php8.2 vendor/bin/phpunit --do-not-cache-result
| Test class | What it checks |
|---|---|
RuntimeTest |
a contract not filled is an exception naming it; every typed accessor throws when empty, but the clock (SystemClock); a service set by hand is answered and must implement the contract; the container is asked once, by the contract's name; a service of the wrong type in the container is refused; a different container forgets what the previous gave and keeps what was set; the clock is the container's when it has one; each accessor answers its contract; a contract is the same under a former name (set, bound or asked by either); a missing contract is named with its former names; reset() forgets everything; a family's own registry is the same registry |
BareTest |
every class of src/ loaded in a PHP process of its own with Composer's autoloader: no file from outside src/ but psr/clock, psr/container and psr/event-dispatcher, no symbol of Symfony, Illuminate, Laravel, Doctrine, Twig, Base or App; and the report does see what it loads |
Schema\AttributeReaderTest |
the vocabulary is not installed (no doctrine/orm); a class's declaration, its kind and attributes; the properties with the class that declares each; the vocabulary answers for every attribute of the mapping; a declaration reads back from its plain form |
Schema\BuilderTest |
every class described, the mapped superclass without a table; a joined hierarchy, its default map, its mapped superclass's field inherited; the relations and their default join columns and join tables; a single-table hierarchy with its declared map and the rules at their moments; the tables as SchemaTool creates them; the same description from declarations kept as data; a description read back from its plain form |
Schema\CamelCaseNamingTest |
tables and columns; a class that names its table, the prefixes given; a name generated as DBAL generates it; a table name too long refused |
Schema\MySqlSchemaTest |
no difference when the description is the database's schema; an integer's display width is no difference; every difference named; the SQL type of each portable type |
BareTest is measured on this package's own vendor: run from an application's vendor/*/tests
(lesringostars does so), it says so and is skipped, while the registry's cases still run there.
Proposer une modification docs/testing.md
Verified, and not
What the tests prove
Run on 2026-10-27 in the package's image omnibridge-core:php8.2 (PHP 8.2.34, PHPUnit 11.5.57), the
checkout's own vendor/, no network: 34 tests, 245 assertions, green. What each test class checks
is in Testing. In short:
- The registry: a missing contract is loud and named (with its former names), a service set by hand wins and must implement the contract, the container is asked once and forgotten when it changes, the clock falls back on the machine's, a former name is the same contract, a family's registry is the same state.
- The package stands alone: every class loads with the three PSR interfaces and nothing of a
framework, an ORM, Twig or an application (
BareTest). - The schema without doctrine/orm installed: declarations read and read back, joined and single-table hierarchies, relations and their defaults, rules at their three moments, the tables as SchemaTool creates them, the naming, a MySQL schema compared (given as data).
Every example of this manual was run in the same image on 2026-10-27 and its output pasted: the plain PHP registry script (Installation), the schema of two entities and the rule (The schema), the naming examples, the MySQL comparison on data.
Verified elsewhere, as the commits state
- The builder against Doctrine on omnisense/omnibase's harness (202 classes,
SchemaDescriptionTestin omnibase compares the description built without Doctrine with Doctrine's on every class the harness maps): three differences found and fixed (commit e74e67c:OrderBydirections, quoted names). MySqlSchemaon lesringostars' database (MySQL 8.4.11): the display width of integers found and ignored since (commit 4ce7ed1); nothing else differed.- The registry under both hosts: omnibridge/symfony's and omnibridge/laravel's suites, and
lesringostars' runs of
vendor/*/tests(the registry's cases pass there,BareTestskipped).
What is not verified
MySqlSchema::read()is not run by this package's suite (no MySQL in the harness): its queries are checked on lesringostars' database only.- The builder's behaviour for what omnibase's entities do not use: embedded classes, attribute and
association overrides, sequence and custom generators,
Table'sindexes/uniqueConstraintsarguments (The schema lists what is not described). - A PHP without
ext-mbstring: composer refuses to install the package (it requires it, forCamelCaseNaming; Installation). - A third host (other than Symfony and Laravel): no bridge exists to try the contracts against.