Symfony Doctrine: Building Custom DBAL Types for Your Own Field Types
AI generated
SF
{ }
Doctrine DBAL
Custom DBAL Types in Symfony: Building Your Own Field Types for Doctrine
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.

14 min read Doctrine DBAL Symfony 7

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

11. FAQ: Custom DBAL Types

1When is a custom DBAL type worth it over a primitive column type?
Whenever a value carries business meaning and its own validation rules, such as a money amount with currency or an email address, and that logic should not be duplicated at every place in the code that uses it.
2Which methods do I need to implement at minimum?
getSQLDeclaration(), convertToPHPValue(), convertToDatabaseValue(), and getName() are the four central methods every custom type must override to work fully.
3Where do I register a custom type?
In doctrine.yaml under dbal.types, mapping a unique name to the fully qualified class. Only after this registration can the type be referenced in an entity through an attribute.
4How do I handle invalid data when loading from the database?
In convertToPHPValue(), a ConversionException should be thrown on malformed data so broken raw data never silently continues on as a broken object in the system.
5What is requiresSQLCommentHint() and when do I need it?
This method tells Doctrine that a custom type based on a native column type like VARCHAR should be recognized separately, so schema comparisons do not incorrectly keep suggesting changes.
6When should I use a Doctrine embeddable instead?
When the individual parts of a value object, such as amount and currency, need to be filtered or aggregated directly in SQL queries, an embeddable with real individual columns is usually more practical than a custom type.
7Can I test a custom type without a real database?
Yes, convertToPHPValue() and convertToDatabaseValue() can be called directly with a concrete AbstractPlatform instance, with no connection to a real database required.
8How do I store multiple values, such as amount and currency, in a single custom type?
A common approach is a combined string representation in a single column, for example in the format amount:currency, which gets split back into the value object's individual parts when loading.
9Do I need to re register the type in every test?
No, since Doctrine registers types globally and statically, a central bootstrap file that registers all custom types exactly once before the test suite runs is enough.
10Does a custom type work with Doctrine Migrations?
Yes, as long as requiresSQLCommentHint() is set correctly, doctrine:migrations:diff reliably recognizes the schema and does not generate unnecessary repeat migrations for unchanged columns.