Skip to content

Add TypeRegistry to Configuration and use it in all internal type resolution - #7342

Open
GromNaN wants to merge 1 commit into
doctrine:4.5.xfrom
GromNaN:type-registry-config
Open

Add TypeRegistry to Configuration and use it in all internal type resolution#7342
GromNaN wants to merge 1 commit into
doctrine:4.5.xfrom
GromNaN:type-registry-config

Conversation

@GromNaN

@GromNaN GromNaN commented Mar 29, 2026

Copy link
Copy Markdown
Member
Q A
Type feature
Fixed issues -

Continues #6705. The existing TypeRegistry class already supports scoped, instance-based
type management; this PR wires it into the rest of DBAL so it is actually used.

Companion PRs: doctrine/orm#12421 and doctrine/DoctrineBundle#2221.
This is already being done in the same way for Doctrine MongoDB ODM: doctrine/mongodb-odm#2966

#7490 is merged into this branch so the whole direction is visible in one place. I recommend
merging #7490 first, then rebasing this PR on top of it.

Disclamer: This PR was made using Claude under my very close supervision; trying to provide a more detailed description of the changes than I would have written myself, but I have verified the content and made some edits for clarity and accuracy.

Motivation

Two goals:

  • Dependency injection into type instances. Custom types sometimes need access to
    services (e.g. a serializer, an encryption service). With the global static registry this is impossible
    without static state. A per-connection type provider can hold fully-constructed type
    instances.

  • Prevent global side-effects. Type::addType() and Type::overrideType() mutate a
    process-wide singleton, so a test or a bundle changing a type affects every connection.
    A scoped provider isolates those changes.

What changed

TypeProvider

New interface, and the type Configuration now expresses:

interface TypeProvider extends Traversable
{
    public function get(string $name): Type;

    public function has(string $name): bool;
}

It extends Traversable, since callers may also want to enumerate the available types.
register() and override() are deliberately absent, so Type::getTypeRegistry() keeps
returning the concrete TypeRegistry that Type::addType() needs.

Extending PSR-11 ContainerInterface was considered and left out for now, since it would make
psr/container a hard requirement. It can still be added later.

TypeRegistry

  • Built-in types are pre-populated: new TypeRegistry() already contains all of them. They are
    read straight from a class constant rather than copied per instance, so construction is cheap.
    Custom types passed to the constructor are layered on top and may override built-ins by name.
  • Types can be lazy-loaded from a PSR-11 container, given a map of type names to service IDs.
    The container is never queried during construction, nor by has():
    new TypeRegistry($container, ['money' => 'app.dbal_type.money']);
  • Implements IteratorAggregate with a generator, replacing the @internal getMap(). Stopping
    early no longer instantiates every remaining type.
  • lookupName() is deprecated. It cannot go yet, because the deprecated Column::setType(),
    ColumnEditor::setType() and ORM's TypedExpression still need instance-to-name. It is removed
    in 5.0, together with the restriction that an instance may only be registered under one name.

Configuration

  • getTypeProvider(): TypeProvider returns the provider for this connection, lazily defaulting
    to the global registry (Type::getTypeRegistry()).
  • setTypeProvider(TypeProvider $provider): self injects a provider scoped to this connection.

Internal type resolution

All of the following now resolve types through $configuration->getTypeProvider() instead
of the static Type::* methods:

Component Method(s) changed
Connection convertToDatabaseValue(), convertToPHPValue(), getBindingInfo()
Statement parameter binding
AbstractPlatform initializeAllDoctrineTypeMappings(), registerDoctrineTypeMapping(), getType()
*SchemaManager (×6) _getPortableTableColumnDefinition()
*MetadataProvider (×6) column type resolution

AbstractPlatform receives its Configuration via a new setConfiguration() method
called by Connection::getDatabasePlatform() after platform creation.

Because #7490 makes Column store a type name rather than an instance, the schema managers hand
the name straight to Column and no longer resolve a Type first. Table, Schema and
SchemaConfig therefore need no provider at all.

The static Type::* methods are deprecated

They all operate on the process-wide registry, which behaves unexpectedly as soon as a connection
has its own type provider: a type registered with Type::addType() is invisible to that
connection, and Type::getType() resolves against the global registry rather than the
connection's. Nothing signalled that today, so the mistake surfaced later as an apparently
unrelated UnknownColumnType.

getTypeRegistry(), getType(), addType(), hasType(), overrideType(),
getTypesMap() and lookupName() therefore carry an @deprecated docblock and a runtime
deprecation. They keep working and still delegate to the global registry, so nothing breaks.

getTypeRegistry() and getType() use triggerIfCalledFromOutside, because
Configuration::getTypeProvider(), AbstractPlatform and the deprecated Column::getType()
call them internally: the supported default path stays silent, and a single user call is not
reported twice.

DBAL 5 will have no static type provider.

Trade-offs

Iterating a provider resolves every type. AbstractPlatform::initializeAllDoctrineTypeMappings()
does exactly that, so schema introspection instantiates all types. Laziness holds for query paths.

Custom types registered via Type::addType() are invisible to connections that use a
custom provider.
This is intentional: it is the isolation the feature provides.
Users who set a provider are responsible for registering all types they need in it.

The global singleton is preserved. Type::getTypeRegistry() still returns the
process-wide registry. Connections that do not call setTypeProvider() continue to behave
exactly as before.

@stof

stof commented Apr 3, 2026

Copy link
Copy Markdown
Member

As discussed during the SymfonyLive, it would be great to support lazy-loading of type instances injected in the TypeRegistry, to reduce the cost of instantiation the connection service (especially when types have dependencies, which might lead to instantiating a bigger object graph).

As far as DoctrineBundle is concerned, the easier way would probably involve injecting a PSR-12 ContainerInterface (with ids being the type names) and a list of type names (or a map of type names to ids in the container to allow more flexibility about those ids). This would allow us to use the Symfony ServiceLocator which performs such lazy-loading (it would of course mean that the constructor should not retrieve type instances, as that would defeat the lazy-loading).
An alternative implementation could be to use \Symfony\Contracts\Service\ServiceProviderInterface which avoids the need for the separate list of available ids (as the getProvidedServices method allows introspecting the container) but this would introduce a dependency on symfony/service-contracts which might be an issue for other frameworks.

@GromNaN

GromNaN commented Apr 3, 2026

Copy link
Copy Markdown
Member Author

Thanks for the reminder @stof!

I implemented both approaches:

  • Symfony ServiceProviderInterface (from symfony/service-contracts, added as an optional require-dev dependency): pass it directly as the constructor argument. getProvidedServices() is called during construction to register factory entries lazily — no type instances are created until the first get() call.

  • PSR ContainerInterface: pass an array<string, ContainerInterface> where each key is the type name and the value is a container that resolves it. This avoids the symfony/service-contracts dependency entirely.

Both paths converge into a unified $factories array (array<string, class-string<Type>|ContainerInterface>) that also handles built-in types lazily. DoctrineBundle can pass a Symfony ServiceLocator either as a ServiceProviderInterface (preferred) or as individual ContainerInterface entries in the array.

@GromNaN
GromNaN force-pushed the type-registry-config branch from d87fe57 to 0b6f833 Compare April 3, 2026 16:10
Comment thread src/Types/TypeRegistry.php Outdated
@stof

stof commented Apr 7, 2026

Copy link
Copy Markdown
Member

I find it weird to pass multiple ContainerInterface. A single container can hold all the types (under different indexes).
My proposal was to have separate arguments to pass a ContainerInterface and a list of ids in it.

@stof

stof commented Aug 4, 2026

Copy link
Copy Markdown
Member

@GromNaN do you plan to change the way the case of a ContainerInterface gets supported ? See my previous comment that was not answered from April.

@GromNaN
GromNaN force-pushed the type-registry-config branch 3 times, most recently from 801c8ea to 5ee4350 Compare August 4, 2026 20:55
Comment thread src/Schema/ColumnDiff.php Outdated
@GromNaN

GromNaN commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

I reworked the container injection following @stof's review.

TypeRegistry now takes a PSR-11 ContainerInterface plus an explicit map of type names to service IDs, instead of the previous array<string, ContainerInterface>:

$registry = new TypeRegistry($container, ['money' => 'app.dbal_type.money']);

The ServiceProviderInterface special case is gone, so src/ no longer references symfony/service-contracts, and psr/container is only a dev dependency. Besides the dependency, requiring the map removes a footgun: deriving the type names from getProvidedServices() silently assumed that the locator keys were type names, so keying a locator by service ID would have registered service IDs as type names.

The container is never queried during construction, nor by has(), so types remain lazy.

This does not add complexity on the bundle side. doctrine/DoctrineBundle#2221 is updated accordingly: the ServiceLocator is still keyed by type name, so the map is simply an identity map.

I also merged #7490 into this branch, to give a complete picture of where this is heading. Storing a type name on Column removes the need to resolve a Type instance in the schema managers, which in turn let me drop the TypeRegistry propagation through SchemaConfig and Table entirely. It also supersedes the ColumnDiff::hasTypeChanged() trade-off listed in the description: comparing type names detects a change between two names that share a single class, without requiring both sides of the diff to resolve from the same registry.

I recommend merging #7490 first, then rebasing this PR on top of it.

Comment thread src/Types/TypeRegistry.php Outdated
Comment thread src/Configuration.php Outdated
Comment thread src/Schema/Column.php
@GromNaN
GromNaN force-pushed the type-registry-config branch 3 times, most recently from 898befe to 783d53c Compare August 5, 2026 11:58
GromNaN added a commit to GromNaN/dbal that referenced this pull request Aug 5, 2026
Requested in review on doctrine#7342: the method was only marked @deprecated in its
docblock, so callers got no signal at runtime.

Uses triggerIfCalledFromOutside rather than trigger, because toArray() still
calls getType() internally when $skipType is false, and that path already
triggers its own deprecation. Verified that toArray() as the first call in a
process reports exactly one deprecation, its own.

Also moves the upgrade note from the 4.4 section to 4.5, where this deprecation
actually lands, next to the related Column mutator notes. Its @deprecated
docblock no longer points at Configuration::getTypeRegistry(), which does not
exist on 4.5.x.
@GromNaN

GromNaN commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Deprecated all the static Type::* methods: mixed with a per-connection type provider they give unexpected results, since a type registered globally is invisible to a connection that has its own provider.

They keep working, and the supported default path stays silent. DBAL 5 will have no static type provider.

GromNaN added a commit to GromNaN/dbal that referenced this pull request Aug 19, 2026
Requested in review on doctrine#7342: the method was only marked @deprecated in its
docblock, so callers got no signal at runtime.

Uses triggerIfCalledFromOutside rather than trigger, because toArray() still
calls getType() internally when $skipType is false, and that path already
triggers its own deprecation. Verified that toArray() as the first call in a
process reports exactly one deprecation, its own.

Also moves the upgrade note from the 4.4 section to 4.5, where this deprecation
actually lands, next to the related Column mutator notes. Its @deprecated
docblock no longer points at Configuration::getTypeRegistry(), which does not
exist on 4.5.x.
@GromNaN
GromNaN force-pushed the type-registry-config branch 2 times, most recently from 335ffac to 4e5852a Compare August 19, 2026 11:25
…instance-based lookups

All internal type resolution (Connection, Statement, AbstractPlatform, SchemaManagers,
MetadataProviders) now goes through Configuration::getTypeRegistry() instead of the
global Type::getType() / Type::hasType() / Type::getTypesMap() static methods.

Table receives an optional Configuration so addColumn() uses the instance registry
when available, falling back to Type::getType() for user code without a Configuration.
ColumnEditor::setTypeName() similarly falls back to Type::getType() for user code;
internal callers (MetadataProviders) now use setType() with the configuration registry.

Deprecate `Column::getType()` in favor of `Column::getTypeName()`

`Column::getType()` returns a `Type` instance. In DBAL 4 the canonical
identifier of a type is its name, not its class or instance:
`Type::getName()` was removed in favor of `TypeRegistry::lookupName()`,
and consumers that only need the name (ORM `DatabaseDriver`, RSM,
schema comparison, reverse engineering, dumps to cache) end up doing a
useless instance -> name round-trip via the global static registry.

Expose the type name directly on `Column`:

- `Column::setTypeName(string): self` and `Column::getTypeName(): string`
  (throws `TypesException` if the name cannot be resolved). `_typeName`
  is the source of truth.
- `Column::setType(Type)` deprecated (still populates `_typeName` eagerly
  so unregistered types now fail early instead of silently).
- `Column::getType()` deprecated.
- `AbstractPlatform::getType(Column)` protected helper introduced as the
  single call site for `Type::getType()`, so a future `TypeRegistry`
  injection has one hook. Migrated `OraclePlatform`, `PostgreSQLPlatform`,
  `DB2Platform` and `PostgreSQLSchemaManager` off `Column::getType()`.
- `ColumnDiff::hasTypeChanged()` now compares type *names* instead of
  instance classes.
- Tests updated to construct columns via `setTypeName()`; two comparator
  tests that only made sense under class-based identity (`clone Type`,
  `overrideType`) collapsed into a single name-based equivalence test.

Document per-connection type registries in UPGRADE.md

Covers Configuration::get/setTypeRegistry(), the fallback to the global
singleton for connections that do not set one, and the deliberate isolation
from Type::addType().

Also notes two things that are easy to trip over: new TypeRegistry() is now
pre-populated with the built-in types, and mocking Configuration requires
stubbing getTypeRegistry() because TypeRegistry is final.

Introduce the TypeProvider interface, addressing review feedback

Configuration now exposes get/setTypeProvider() typed against the new
Doctrine\DBAL\Types\TypeProvider instead of the final TypeRegistry, so the
type source can be extended or stubbed. The ORM testsuite previously had to
instantiate a real registry and touch unrelated tests because the final class
could not be doubled.

The interface extends PSR-11 ContainerInterface and Traversable, since a type
registry is a container of types that callers may also enumerate. get() is
redeclared to narrow the return type to Type; without that, every call site
would degrade to mixed. register() and override() stay off the interface, so
Type::getTypeRegistry() keeps returning the concrete class for Type::addType().
Because interface inheritance is resolved eagerly, psr/container moves back to
a hard requirement.

getMap() is replaced by iteration: TypeRegistry implements IteratorAggregate
with a generator that yields from each source in turn rather than merging them,
so iteration allocates nothing extra and stopping early leaves the remaining
types uninstantiated.

Also from the review:

- Drop the unset() in get()'s finally. It was redundant, since $instances is
  checked first and shadows the service ID, and being in finally it also ran on
  failure: a transient container error permanently dropped the type, so has()
  flipped to false and a retry reported an unknown type instead of retrying.
- Deprecate TypeRegistry::lookupName() and Type::lookupName(). They cannot be
  removed yet because the deprecated Column::setType(), ColumnEditor::setType()
  and ORM's TypedExpression branch still need to derive a name from an instance.
  Both go in 5.0, along with the one-instance-one-name restriction.
- Add a runtime deprecation to Column::getType(). It uses
  triggerIfCalledFromOutside because toArray() calls it internally when
  $skipType is false, and that path already triggers its own deprecation.

Remove a stray blank line in UPGRADE.md

Stop extending ContainerInterface in TypeProvider

Extending PSR-11 made psr/container a hard requirement, because interface
inheritance is resolved eagerly. That is not worth it yet: nothing in DBAL
consumes a TypeProvider as a container, and the interface can still be widened
later without breaking implementors.

psr/container therefore returns to require-dev. It stays a soft dependency:
TypeRegistry still accepts a container and catches ContainerExceptionInterface,
but those are parameter and catch positions, which PHP only resolves when a
container is actually passed. Verified by running the array-based path with an
autoloader that fails on any Psr\Container\* lookup.

Move the Column::getType() upgrade note to the 4.5 section

It was inserted directly after the "Upgrade to 4.4" heading, so it documented a
4.5 deprecation under 4.4. Placed next to the related Column mutator notes.

Same fix as on the doctrine#7490 branch, where the note originates.

Mention the connection's TypeProvider in UnknownColumnType

The message told users to register the type with Type::addType(), which does
not help a connection that has its own TypeProvider: such a connection does not
see globally registered types, so following the advice led nowhere.

Also normalises Foo#bar() to Foo::bar(); that notation appeared nowhere else in
src/.

Deprecate the static Type methods

They all operate on the process-wide registry, which behaves unexpectedly once a
connection has its own type provider: a type registered with Type::addType() is
invisible to that connection, and Type::getType() resolves against the global
registry rather than the connection's. Nothing signalled that, so the failure
surfaced later as an unrelated UnknownColumnType.

All seven now carry an @deprecated docblock and a runtime trigger.
getTypeRegistry() and getType() use triggerIfCalledFromOutside, because
Configuration::getTypeProvider(), AbstractPlatform and the deprecated
Column::getType() call them internally; that keeps the supported default path
silent and avoids reporting one user call twice. Verified that each static fires
exactly once, that Column::getType() still reports once rather than twice, and
that a plain connection stays silent through insert and schema introspection.

UnknownColumnType no longer recommends Type::addType(), which this change
deprecates and which would not have fixed the error for a connection with its
own provider.

DBAL 5 will have no static type provider.
@GromNaN
GromNaN force-pushed the type-registry-config branch from 4e5852a to 80f910b Compare August 19, 2026 13:13
@GromNaN

GromNaN commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Changes since fdb05f8 (previous review point):

Rebase onto latest 4.5.x
The branch was rebased on the current 4.5.x. This pulls in the recent upstream work (the UTC DateTime types, the PHPUnit 11.5.56 and php_codesniffer 4.0.4 bumps, and related functional tests) and resolves the conflicts against it. The whole feature is now squashed into a single commit.

Type resolution typed against TypeProvider instead of the concrete TypeRegistry
Configuration and SchemaConfig now expose the type source as the TypeProvider interface rather than the final TypeRegistry. This keeps the source substitutable (the ORM test suite can stub it) while Type::getTypeRegistry() still returns the concrete class for Type::addType().

Backward compatibility for the deprecated Column::getType() / setType()
Both deprecated methods must keep resolving types even when a connection uses its own registry. Instead of always hitting the global static registry, each Column now carries an optional TypeProvider set through Column::setTypeRegistry(). All schema managers inject the connection registry into every introspected column, so getType() and setType() resolve against the right source.

The registry is also injected into Table. Rather than adding a constructor argument, Table and TableEditor expose setTypeRegistry() (mirroring Column). Table::addColumn() propagates it to the created column, and Table::edit() carries it through TableEditor::create(), so the BC guarantee survives an edit cycle.

Column::setType() no longer depends on TypeRegistry::lookupName()
lookupName() is not part of the TypeProvider interface and is scheduled for removal in 5.0. When a plain TypeProvider (not a TypeRegistry) is injected, setType() now derives the type name by iterating the provider (interface only) and throws TypeNotRegistered if the instance is unknown. The fast path via lookupName() is kept for the concrete registry and the global fallback.

SchemaConfig::get/setTypeRegistry()
Marked @internal (they only exist for the deprecated Column::getType() path) and typed on TypeProvider. This also fixes a type mismatch where the property and getter were ?TypeRegistry while the setter accepted a TypeProvider.

Tests

  • ColumnTest: getType() and setType() resolve against the injected registry, fall back to the global one when none is set, work with a non-TypeRegistry TypeProvider, and throw TypeNotRegistered for an unknown instance.
  • TableTest: addColumn() uses the injected registry (distinct instance, not the global one) and edit() preserves it.
  • SchemaConfigTest: round-trip of the registry, including a plain TypeProvider implementation.
  • New tests/Types/InMemoryTypeProvider test double, a minimal TypeProvider that is not a TypeRegistry, to exercise the interface-only paths.

@GromNaN GromNaN left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick impact review on Symfony:

Comment thread src/Configuration.php

public function getTypeRegistry(): TypeProvider
{
return $this->typeRegistry ??= Type::getTypeRegistry();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ensures backward compatibility: if the TypeProvider is not explicitly configured, all the connections share the same TypeRegistry singleton.

Then DBAL only uses the configured TypeProvider everywhere.

GromNaN added a commit to GromNaN/mongodb-odm that referenced this pull request Aug 25, 2026
Follows the shape settled in doctrine/dbal#7342, so users who know DBAL find
the same concepts and names here.

- Add `TypeProvider`, the interface `Configuration` accepts and returns:
  `get()`, `has()`, and `Traversable<string, Type>`. `TypeRegistry` becomes
  one implementation of it rather than the only option.
- Make `TypeRegistry` final, implementing `TypeProvider` and
  `IteratorAggregate`. Built-in types move to a class constant, the
  constructor takes a map of instances or class names layered on top, and
  `getMap()` is replaced by `getIterator()`.
- Support lazy-loading types from a PSR-11 container, given a map of type
  names to service IDs. The container is never queried during construction,
  nor by `has()`. This lets the bundle wire a service locator instead of
  building every custom type at boot.
- Move `guessTypeFromValue()` and `convertToDatabaseValue()` out of the
  registry into a new internal `TypeGuesser`, keeping `TypeProvider` minimal.
- Stop instantiating a type just to validate it in `register()`. The
  constructor is inspected by reflection instead, so registering by class
  name stays lazy.

Tests now inject a registry scoped to the test `Configuration`, which removes
the reflection reset of the shared instance in `tearDown()`.
GromNaN added a commit to GromNaN/mongodb-odm that referenced this pull request Aug 25, 2026
Follows the shape settled in doctrine/dbal#7342, so users who know DBAL find
the same concepts and names here.

- Add `TypeProvider`, the interface `Configuration` accepts and returns:
  `get()`, `has()`, and `Traversable<string, Type>`. `TypeRegistry` becomes
  one implementation of it rather than the only option.
- Make `TypeRegistry` final, implementing `TypeProvider` and
  `IteratorAggregate`. Built-in types move to a class constant, the
  constructor takes a map of instances or class names layered on top, and
  `getMap()` is replaced by `getIterator()`.
- Support lazy-loading types from a PSR-11 container, given a map of type
  names to service IDs. The container is never queried during construction,
  nor by `has()`. This lets the bundle wire a service locator instead of
  building every custom type at boot.
- Move `guessTypeFromValue()` and `convertToDatabaseValue()` out of the
  registry into a new internal `TypeGuesser`, keeping `TypeProvider` minimal.
- Stop instantiating a type just to validate it in `register()`. The
  constructor is inspected by reflection instead, so registering by class
  name stays lazy.

Tests now inject a registry scoped to the test `Configuration`, which removes
the reflection reset of the shared instance in `tearDown()`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants