diff --git a/docs/en/dbal-type.rst b/docs/en/dbal-type.rst
new file mode 100644
index 000000000..25967abba
--- /dev/null
+++ b/docs/en/dbal-type.rst
@@ -0,0 +1,88 @@
+DBAL Types
+==========
+
+Custom DBAL types can be registered using the ``AsDbalType`` attribute. This
+attribute allows you to define a name for your custom type directly in the class
+definition. If the name is not provided, the class name will be used as the default.
+
+To register a custom DBAL type, create a class that extends
+``Doctrine\DBAL\Types\Type`` and add the ``#[AsDbalType]`` attribute to it:
+
+.. code-block:: php
+
+ namespace App\Doctrine\Type;
+
+ use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType;
+ use Doctrine\DBAL\Platforms\AbstractPlatform;
+ use Doctrine\DBAL\Types\Type;
+
+ #[AsDbalType(name: 'money')]
+ class MoneyType extends Type
+ {
+ public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
+ {
+ return $platform->getDecimalTypeDeclarationSQL($column);
+ }
+
+ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed
+ {
+ return $value;
+ }
+
+ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed
+ {
+ return $value;
+ }
+ }
+
+When using the ``AsDbalType`` attribute, the type will be automatically
+registered with Doctrine.
+
+Manual Registration
+-------------------
+
+Alternatively, you can register custom types in your configuration:
+
+.. configuration-block::
+
+ .. code-block:: yaml
+
+ # config/packages/doctrine.yaml
+ doctrine:
+ dbal:
+ types:
+ money: App\Doctrine\Type\MoneyType
+
+ .. code-block:: xml
+
+
+
+
+
+
+ App\Doctrine\Type\MoneyType
+
+
+
+
+ .. code-block:: php
+
+ // config/packages/doctrine.php
+ use App\Doctrine\Type\MoneyType;
+ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+
+ return static function (ContainerConfigurator $containerConfigurator): void {
+ $containerConfigurator->extension('doctrine', [
+ 'dbal' => [
+ 'types' => [
+ 'money' => MoneyType::class,
+ ],
+ ],
+ ]);
+ };
diff --git a/docs/en/index.rst b/docs/en/index.rst
index 6e535144f..a408f6f40 100644
--- a/docs/en/index.rst
+++ b/docs/en/index.rst
@@ -8,6 +8,7 @@ configuration options, console commands and even a web debug toolbar collector.
installation
doctrine-console
+ dbal-type
entity-listeners
event-listeners
custom-id-generators
diff --git a/src/Attribute/AsDbalType.php b/src/Attribute/AsDbalType.php
new file mode 100644
index 000000000..dee4e8bf4
--- /dev/null
+++ b/src/Attribute/AsDbalType.php
@@ -0,0 +1,15 @@
+ $reflector
+ *
+ * @template T of ReflectionClass
+ */
+ public static function autoconfigureFromAttribute(ChildDefinition $definition, AsDbalType $type, ReflectionClass $reflector): void
+ {
+ $attributes = [
+ 'type_name' => $type->name ?? $reflector->name,
+ ];
+
+ // 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);
+ } 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)]);
+ }
+ }
+
+ public function process(ContainerBuilder $container): void
+ {
+ $types = $container->getParameter('doctrine.dbal.connection_factory.types');
+
+ 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));
+ }
+
+ if (! is_subclass_of($class, Type::class)) {
+ throw new InvalidArgumentException(sprintf('The "%s" class must extends "%s".', $class, Type::class));
+ }
+
+ $types[$tag['type_name'] ?? $id] = ['class' => $class];
+ }
+ }
+
+ $container->setParameter('doctrine.dbal.connection_factory.types', $types);
+ }
+
+ /** @return array> */
+ private function findTaggedResourceIds(ContainerBuilder $container): array
+ {
+ // 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);
+ }
+
+ // Needed to keep compatibility with symfony/dependency-injection < 7.3
+ $tags = [];
+ foreach ($container->getDefinitions() as $id => $definition) {
+ if (! $definition->hasTag(self::TAG)) {
+ continue;
+ }
+
+ if (! $definition->hasTag('container.excluded')) {
+ throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" is missing the "container.excluded" tag.', $id, self::TAG));
+ }
+
+ $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 ($definition->getClass() !== $class) {
+ $definition->setClass($class);
+ }
+
+ $tags[$id] = $definition->getTag(self::TAG);
+ }
+
+ return $tags;
+ }
+}
diff --git a/src/DependencyInjection/DoctrineExtension.php b/src/DependencyInjection/DoctrineExtension.php
index 1db266b04..a505af539 100644
--- a/src/DependencyInjection/DoctrineExtension.php
+++ b/src/DependencyInjection/DoctrineExtension.php
@@ -4,6 +4,7 @@
namespace Doctrine\Bundle\DoctrineBundle\DependencyInjection;
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsMiddleware;
@@ -11,6 +12,7 @@
use Doctrine\Bundle\DoctrineBundle\ConnectionFactory;
use Doctrine\Bundle\DoctrineBundle\Dbal\RegexSchemaAssetFilter;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\IdGeneratorPass;
+use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass;
use Doctrine\Bundle\DoctrineBundle\Mapping\ContainerEntityListenerResolver;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepositoryInterface;
@@ -550,6 +552,9 @@ private function dbalLoad(array $config, ContainerBuilder $container): void
$this->loadDbalConnection($name, $connection, $container);
}
+ /** @phpstan-ignore argument.type (Needed for the $reflector parameter) */
+ $container->registerAttributeForAutoconfiguration(AsDbalType::class, RegisterDbalTypePass::autoconfigureFromAttribute(...));
+
$container->registerForAutoconfiguration(MiddlewareInterface::class)->addTag('doctrine.middleware');
$container->registerAttributeForAutoconfiguration(AsMiddleware::class, static function (ChildDefinition $definition, AsMiddleware $attribute): void {
diff --git a/src/DoctrineBundle.php b/src/DoctrineBundle.php
index 02d6856cd..a4266d0bd 100644
--- a/src/DoctrineBundle.php
+++ b/src/DoctrineBundle.php
@@ -9,6 +9,7 @@
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\EntityListenerPass;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\IdGeneratorPass;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\MiddlewaresPass;
+use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RemoveLoggingMiddlewarePass;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RemoveProfilerControllerPass;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass;
@@ -68,6 +69,7 @@ public function process(ContainerBuilder $container): void
$container->addCompilerPass(new RemoveLoggingMiddlewarePass());
$container->addCompilerPass(new MiddlewaresPass());
$container->addCompilerPass(new RegisterUidTypePass());
+ $container->addCompilerPass(new RegisterDbalTypePass());
if (! class_exists(RegisterDatePointTypePass::class)) {
return;
diff --git a/tests/BundleTest.php b/tests/BundleTest.php
index 3f99b6913..1c557bd44 100644
--- a/tests/BundleTest.php
+++ b/tests/BundleTest.php
@@ -5,6 +5,7 @@
namespace Doctrine\Bundle\DoctrineBundle\Tests;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DbalSchemaFilterPass;
+use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Doctrine\DependencyInjection\CompilerPass\DoctrineValidationPass;
@@ -22,9 +23,10 @@ public function testBuildCompilerPasses(): void
$config = $container->getCompilerPassConfig();
$passes = $config->getBeforeOptimizationPasses();
- $foundEventListener = false;
- $foundValidation = false;
- $foundSchemaFilter = false;
+ $foundEventListener = false;
+ $foundValidation = false;
+ $foundSchemaFilter = false;
+ $foundRegisterDbalType = false;
foreach ($passes as $pass) {
if ($pass instanceof RegisterEventListenersAndSubscribersPass) {
@@ -33,11 +35,14 @@ public function testBuildCompilerPasses(): void
$foundValidation = true;
} elseif ($pass instanceof DbalSchemaFilterPass) {
$foundSchemaFilter = true;
+ } elseif ($pass instanceof RegisterDbalTypePass) {
+ $foundRegisterDbalType = true;
}
}
$this->assertTrue($foundEventListener, 'RegisterEventListenersAndSubscribersPass was not found');
$this->assertTrue($foundValidation, 'DoctrineValidationPass was not found');
$this->assertTrue($foundSchemaFilter, 'DbalSchemaFilterPass was not found');
+ $this->assertTrue($foundRegisterDbalType, 'RegisterDbalTypePass was not found');
}
}
diff --git a/tests/DependencyInjection/Compiler/RegisterDbalTypePassTest.php b/tests/DependencyInjection/Compiler/RegisterDbalTypePassTest.php
new file mode 100644
index 000000000..48ee2f543
--- /dev/null
+++ b/tests/DependencyInjection/Compiler/RegisterDbalTypePassTest.php
@@ -0,0 +1,80 @@
+addCompilerPass(new RegisterDbalTypePass());
+
+ $container->setParameter('doctrine.dbal.connection_factory.types', []);
+
+ $container->register(BarType::class)
+ ->addTag('doctrine.dbal.type', ['type_name' => 'bar'])
+ ->addTag('container.excluded');
+
+ $container->compile();
+
+ self::assertSame(['bar' => ['class' => BarType::class]], $container->getParameter('doctrine.dbal.connection_factory.types'));
+ }
+
+ public function testServiceIdMustBeUsedAsTypeNameIfNotDefined(): void
+ {
+ $container = new ContainerBuilder();
+ $container->addCompilerPass(new RegisterDbalTypePass());
+
+ $container->setParameter('doctrine.dbal.connection_factory.types', []);
+
+ $container->register('doctrine.dbal.type.bar')
+ ->setClass(BarType::class)
+ ->addTag('doctrine.dbal.type')
+ ->addTag('container.excluded');
+
+ $container->compile();
+
+ self::assertSame(['doctrine.dbal.type.bar' => ['class' => BarType::class]], $container->getParameter('doctrine.dbal.connection_factory.types'));
+ }
+
+ public function testTypeMustBeASubclassOfTheDbalBaseType(): void
+ {
+ $container = new ContainerBuilder();
+ $container->addCompilerPass(new RegisterDbalTypePass());
+
+ $container->setParameter('doctrine.dbal.connection_factory.types', []);
+
+ $container->register(NotASubClassOfDbalBaseType::class)
+ ->addTag('doctrine.dbal.type', ['type_name' => 'invalid_type'])
+ ->addTag('container.excluded');
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage(sprintf('The "%s" class must extends "%s".', NotASubClassOfDbalBaseType::class, Type::class));
+
+ $container->compile();
+ }
+}
+
+class BarType extends Type
+{
+ /** @param array $column */
+ public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
+ {
+ return 'bar';
+ }
+}
+
+class NotASubClassOfDbalBaseType
+{
+}
diff --git a/tests/DependencyInjection/DoctrineExtensionTest.php b/tests/DependencyInjection/DoctrineExtensionTest.php
index 69d6b9d98..f7381e437 100644
--- a/tests/DependencyInjection/DoctrineExtensionTest.php
+++ b/tests/DependencyInjection/DoctrineExtensionTest.php
@@ -5,11 +5,14 @@
namespace Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection;
use Closure;
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\Bundle\DoctrineBundle\CacheWarmer\DoctrineMetadataCacheWarmer;
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\DoctrineExtension;
use Doctrine\Bundle\DoctrineBundle\Tests\Builder\BundleConfigurationBuilder;
+use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\DbalType;
+use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\DbalTypeNoName;
use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\Php8EntityListener;
use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\Php8EventListener;
use Doctrine\DBAL\Connection;
@@ -948,6 +951,46 @@ public static function cacheConfigurationProvider(): array
];
}
+ /** @param class-string $typeClassname */
+ #[DataProvider('provideDatabaseTypeAttribute')]
+ public function testAsDatabaseTypeAttribute(string $typeClassname, string $expectedTypeName): void
+ {
+ $container = $this->getContainer();
+ $extension = new DoctrineExtension();
+
+ $config = BundleConfigurationBuilder::createBuilder()
+ ->addBaseConnection()
+ ->build();
+
+ $extension->load([$config], $container);
+
+ /** @phpstan-ignore function.alreadyNarrowedType */
+ $attributes = method_exists($container, 'getAttributeAutoconfigurators')
+ ? array_map(static fn (array $arr) => $arr[0], $container->getAttributeAutoconfigurators())
+ /** @phpstan-ignore method.notFound */
+ : $container->getAutoconfiguredAttributes();
+ $this->assertInstanceOf(Closure::class, $attributes[AsDbalType::class]);
+
+ $reflector = new ReflectionClass($typeClassname);
+ $definition = new ChildDefinition('');
+ $attribute = $reflector->getAttributes(AsDbalType::class)[0]->newInstance();
+
+ $attributes[AsDbalType::class]($definition, $attribute, $reflector);
+
+ $expected = ['type_name' => $expectedTypeName];
+ $this->assertSame([$expected], $definition->getTag('doctrine.dbal.type'));
+ $this->assertSame([['source' => 'by tag "doctrine.dbal.type"']], $definition->getTag('container.excluded'));
+ }
+
+ /** @return array */
+ public static function provideDatabaseTypeAttribute(): array
+ {
+ return [
+ 'with type name' => [DbalType::class, 'dbal_type'],
+ 'without type name' => [DbalTypeNoName::class, DbalTypeNoName::class],
+ ];
+ }
+
/** @return array */
public static function provideAttributeExcludedFromContainer(): array
{
diff --git a/tests/DependencyInjection/Fixtures/DbalType.php b/tests/DependencyInjection/Fixtures/DbalType.php
new file mode 100644
index 000000000..bf5fef26e
--- /dev/null
+++ b/tests/DependencyInjection/Fixtures/DbalType.php
@@ -0,0 +1,19 @@
+ $column */
+ public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
+ {
+ return 'dbal_type';
+ }
+}
diff --git a/tests/DependencyInjection/Fixtures/DbalTypeNoName.php b/tests/DependencyInjection/Fixtures/DbalTypeNoName.php
new file mode 100644
index 000000000..fffabd6f7
--- /dev/null
+++ b/tests/DependencyInjection/Fixtures/DbalTypeNoName.php
@@ -0,0 +1,19 @@
+ $column */
+ public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
+ {
+ return 'dbal_type_no_name';
+ }
+}