Skip to main content

Writing a converter

A converter is the only place a value changes shape between PHP and a column, so a type the package does not handle needs one. DateTimeImmutable is the common case: there is no built-in converter for it, because the column type and format it should use are an application's decision.

The interface

namespace Dirthara\Entity\Type;

interface TypeConverter
{
/**
* @return class-string|non-empty-string
*/
public function type(): string;

public function toDatabase(mixed $value): string|int|float|bool;

public function fromDatabase(mixed $value): mixed;
}
MethodHas to promise
type()The registry key. Return the class name to have a property of that type resolve to it automatically, or a short name such as 'money' to be named with #[Column(converter: ...)].
toDatabase()A single bindable scalar. Never null, and never an array or object.
fromDatabase()The property's type. Whatever the driver reported is the input, so accept every shape a driver might use.

Both directions receive mixed and must refuse what they cannot handle by throwing TypeConversionException. Neither is ever given null; the package handles that around them, as described under null.

caution

toDatabase() returning anything but a scalar will not type-check. The query builder binds parameters, and a bound parameter can only be a scalar or null, so a converter that wants to store a structure has to encode it — that is what the json and serialized converters do.

A worked example

use DateTimeImmutable;
use Dirthara\Entity\Exception\TypeConversionException;
use Dirthara\Entity\Type\TypeConverter;

final readonly class DateTimeConverter implements TypeConverter
{
private const string FORMAT = 'Y-m-d H:i:s';

public function type(): string
{
return DateTimeImmutable::class;
}

public function toDatabase(mixed $value): string
{
if (!$value instanceof DateTimeImmutable) {
throw TypeConversionException::invalidValue(
expected: DateTimeImmutable::class,
actual: $value,
);
}

return $value->format(self::FORMAT);
}

public function fromDatabase(mixed $value): DateTimeImmutable
{
if (!is_string($value)) {
throw TypeConversionException::invalidColumnValue(
expected: 'date-time string',
actual: $value,
);
}

return DateTimeImmutable::createFromFormat(self::FORMAT, $value)
?: throw TypeConversionException::invalidColumnValue(
expected: self::FORMAT,
actual: $value,
);
}
}

Register it and the property maps with no attribute, because type() returns the class name the property is typed with:

$types = new TypeRegistry([new DateTimeConverter()]);
public DateTimeImmutable $createdAt;

Which exception to throw

FactoryFor
TypeConversionException::invalidValue(string $expected, mixed $actual)toDatabase() was handed something it cannot write.
TypeConversionException::invalidColumnValue(string $expected, mixed $actual)fromDatabase() was handed a column value it cannot read.
TypeConversionException::conversionFailed(string $type, mixed $value, Throwable $previous)Something underneath failed, such as a JsonException.
TypeConversionException::unsupportedType(string $type)Only the registry throws this one.

Each records the value in its context, so keep a secret out of a converted property or out of your logs. See Error handling.

Testing one

A converter is a pure function in both directions, so it needs no database:

$converter = new DateTimeConverter();

self::assertSame(
'2026-09-14 12:00:00',
$converter->toDatabase(new DateTimeImmutable('2026-09-14 12:00:00')),
);

What a real driver reports for a column is the part worth checking against a server. The package's own conformance suite maps one column of every convertible type and runs it against all four databases, which is where a driver that answers with a string for a float shows up.