If you use Value Objects as entity identifiers in PHP, you eventually drown in custom DBAL types: boilerplate classes, repetitive configuration, and fragile mappings that break silently when someone forgets to register a type or the type name changes.
This problem shows up consistently in projects built with Symfony and Doctrine, especially when following DDD-style modeling where identifiers are not raw UUIDs but explicit Value Objects.
use Symfony\Component\Uid\UuidV7;
abstract class Uid
{
public static function create(): static
{
return new static(UuidV7::generate());
}
public static function fromString(string $value): static
{
return new static(new UuidV7($value)->toRfc4122());
}
final private function __construct(
private(set) string $value,
) {
}
}
class ProductId extends Uid
{
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
This post explains how to implement a single generic DBAL type that supports these ID Value Objects.
This is not a primer on Value Objects or Doctrine internals. It assumes prior familiarity with both and focuses strictly on eliminating manual work and improving the DX. For that reason, all code examples are intentionally shortened to emphasize the core ideas.
Now let’s continue and create an entity identified by a first-class Value Object, not a scalar:
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Product
{
#[ORM\Id, ORM\Column]
private(set) ProductId $id;
}
That definition is clean, intentional, and fully valid at the domain level. The failure appears only when the ORM tries to bridge the gap to persistence.
At runtime, Doctrine halts with the following error:
[FAIL] The entity-class App\Entity\Product mapping is invalid:
* The field 'App\Entity\Product#id' has the property type 'App\Entity\ProductId' that differs from the metadata field type 'string' returned by the 'string' DBAL type.
💥 Doctrine has no notion of ProductId as a domain concept. It sees a PHP class on one side and a scalar database column on the other, and it refuses to guess how the two relate.
#[ORM\Id, ORM\Column(type: ProductId::class)]
private(set) ProductId $id;
The immediate fix is to make the mapping explicit so Doctrine no longer has to guess. By providing ProductId::class, you are effectively telling DBAL: “there exists a column type with this name”, DBAL then looks it up in its internal type registry.
Usually you would set a symbolic DBAL type name here (e.g.
ORM\Column(type: 'product_id')). In this case, we're using the Fully-Qualified Class Name (FQCN) of the concrete ID class instead. This is intentional, and the reason will become clear later.
Unknown column type "App\Entity\ProductId" requested. Any Doctrine type that you use has to be registered with \Doctrine\DBAL\Types\Type::addType(). You can get a list of all the known types with \Doctrine\DBAL\Types\Type::getTypesMap(). If this error occurs during database introspection then you might have forgotten to register all database types for a Doctrine Type. Use AbstractPlatform#registerDoctrineTypeMapping() or have your custom types implement Type#getMappedDatabaseTypes(). If the type name is empty you might have a problem with the cache or forgot some mapping information.
💥 It still fails because no such type has been registered. So let’s address the problem by introducing a dedicated DBAL type for these ID value objects.
First, create a new DBAL type class extending the abstract Type:
class UidType extends Type
{
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getGuidTypeDeclarationSQL($column);
}
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Uid
{
$class = $this->getUidClass();
// other checks ...
return $class::fromString($value);
}
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
$class = $this->getUidClass();
if ($value instanceof $class) {
return $value->value;
}
// other checks ...
}
/**
* @return class-string<Uid>
*/
private function getUidClass(): string
{
return self::lookupName($this);
}
}
Then, register this new DBAL type for the missing column type:
doctrine:
dbal:
types:
App\Entity\ProductId: App\DBAL\Type\UidType
This class defines a single generic DBAL type able to convert all the values back and forth from multiple identifier Value Objects that all extend a common Uid base class.
At the DBAL level, it behaves like any other custom type:
-
getSQLDeclaration()delegates column definition to the platform, using a GUID-compatible storage -
convertToPHPValue()transforms the database scalar into a domain identifier -
convertToDatabaseValue()extracts the scalar value from the identifier before persistence
So far, nothing unusual. The critical part is how the concrete identifier class is resolved:
/**
* @return class-string<Uid>
*/
private function getUidClass(): string
{
return self::lookupName($this);
}
Type::lookupName($this) returns the registered DBAL type name for the current instance. In this design, that name is not a symbolic alias like product_id. It is the FQCN of the identifier itself (App\Entity\ProductId).
Because the column mapping uses type ProductId::class, and the DBAL configuration registers that same FQCN as a type pointing to UidType, the DBAL type name is the identifier class.
As a result, the DBAL type instance knows which identifier it represents. getUidClass() returns the exact concrete Uid subclass to instantiate, and no subclassing, no switch statements, no per-ID DBAL types.
Looking to simplify this further?
DBAL type registration can be automated, and the ORM\Column::$type value can be inferred directly from the property type, eliminating repetitive configuration and manual wiring.
This solution for generic DBAL types can also be applied to any kind of Value Objects where persistence follows a consistent mapping rule and the concrete class can be resolved at runtime from the DBAL type name.
This is my last post of the year. Happy holidays!
Top comments (0)