Aller au contenu

omnisense/omnibridge

The core of the omnibridge family: the contracts any host answers for an application that runs on more than one framework (addresses, translations, languages, access, sessions, tokens, stored files, routes, environment) and the registry the application reaches them through. No framework, no ORM: each omnibridge/<host> bridge answers them with its host's services.

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

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 are Doctrine\ORM\Mapping's, the vocabulary omnibase's entities are written in, but their classes need not exist: Vocabulary holds each attribute's parameters and defaults as data (omnibridge/doctrine's VocabularyTest keeps it equal to doctrine/orm's constructors). AttributeReader reads a class into a ClassDeclaration (its attributes, its properties with the class that declares each, its lifecycle methods); DeclarationSet keeps declarations as data (a JSON file, toArray()/fromArray()). A persistence bridge maps the classes from a Declarations source and never reads the attributes itself.
  • Schema\Description: the neutral description - Entity (table, kind, parent and root, Inheritance, identifier, Fields, Relations with their JoinColumns or JoinTable), and the Tables 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\Rule is 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\Tag is threadTag, Thread::$tags the join table thread_tags (thread_id, threadTag_id)), and a generated index or foreign key name as DBAL generates it. omnibridge/doctrine's NamingStrategy answers Doctrine with it.
  • Schema\Database\MySqlSchema: a MySQL schema read from information_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: Runtime method 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 \PDO connected to MySQL (ext-pdo, ext-pdo_mysql in the application).
  • Schema\CamelCaseNaming calls mb_strtolower(), mb_substr() and mb_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\Urls is 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 but clock()) 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 by Runtime::clock(). A host gives its own (symfony/clock's, which a test can freeze; omnibridge/laravel's DateClock over Laravel's dates). When none was given, the registry answers Omnibridge\SystemClock, whose now() is new \DateTimeImmutable() - the machine's time in PHP's default time zone.
  • Psr\EventDispatcher\EventDispatcherInterface, asked by Runtime::events(): Symfony's event_dispatcher, omnibridge/laravel's DispatcherEvents over Laravel's dispatcher. There is no fallback: asked and not given, it throws MissingContractException.

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:

  1. 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.
  2. The registry told: a service bound in the container under the former name only is found when the new name is asked, and MissingContractException lists 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:

  1. Declarations (Schema\Declaration\*): what the entity classes declare, read from their attributes without instantiating them, or kept as data.
  2. 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.
  3. The description (Schema\Description): plain, readonly data - entities and tables - that a persistence bridge maps, a JSON file keeps, and Schema\Database\MySqlSchema compares 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 in Vocabulary::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')] is name: 'slug'); a nested attribute written as an object (new ORM\JoinColumn(...) inside a JoinTable) becomes an Attribute of 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, MappedSuperclass nor Embeddable reads null. 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 inherited from an entity parent, declared in 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, and options); each Index and UniqueConstraint written on the class (columns or fields, flags, options, by name or by position); InheritanceType, DiscriminatorColumn (default dtype, string, 255) and DiscriminatorMap.
  • Each property not inherited: a Column is a field (type, length, precision, scale, unique, nullable, index, options, enumType, name with backquotes stripped; Id and its position; GeneratedValue's strategy); else a OneToOne, OneToMany, ManyToOne or ManyToMany is a relation (targetEntity, mappedBy, inversedBy, indexBy, OrderBy, JoinColumns, JoinTable and InverseJoinColumns, Id).
  • Completion (Draft::mapField(), mapRelation()):
    • a field's type from its property's type when it names none: \DateInterval dateinterval, \DateTime datetime, \DateTimeImmutable datetime_immutable, array json, bool boolean, float float, int integer, string string; a backed enumeration its backing type, with enumType set (a pure enumeration is a LogicException); otherwise string;
    • 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>_id toward id, 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 delete CASCADE when the join table names none; a relation of a class to itself names them <table>_source and <table>_target);
    • the identifier in Id position order.
  • The generator: a child of an entity keeps its root's; an AUTO root's becomes autoGenerator.
  • 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: Embedded properties (no column for them; Naming::embeddedFieldToColumnName() is there but the builder never asks it), AttributeOverrides and AssociationOverrides, the indexes and uniqueConstraints arguments of #[Table] (write #[Index] and #[UniqueConstraint] on the class), Table's schema, SequenceGenerator and CustomIdGenerator'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's MockClock, 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's TestCase), 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, SchemaDescriptionTest in omnibase compares the description built without Doctrine with Doctrine's on every class the harness maps): three differences found and fixed (commit e74e67c: OrderBy directions, quoted names).
  • MySqlSchema on 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, BareTest skipped).

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's indexes/uniqueConstraints arguments (The schema lists what is not described).
  • A PHP without ext-mbstring: composer refuses to install the package (it requires it, for CamelCaseNaming; Installation).
  • A third host (other than Symfony and Laravel): no bridge exists to try the contracts against.

Proposer une modification docs/verified.md