Skip to content
Open
22 changes: 19 additions & 3 deletions src/Attribute/AsDbalType.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,26 @@

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
/**
* Registers the tagged class as a Doctrine DBAL type on the connection.
*
* With DBAL >= 4.5 and ORM >= 3.7 when the ORM are used, the type is registered
* per connection and enables constructor dependency injection.
*
* With DBAL < 4.5, the type is registered globally instead.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class AsDbalType
{
public function __construct(public string|null $name = null)
{
/**
* @param string|null $name The DBAL type name used in column mappings (e.g. in #[Column(type: ...)]).
* Defaults to the fully-qualified class name of the type when omitted.
* @param string|null $connection Restrict the type to a specific named connection.
* When null (default), the type is registered for all connections.
*/
public function __construct(
public string|null $name = null,
public string|null $connection = null,
) {
}
}
138 changes: 87 additions & 51 deletions src/DependencyInjection/Compiler/RegisterDbalTypePass.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@
namespace Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType;
use Doctrine\DBAL\Configuration as DbalConfiguration;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\TypeRegistry;
use ReflectionClass;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;

use function array_combine;
use function array_keys;
use function is_subclass_of;
use function method_exists;
use function sprintf;
Expand All @@ -21,82 +28,111 @@ final class RegisterDbalTypePass implements CompilerPassInterface
{
private const string TAG = 'doctrine.dbal.type';

/**
* @param ReflectionClass<T> $reflector
*
* @template T of ReflectionClass
*/
/** @param ReflectionClass<*> $reflector */
public static function autoconfigureFromAttribute(ChildDefinition $definition, AsDbalType $type, ReflectionClass $reflector): void
{
$attributes = [
$definition->addTag(self::TAG, [
'type_name' => $type->name ?? $reflector->name,
];
'connection' => $type->connection,
]);
}

// Determine if the version of symfony/dependency-injection is >= 7.3
/** @phpstan-ignore function.alreadyNarrowedType */
if (method_exists($definition, 'addResourceTag')) {
$definition->addResourceTag(self::TAG, $attributes);
public function process(ContainerBuilder $container): void
{
if (method_exists(DbalConfiguration::class, 'setTypeProvider')) {
$this->registerInTypeRegistry($container);
} else {
// Needed to keep compatibility with symfony/dependency-injection < 7.3
$definition->addTag(self::TAG, $attributes)
->addTag('container.excluded', ['source' => sprintf('by tag "%s"', self::TAG)]);
$this->registerInConfig($container);
}
}

public function process(ContainerBuilder $container): void
/**
* New approach: inject a per-connection TypeRegistry with lazy service resolution (DBAL >= 4.5).
*/
private function registerInTypeRegistry(ContainerBuilder $container): void
{
$types = $container->getParameter('doctrine.dbal.connection_factory.types');
if (! $container->hasParameter('doctrine.connections')) {
return;
}

foreach ($this->findTaggedResourceIds($container) as $id => $tags) {
foreach ($tags as $tag) {
$class = $container->getDefinition($id)->getClass();
if (! $class) {
throw new InvalidArgumentException(sprintf('The definition of "%s" must define its class.', $id));
}
// Config-based types (apply to all connections)
/** @var array<string, array{class: string}> $configTypes */
$configTypes = $container->getParameter('doctrine.dbal.connection_factory.types');

if (! is_subclass_of($class, Type::class)) {
throw new InvalidArgumentException(sprintf('The "%s" class must extends "%s".', $class, Type::class));
}
$taggedServiceIds = $container->findTaggedServiceIds(self::TAG);

$types[$tag['type_name'] ?? $id] = ['class' => $class];
}
if ($configTypes === [] && $taggedServiceIds === []) {
return;
}

$container->setParameter('doctrine.dbal.connection_factory.types', $types);
foreach (array_keys($container->getParameter('doctrine.connections')) as $name) {
$services = [];

// Config-based types become inline definitions in the ServiceLocator
foreach ($configTypes as $typeName => $typeConfig) {
$services[$typeName] = new Definition($typeConfig['class']);
}

// Service-tagged types: global (no connection restriction) or matching this connection
foreach ($taggedServiceIds as $id => $tags) {
foreach ($tags as $tag) {
if ($name !== ($tag['connection'] ?? $name)) {
continue;
}

$services[$tag['type_name'] ?? $tag['type'] ?? $id] = new Reference($id);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why supporting 2 different attribute names for the type name ?

@GromNaN GromNaN Aug 6, 2026

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.

Good point. I liked type better for the explicit tag configuration (shorter, reads nicely), but type_name is already the key shipped in 3.3.0, so keeping it avoids a BC break for anyone tagging a service by hand. I dropped the extra fallback and kept type_name as the only key.

}
}

$registryId = sprintf('doctrine.dbal.%s_connection.type_registry', $name);
$registryRef = new Reference($registryId);

// Inject a ServiceLocator so types are resolved lazily on first use. The locator is
// keyed by type name, so the name-to-service-ID map the registry requires is an
// identity map.
$locatorRef = ServiceLocatorTagPass::register($container, $services);
$typeNames = array_keys($services);
$container->setDefinition($registryId, new Definition(TypeRegistry::class, [
$locatorRef,
array_combine($typeNames, $typeNames),
]));

$container
->getDefinition(sprintf('doctrine.dbal.%s_connection.configuration', $name))
->addMethodCall('setTypeProvider', [$registryRef]);
}
}

/** @return array<string, array<array{type_name?: string}>> */
private function findTaggedResourceIds(ContainerBuilder $container): array
/**
* Fallback approach: register types in the global type registry via doctrine.dbal.connection_factory.types.
* Used when DBAL does not support TypeRegistry injection (DBAL < 4.5).
* Does not support DI or per-connection type restriction.
*/
private function registerInConfig(ContainerBuilder $container): void
{
// Determine if the version of symfony/dependency-injection is >= 7.3
/** @phpstan-ignore function.alreadyNarrowedType */
if (method_exists($container, 'findTaggedResourceIds')) {
return $container->findTaggedResourceIds(self::TAG);
}
$types = $container->getParameter('doctrine.dbal.connection_factory.types');

// Needed to keep compatibility with symfony/dependency-injection < 7.3
$tags = [];
foreach ($container->getDefinitions() as $id => $definition) {
if (! $definition->hasTag(self::TAG)) {
continue;
}
foreach ($container->findTaggedServiceIds(self::TAG) as $id => $tags) {
$definition = $container->getDefinition($id);

if (! $definition->hasTag('container.excluded')) {
throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" is missing the "container.excluded" tag.', $id, self::TAG));
$class = $definition->getClass();
if (! $class) {
throw new InvalidArgumentException(sprintf('The definition of "%s" must define its class.', $id));
}

$class = $container->getParameterBag()->resolveValue($definition->getClass());
if (! $class || $definition->isAbstract()) {
throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" must have a class and not be abstract.', $id, self::TAG));
if (! is_subclass_of($class, Type::class)) {
throw new InvalidArgumentException(sprintf('The "%s" class must extends "%s".', $class, Type::class));
}

if ($definition->getClass() !== $class) {
$definition->setClass($class);
}
// The type is instantiated by DBAL, not used as a service, so exclude its
// definition from the container.
$definition->addTag('container.excluded', ['source' => sprintf('by tag "%s"', self::TAG)]);

$tags[$id] = $definition->getTag(self::TAG);
foreach ($tags as $tag) {
$types[$tag['type_name'] ?? $tag['type'] ?? $id] = ['class' => $class];
}
}

return $tags;
$container->setParameter('doctrine.dbal.connection_factory.types', $types);
}
}
14 changes: 14 additions & 0 deletions tests/DataCollector/DoctrineDataCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
namespace Doctrine\Bundle\DoctrineBundle\Tests\DataCollector;

use Doctrine\Bundle\DoctrineBundle\DataCollector\DoctrineDataCollector;
use Doctrine\DBAL\Configuration as DBALConfiguration;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\TypeProvider;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
Expand All @@ -21,6 +24,7 @@
use Symfony\Component\HttpFoundation\Response;

use function interface_exists;
use function method_exists;

/**
* @phpstan-type GroupedQueryItemType = array{
Expand Down Expand Up @@ -56,6 +60,16 @@ public function testCollectEntities(): void

$manager->method('getMetadataFactory')->willReturn($factory);
$manager->method('getConfiguration')->willReturn($config);
if (method_exists(DBALConfiguration::class, 'getTypeProvider')) {
$dbalConfig = $this->createStub(DBALConfiguration::class);
// TypeProvider is an interface, so it can simply be stubbed.
/** @phpstan-ignore class.notFound (TypeProvider only exists on DBAL >= 4.5) */
$dbalConfig->method('getTypeProvider')->willReturn($this->createStub(TypeProvider::class));
$connection = $this->createStub(Connection::class);
$connection->method('getConfiguration')->willReturn($dbalConfig);
$manager->method('getConnection')->willReturn($connection);
}

$manager->method('getUnitOfWork')->willReturn($unitOfWork);
$unitOfWork->method('getIdentityMap')->willReturn([
self::FIRST_ENTITY => [new stdClass()],
Expand Down
Loading
Loading