Types and Enums

Dates as DateTimeImmutable, money as decimal strings, JSON columns, and backed enums mapped straight to a column.

Syntax#[ORM\Column(type: Types::DECIMAL, precision: 12, scale: 2)] / #[ORM\Column(enumType: Status::class)]

A Doctrine mapping type is a translator. It sits between a PHP value and a database column, converting in both directions. The constants live in Doctrine\DBAL\Types\Types, and choosing the right one is most of what keeps data honest.

Dates

Types::DATETIME_IMMUTABLE hydrates into a DateTimeImmutable. Prefer it to the mutable form. A mutable DateTime changed in place is the same object Doctrine snapshotted when it loaded the entity, so the change can go unnoticed and never reach an UPDATE. An immutable object cannot be changed in place: every modify() returns a new instance that you assign, and the assignment is what Doctrine sees. Types::DATE_IMMUTABLE stores a day with no time.

Money

Types::DECIMAL with precision and scale stores an exact value. In PHP it arrives as a string, deliberately, because a float cannot hold 0.10 exactly. Do the arithmetic with bcmath, or store minor units in an integer column.

JSON

Types::JSON stores an array or a nested structure in one column. It is right for a payload whose shape you do not control, such as a gateway response. It is wrong for anything you will filter or sort on, and there is no partial update: the whole value is compared and rewritten.

Enums

PHP 8.1 added backed enums, and Doctrine maps them with the enumType option. Only string-backed and int-backed enums work; a pure enum has no value to store. #[ORM\Column(enumType: Status::class)] is shorthand: Doctrine infers a string or integer column from the enum's backing type. The longhand names the type as well. A stored value that matches no case throws while hydrating, which is a feature: it tells you a case was renamed without a migration.

Example

Example · php
<?php
// src/Entity/Payment.php

declare(strict_types=1);

namespace App\Entity;

use App\Payment\PaymentStatus;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Payment
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // Exact value, 12 digits with 2 after the point. Hydrated as a string.
    #[ORM\Column(type: Types::DECIMAL, precision: 12, scale: 2)]
    private string $amount;

    #[ORM\Column(length: 3)]
    private string $currency;

    // Shorthand: the column type is inferred from the enum's backing type,
    // so a string-backed enum gives a string column.
    // Longhand, the same column spelled out:
    // #[ORM\Column(type: Types::STRING, length: 255, enumType: PaymentStatus::class)]
    #[ORM\Column(enumType: PaymentStatus::class)]
    private PaymentStatus $status = PaymentStatus::Pending;

    // Immutable, so a change is always an assignment Doctrine can detect.
    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    // The gateway owns this shape. Stored whole, rewritten whole.
    #[ORM\Column(type: Types::JSON)]
    private array $gatewayPayload = [];

    public function __construct(string $amount, string $currency, \DateTimeImmutable $createdAt)
    {
        $this->amount = $amount;
        $this->currency = $currency;
        $this->createdAt = $createdAt;
    }

    public function getAmount(): string
    {
        return $this->amount;
    }
}

When to use it

  • Store an order total that must still be exact after a dozen additions.
  • Replace a status column of free-text strings with a backed enum the database and PHP agree on.
  • Keep a payment gateway's whole response for support, without inventing a column per field.

More examples

The enum behind the column

Mapping an enum replaces a loose string column and the constants-plus-validation that usually guard it. The stored value is the backing value, so renaming a case means a data migration: existing rows still hold the old string and will fail to hydrate. Changing only the case name, not the value, is free.

Example · php
<?php
// src/Payment/PaymentStatus.php

declare(strict_types=1);

namespace App\Payment;

// Backed by string: 'pending' is what lands in the column. An int-backed enum
// would give an integer column instead. A pure enum (no = value) cannot be
// mapped, because there is nothing to store.
enum PaymentStatus: string
{
    case Pending = 'pending';
    case Captured = 'captured';
    case Refunded = 'refunded';

    // Behaviour belongs on the enum, not in a match spread over controllers.
    public function isSettled(): bool
    {
        return self::Pending !== $this;
    }
}

Decimal arithmetic without floats

This is why the decimal type hands you a string. bcmath takes strings, returns strings, and the scale argument fixes the number of decimal places, so the result can go straight back into the column. Reach for it for any total, tax or fee; reach for integer minor units when you would rather not depend on an extension.

Example · php
<?php
// src/Payment/AmountCalculator.php

declare(strict_types=1);

namespace App\Payment;

final class AmountCalculator
{
    // Inputs and output are decimal strings, the same form the column uses,
    // so nothing is ever converted to a float on the way through.
    public function total(string ...$amounts): string
    {
        $total = '0.00';

        foreach ($amounts as $amount) {
            $total = bcadd($total, $amount, 2);
        }

        return $total;
    }
}

$calculator = new AmountCalculator();

echo $calculator->total('19.99', '5.01', '0.10');
// Output:
// 25.10

// For contrast, the float route loses the value quietly:
var_dump(0.1 + 0.2 === 0.3);
// Output:
// bool(false)

Immutable dates in practice

The fixed constructor argument keeps the output predictable; in an application the current time comes from an injected clock rather than from new DateTimeImmutable() inside a method. The behaviour to remember is that an immutable date can only be replaced, and a replacement is an assignment Doctrine notices.

Example · php
<?php
// src/Billing/Deadline.php

declare(strict_types=1);

namespace App\Billing;

final class Deadline
{
    public function __construct(private readonly \DateTimeImmutable $issuedAt)
    {
    }

    public function dueAt(int $days): \DateTimeImmutable
    {
        // modify() on DateTimeImmutable returns a NEW object; $issuedAt is
        // untouched. On a mutable DateTime the same call changes the object
        // in place, which is how a shared date silently shifts.
        return $this->issuedAt->modify(sprintf('+%d days', $days));
    }
}

$deadline = new Deadline(new \DateTimeImmutable('2026-03-01 09:00:00'));

echo $deadline->dueAt(30)->format('Y-m-d H:i');
// Output:
// 2026-03-31 09:00

Discussion

  • Be the first to comment on this lesson.