pixelfederation / doctrine-generic-types-bundle
Map value-object hierarchies to Doctrine DBAL types in Symfony applications.
Package info
github.com/pixelfederation/doctrine-generic-types-bundle
Type:symfony-bundle
pkg:composer/pixelfederation/doctrine-generic-types-bundle
Requires
- php: ^8.5
- composer/class-map-generator: ^1.7
- doctrine/dbal: ^4.4
- doctrine/doctrine-bundle: ^3.3
- symfony/http-kernel: ^7.4 || ^8.1
Requires (Dev)
- dama/doctrine-test-bundle: ^8.6
- doctrine/orm: ^3.5
- ergebnis/composer-normalize: ^2.52
- friendsofphp/php-cs-fixer: ^3.89
- jms/serializer: ^3.32
- matthiasnoback/symfony-dependency-injection-test: ^6.1
- php-parallel-lint/php-parallel-lint: ^1.4
- phpro/grumphp: ^2.23
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.3
- pixelfederation/coding-standards: ^5.5
- psalm/phar: ^6.16
- ramsey/uuid: ^4.9
- roave/security-advisories: dev-latest
- symfony/dotenv: ^7.4 || ^8.1
- symfony/flex: ^2.11
- symfony/phpunit-bridge: ^7.4 || ^8.1
- symfony/property-access: ^7.4 || ^8.1
- symfony/serializer: ^7.4 || ^8.1
- symfony/uid: ^7.4 || ^8.1
Suggests
- jms/serializer-bundle: Required when using the JMS Serializer value and Doctrine type bridge in Symfony.
- ramsey/uuid: Required when using the Ramsey UUID value and Doctrine type bridge.
- symfony/doctrine-bridge: Required when using the Symfony UID Doctrine type bridge.
- symfony/serializer: Required when using the Symfony Serializer value and Doctrine type bridge.
- symfony/uid: Required when using the Symfony UID value and Doctrine type bridge.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 07:34:23 UTC
README
PixelFederation DoctrineGenericTypesBundle
Installation
Install via Composer:
composer require pixelfederation/doctrine-generic-types-bundle
Register the bundle in config/bundles.php if you don't use Symfony Flex:
return [ // ... PixelFederation\DoctrineGenericTypesBundle\PixelFederationDoctrineGenericTypesBundle::class => ['all' => true], ];
Configure the value hierarchy, its Doctrine type, and the directory containing concrete values:
<?php // config/packages/pixel_federation_doctrine_generic_types.php declare(strict_types=1); use PixelFederation\DoctrineGenericTypesBundle\Doctrine\Type\StringValueType; use PixelFederation\DoctrineGenericTypesBundle\Value\StringValue; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function (ContainerConfigurator $container): void { $container->extension('pixel_federation_doctrine_generic_types', [ 'generic_types' => [ StringValue::class => StringValueType::class, ], 'directories' => [ './src/Value', ], ]); };
Usage
Create a value object:
<?php declare(strict_types=1); namespace App\Value; use PixelFederation\DoctrineGenericTypesBundle\Value\StringValue; final readonly class FirstName extends StringValue { }
Use its concrete class as the Doctrine column type:
<?php declare(strict_types=1); namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use App\Value\FirstName; #[ORM\Entity] #[ORM\Table(name: 'person')] class Person { public function __construct( #[ORM\Column(type: FirstName::class, length: 255)] public FirstName $firstName, ) { } }
Doctrine now persists and hydrates FirstName automatically. Add mappings only for the value families used by the
application; the complete list is below.
String-based generic types require an explicit column length because Doctrine ORM only provides the default
length for its built-in string type.
Built-in value types
The bundle provides these base value classes and matching Doctrine types:
| Value base | Doctrine type | PHP database value | Column options |
|---|---|---|---|
AsciiStringValue |
AsciiStringValueType |
ASCII string |
length |
BooleanValue |
BooleanValueType |
bool |
— |
IntegerValue |
IntegerValueType |
int |
— |
BigIntegerValue |
BigIntegerValueType |
int or integer string |
— |
FloatValue |
FloatValueType |
float |
— |
DecimalValue |
DecimalValueType |
numeric string |
precision and scale |
StringValue |
StringValueType |
string |
length |
LongTextValue |
LongTextValueType |
string |
— |
DateValue |
DateValueType |
DateTimeImmutable |
— |
DateTimeValue |
DateTimeValueType |
DateTimeImmutable |
— |
TimeValue |
TimeValueType |
DateTimeImmutable |
— |
DateIntervalValue |
DateIntervalValueType |
DateInterval |
— |
JsonSerializableValue |
JsonSerializableValueType |
result of jsonSerialize() |
— |
NativeJsonValue |
NativeJsonValueType |
mixed |
— |
BigIntegerValue accepts integers and signed decimal integer strings so that values outside PHP's integer range
remain exact.
DecimalValue accepts numeric strings to avoid floating-point precision loss. Decimal entity fields must declare
their precision and scale, for example #[ORM\Column(type: Price::class, precision: 10, scale: 2)].
AsciiStringValue rejects non-ASCII characters and, like StringValue, requires an explicit column length.
TimeValue represents a time of day without a date or timezone; DBAL hydrates its DateTimeImmutable value with
1970-01-01 as the date. DateIntervalValue delegates Doctrine's portable ISO-8601 interval representation to the
native DBAL dateinterval type.
JSON values
Two JSON value bases are available. JsonSerializableValue is intended for structured value objects. It requires
the concrete value to implement jsonSerialize() and fromDbValue(), making both serialization and hydration
explicit:
use Override; use PixelFederation\DoctrineGenericTypesBundle\Value\JsonSerializableValue; final readonly class Address extends JsonSerializableValue { public function __construct( public string $city, public string $street, ) { } #[Override] public function jsonSerialize(): array { return ['city' => $this->city, 'street' => $this->street]; } #[Override] public static function fromDbValue(mixed $dbValue): static { // Validate the decoded JSON structure as required by the domain. return new static($dbValue['city'], $dbValue['street']); } }
NativeJsonValue stores any value accepted by Doctrine DBAL's JSON type. Doctrine serializes the value with
json_encode() and decodes JSON objects into associative arrays when loading them. This is convenient for native
arrays and scalar JSON values, but applications are responsible for ensuring that the value is JSON-serializable.
Configure each hierarchy with its corresponding Doctrine type:
'generic_types' => [
JsonSerializableValue::class => JsonSerializableValueType::class,
NativeJsonValue::class => NativeJsonValueType::class,
],
Serializer bridges
Serializer bridges persist the complete value object as JSON and deserialize the stored JSON directly back into
its concrete value class. Unlike JsonSerializableValue, the value object does not implement its own serialization
or hydration methods; serializer metadata and configuration control both directions.
Symfony Serializer
Install and configure Symfony Serializer in the application. The Serializer Pack includes the normalizers and supporting components commonly needed for object hydration:
composer require symfony/serializer-pack
Implement SymfonySerializerValue:
use PixelFederation\DoctrineGenericTypesBundle\Bridge\SymfonySerializer\Value\SymfonySerializerValue; final readonly class Address implements SymfonySerializerValue { public function __construct( public string $city, public string $street, ) { } }
Map the hierarchy to SymfonySerializerValueType:
use PixelFederation\DoctrineGenericTypesBundle\Bridge\SymfonySerializer\Doctrine\Type\SymfonySerializerValueType; use PixelFederation\DoctrineGenericTypesBundle\Bridge\SymfonySerializer\Value\SymfonySerializerValue; 'generic_types' => [ SymfonySerializerValue::class => SymfonySerializerValueType::class, ], 'serializer_bridges' => [ 'symfony' => [ 'service' => 'serializer', ], ],
The service option is required when the Symfony bridge is enabled and may reference the standard serializer
service or a dedicated serializer service. The bundle registers SymfonySerializerGenericTypeFactory with that
service and creates one Doctrine type instance for each discovered concrete SymfonySerializerValue class.
JMS Serializer
Install and enable JMSSerializerBundle so that the application container exposes the jms_serializer service:
composer require jms/serializer-bundle
// config/bundles.php return [ // ... JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true], ];
Implement JmsSerializerValue and add any JMS metadata required by the value object:
use PixelFederation\DoctrineGenericTypesBundle\Bridge\JmsSerializer\Value\JmsSerializerValue; final readonly class Address implements JmsSerializerValue { public function __construct( public string $city, public string $street, ) { } }
Map the hierarchy to JmsSerializerValueType:
use PixelFederation\DoctrineGenericTypesBundle\Bridge\JmsSerializer\Doctrine\Type\JmsSerializerValueType; use PixelFederation\DoctrineGenericTypesBundle\Bridge\JmsSerializer\Value\JmsSerializerValue; 'generic_types' => [ JmsSerializerValue::class => JmsSerializerValueType::class, ], 'serializer_bridges' => [ 'jms' => [ 'service' => 'jms_serializer', ], ],
The service option is required when the JMS bridge is enabled. It normally references the jms_serializer service
provided by JMSSerializerBundle, but it may reference any service implementing JMS SerializerInterface.
Both bridges use the generic type factory mechanism. GenericTypeFactoryProvider receives all services tagged with
GenericTypeFactoryProvider::TAG and selects the first supporting factory in priority order. The built-in
StaticGenericTypeFactory remains responsible for types implementing
StaticGenericType. Serializer types implement only the GenericType marker because their factories construct them
with dependencies from the service container.
Neither bridge is enabled by merely installing its serializer package. A configured bridge fails container compilation when its service ID does not exist, preventing a persistence mapping from silently using the wrong serializer.
Serializer output becomes part of the database format. Changes to property names, exclusion rules, groups, custom normalizers, handlers, or serializer metadata may therefore require a data migration. Keep serializer configuration used for persistence stable and ensure that it can reconstruct readonly constructors and nested value objects.
Ramsey UUID bridge
Install Ramsey UUID:
composer require ramsey/uuid
Extend the Ramsey UuidValue base and map its hierarchy to the GUID-backed UuidValueType:
use PixelFederation\DoctrineGenericTypesBundle\Bridge\RamseyUuid\Doctrine\Type\UuidValueType; use PixelFederation\DoctrineGenericTypesBundle\Bridge\RamseyUuid\Value\UuidValue; 'generic_types' => [ UuidValue::class => UuidValueType::class, ],
Symfony UID bridge
The optional Symfony UID bridge provides UUID and ULID value bases with two storage strategies each:
composer require symfony/uid symfony/doctrine-bridge
UuidValueTypedelegates to Symfony's Doctrine UUID type. It uses a native GUID where supported and a fixed 16-byte binary column otherwise.UuidValueChar36Typestores the canonical RFC 4122 UUID with hyphens in a fixedCHAR(36)column.UlidValueTypedelegates to Symfony's Doctrine ULID type and uses a native GUID or fixed 16-byte binary column.UlidValueChar26Typestores the canonical Base32 ULID in a fixedCHAR(26)column.
Choose one type for your Symfony UUID value hierarchy in the bundle configuration:
use PixelFederation\DoctrineGenericTypesBundle\Bridge\SymfonyUid\Doctrine\Type\UuidValueChar36Type; use PixelFederation\DoctrineGenericTypesBundle\Bridge\SymfonyUid\Doctrine\Type\UuidValueType; use PixelFederation\DoctrineGenericTypesBundle\Bridge\SymfonyUid\Value\UuidValue; 'generic_types' => [ UuidValue::class => UuidValueType::class, // Or use UuidValueChar36Type::class for CHAR(36) storage. ],
Custom generic types
For values backed by a native DBAL type, extend BaseGenericType. It supplies the common validation and conversion
flow; the custom type only selects its value hierarchy and underlying DBAL type:
use App\Value\EmailAddress; use Doctrine\DBAL\Types\StringType; use Doctrine\DBAL\Types\Type; use Override; use PixelFederation\DoctrineGenericTypesBundle\Doctrine\Type\BaseGenericType; /** @extends BaseGenericType<EmailAddress> */ final class EmailAddressType extends BaseGenericType { #[Override] protected static function getAbstractValueClass(): string { return EmailAddress::class; } #[Override] protected static function createDoctrineType(): Type { return new StringType(); } }
Map EmailAddress::class to EmailAddressType::class under generic_types and include the directory containing its
concrete subclasses. Implement StaticGenericType directly only when BaseGenericType cannot model the required
conversion.
Custom GenericTypeFactory
Types implementing StaticGenericType are instantiated by the built-in StaticGenericTypeFactory through their
createForValue() method. If a Doctrine type needs services from the dependency injection container, implement only
the GenericType marker and create it with a custom GenericTypeFactory instead.
For example, given a Doctrine type whose constructor requires an application serializer:
namespace App\Doctrine\Type; use App\Serializer\ValueSerializer; use Doctrine\DBAL\Types\JsonType; use PixelFederation\DoctrineGenericTypesBundle\Doctrine\Type\GenericType; use PixelFederation\DoctrineGenericTypesBundle\Value\Value; final class SerializedValueType extends JsonType implements GenericType { /** @param class-string<Value<mixed>> $valueClass */ public function __construct( private readonly string $valueClass, private readonly ValueSerializer $serializer, ) { } // Implement the Doctrine conversion methods using $valueClass and $serializer. }
Create a factory that supports that Doctrine type and passes its dependencies to each created instance:
namespace App\Doctrine\TypeRegistry; use App\Doctrine\Type\SerializedValueType; use App\Serializer\ValueSerializer; use Doctrine\DBAL\Types\Type; use Override; use PixelFederation\DoctrineGenericTypesBundle\Doctrine\Type\GenericType; use PixelFederation\DoctrineGenericTypesBundle\Doctrine\TypeRegistry\GenericTypeFactory; use PixelFederation\DoctrineGenericTypesBundle\Value\Value; final readonly class SerializedValueTypeFactory implements GenericTypeFactory { public function __construct( private ValueSerializer $serializer, ) { } /** @param class-string<GenericType> $type */ #[Override] public function supports(string $type): bool { return $type === SerializedValueType::class; } /** * @param class-string<GenericType> $type * @param class-string<Value<mixed>> $value */ #[Override] public function create(string $type, string $value): Type { return new SerializedValueType($value, $this->serializer); } }
Register the factory as a service tagged with GenericTypeFactoryProvider::TAG:
// config/services.php use App\Doctrine\TypeRegistry\SerializedValueTypeFactory; use PixelFederation\DoctrineGenericTypesBundle\Doctrine\TypeRegistry\GenericTypeFactoryProvider; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function (ContainerConfigurator $container): void { $container->services() ->set(SerializedValueTypeFactory::class) ->autowire() ->tag(GenericTypeFactoryProvider::TAG, ['priority' => 100]); };
The value hierarchy is mapped to the custom type in the same way as any built-in generic type:
use App\CustomValue\SerializedValue; use App\Doctrine\Type\SerializedValueType; 'generic_types' => [ SerializedValue::class => SerializedValueType::class, ],
Symfony orders the tagged factories by their priority from highest to lowest. The built-in factories use
GenericTypeFactoryProvider::DEFAULT_PRIORITY (0), so a positive priority allows a custom factory to take
precedence. Factories with the same priority retain their container registration order.
GenericTypeFactoryProvider uses the first ordered factory whose supports() method returns true. A factory should
therefore support only the Doctrine types it can construct and should validate the supplied value class in
create() when the type is restricted to a particular value hierarchy.