From primitive column types to domain specific value types
How to use a custom Doctrine DBAL type to work with a Money object or an EmailAddress value object directly in entities, including convertToPHPValue, convertToDatabaseValue, and registration.
Table of Contents
- 1. Why primitive column types hit their limits
- 2. Extending Type::class: the basic structure of a custom DBAL type
- 3. convertToPHPValue and convertToDatabaseValue in detail
- 4. Registering in doctrine.yaml and using it in an entity
- 5. A second example: EmailAddress as a custom type
- 6. The concrete advantage over primitive column types
- 7. Custom type vs. Doctrine embeddable: where to draw the line
- 8. Testing your own DBAL types
- 9. Common pitfalls with custom field types
- 10. Summary
- 11. FAQ
1. Why primitive column types hit their limits
Most entities start out using string, int, and decimal for everything that does not fit an obvious built in category. A money amount ends up stored as decimal(10,2), an email address as string(255), even though both carry far more meaning than a plain number or piece of text. The problem shows up at the latest when the same validation or formatting logic has to be duplicated across multiple places in the code, because the type itself gives no guarantees.
A money amount without a currency is incomplete from a business perspective, and an email address without validation at creation time can hold an invalid value at any point. A custom DBAL type fixes this at the root: it maps a domain specific PHP value type, such as an immutable Money or EmailAddress object, directly onto a database column and guarantees that an invalid or incomplete value can never exist anywhere in the application.
2. Extending Type::class: the basic structure of a custom DBAL type
Every custom field type extends Doctrine\DBAL\Types\Type and must implement at least three methods: getSQLDeclaration() describes how the column is actually created in the database, convertToPHPValue() turns the raw database value into the PHP object, and convertToDatabaseValue() does the reverse when saving. On top of that, getName() returns the unique identifier under which the type is later referenced in entities.
For a Money value object that internally holds an integer amount in cents plus an ISO 4217 currency code, a simple VARCHAR that stores both values combined works well as the database representation, for example in the format 1999:EUR. This string representation is database agnostic, behaves the same across MySQL, PostgreSQL, or SQLite, and avoids the need for separate columns for amount and currency.
3. convertToPHPValue and convertToDatabaseValue in detail
convertToPHPValue() is called by Doctrine every time an entity is loaded, and it receives the raw database value as the first parameter and the AbstractPlatform as the second. This is where the actual object construction happens, including validation. If the stored string is malformed, a ConversionException should be thrown here so broken data never silently ends up as a broken object in the system.
convertToDatabaseValue() does the reverse when saving and receives the PHP object plus the platform as well. Since the Money object is designed to be immutable, a simple call to its own __toString() or toPersistenceString() method is enough here. The example below shows the full implementation of a MoneyType for Symfony 7 using PHP 8.4.
<?php
declare(strict_types=1);
namespace App\Doctrine\Type;
use App\ValueObject\Money;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
final class MoneyType extends Type
{
public const NAME = 'money';
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return 'VARCHAR(32)';
}
public function convertToPHPValue($value, AbstractPlatform $platform): ?Money
{
if ($value === null) {
return null;
}
[$amount, $currency] = explode(':', (string) $value);
if (!is_numeric($amount) || $currency === '') {
throw ConversionException::conversionFailed((string) $value, self::NAME);
}
return new Money((int) $amount, $currency);
}
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return null;
}
if (!$value instanceof Money) {
throw ConversionException::conversionFailedInvalidType($value, self::NAME, ['null', Money::class]);
}
return sprintf('%d:%s', $value->amountInCents(), $value->currency());
}
public function getName(): string
{
return self::NAME;
}
}
4. Registering in doctrine.yaml and using it in an entity
A custom type has to be made known to Doctrine before it can be used in an entity. This happens in doctrine.yaml under dbal.types, where the name money is mapped to the class App\Doctrine\Type\MoneyType. Only after this registration can the attribute #[ORM\Column(type: 'money')] be used in an entity without Doctrine throwing an exception about an unknown type.
In the entity itself, a single column declaration is then enough to get a complete Money object with amount and currency, instead of two separate fields for amountInCents and currency. The property on the entity is typed as ?Money, so PHPStan and the IDE can already flag type errors while writing the code, long before a test or runtime execution would reveal the mistake.
5. A second example: EmailAddress as a custom type
Besides money amounts, email addresses are also an excellent fit for a custom DBAL type, since they too have a clear validation rule, namely a valid format per RFC 5322. An EmailAddressType still stores the address as a plain VARCHAR in the database, but guarantees that loading from the database always produces a validated EmailAddress object, never a raw, potentially invalid string.
This becomes especially valuable when the address carries additional methods, such as getDomain() or normalization of case in the constructor. Every piece of code that works with this entity automatically benefits from that guarantee, without needing to validate or normalize the value again at every single call site.
6. The concrete advantage over primitive column types
The central benefit of a custom DBAL type is that validation and conversion happen in exactly one place in the code, instead of being scattered across dozens of controllers, services, and forms. A Money object can never hold a negative amount with an invalid currency code after construction, because the value object's own constructor already prevents that, long before Doctrine even gets involved.
The entity itself also becomes more readable. A property of type Money instead of two raw int and string fields communicates the business intent far more clearly and makes the relationship explicit, instead of expressing it implicitly through naming conventions like amountCents and amountCurrency, which can easily drift apart when only one of the two fields gets updated.
7. Custom type vs. Doctrine embeddable: where to draw the line
Doctrine offers embeddables as an alternative solution for similar problems, mapping a value object onto several real columns instead of serializing it into a single column. For a Money object, an embeddable would produce two separate columns, amount_cents and currency, while a custom type combines both into a single column.
The choice depends on the use case. If amount and currency need to be filtered or aggregated individually in SQL queries, for example SUM(amount_cents) GROUP BY currency, an embeddable with real columns is usually more practical. If the goal is mainly type safety and encapsulation without direct SQL access to the individual values, a custom type with a combined column is often the simpler, more maintainable solution.
8. Testing your own DBAL types
A custom type can be tested without a real database connection by calling convertToPHPValue() and convertToDatabaseValue() directly with an instance of a concrete AbstractPlatform implementation like MySQLPlatform. It is important to test both the success path and malformed input, such as a string missing the colon separator, which must trigger a ConversionException.
It is also worth adding an integration test against a real (test) database that persists an entity using the new type, clears the entity manager, and reloads the entity fresh. This verifies that the complete roundtrip, from PHP object through the database and back to a PHP object, works losslessly and that no rounding or encoding issues occur.
9. Common pitfalls with custom field types
An often overlooked detail is that requiresSQLCommentHint() should return true when the type is based on an existing native type like VARCHAR. Without this hint, Doctrine cannot reliably recognize the column type during doctrine:schema:update or doctrine:migrations:diff, and repeatedly suggests the same migration incorrectly even though nothing has actually changed in the schema.
Another pitfall is type caching. Doctrine registers types globally and statically, which means an already registered type cannot simply be re registered with different configuration in tests. In test suites it is therefore best to use a central bootstrap file that registers all custom types exactly once before the test suite runs, instead of repeating this in every single test case.
| Method | Task | Called When | Important Note |
|---|---|---|---|
| getSQLDeclaration() | Defines the column DDL | During schema creation/migration | Set requiresSQLCommentHint() when based on a native type |
| convertToPHPValue() | Raw value to PHP object | When loading the entity | Throw ConversionException on invalid data |
| convertToDatabaseValue() | PHP object to raw value | When persisting/flushing | Check type with instanceof before converting |
| getName() | Unique identifier of the type | During registration | Must match the dbal.types key |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Custom DBAL Types
Type::class
Base class for custom field types with four central methods
Conversion
convertToPHPValue/convertToDatabaseValue encapsulate validation in one place
Registration
Entry under dbal.types in doctrine.yaml, then usable in an entity
Boundary
Embeddable for multiple columns, custom type for one combined column