skip to content

With PDO::FETCH_CLASS in PHP 8.5, when does the constructor run relative to property hydration, and why does that clash with constructor-promoted readonly classes?

level: seniorimportance: should knowfreq 32%

answer

  1. properties first, constructor second
  2. FETCH_PROPS_LATE flips the order
  3. constructor gets only the ctorArgs
  4. unknown column becomes a dynamic property
  5. FETCH_ASSOC plus a named constructor

basics

~20 s

FETCH_CLASS assigns columns to same-named properties first, then calls the constructor with only the ctorArgs you supplied; FETCH_PROPS_LATE reverses that. Promoted readonly parameters then get no arguments or hit already-initialized properties, so the fetch throws.

solid answer

~40 s

With `PDO::FETCH_CLASS`, PDO creates the object, writes each column into the property of the same name, **even private or readonly ones**, and only then calls the constructor with the `ctorArgs` array you passed, empty by default. `PDO::FETCH_PROPS_LATE` calls the constructor first and assigns properties afterwards. A class built on constructor promotion, `__construct(public readonly int $id, ...)`, fits neither order: with no `ctorArgs` the constructor throws `ArgumentCountError`, and if you supply arguments, the promoted assignment hits a readonly property PDO already initialized and throws `Error`. With `FETCH_PROPS_LATE`, PDO's own writes hit the properties the constructor just set. Columns without a matching property become dynamic properties, deprecated since 8.2. For immutable objects, fetch with `FETCH_ASSOC` and map each row through a named constructor such as `Appointment::fromRow()`.

code

php · 21 lines
php
<?php
declare(strict_types=1);

final class Appointment
{
    public function __construct(
        public readonly int $id,
        public readonly int $doctorId,
        public readonly DateTimeImmutable $startsAt,
    ) {}

    public static function fromRow(array $row): self
    {
        return new self((int) $row['id'], (int) $row['doctor_id'], new DateTimeImmutable($row['starts_at']));
    }
}

/** @var PDO $pdo */
$stmt = $pdo->prepare('SELECT id, doctor_id, starts_at FROM appointments WHERE doctor_id = ?');
$stmt->execute([7]);
$appointments = array_map(Appointment::fromRow(...), $stmt->fetchAll(PDO::FETCH_ASSOC));

go deeper

for a junior

Know that FETCH_CLASS fills an object of your class from a row, matching columns to properties by name.

for a middle

Explain the order, properties before constructor unless FETCH_PROPS_LATE, and what happens to columns without a matching property.

for a senior

Recognise why readonly promoted entities break under FETCH_CLASS and choose explicit mapping through named constructors for immutable objects.

for a principal

Set a codebase rule for how rows become objects, weighing boilerplate against constructor invariants and the coupling of classes to column names.

## How FETCH_CLASS builds an object `PDO::FETCH_CLASS` asks PDO to turn each row into an instance of a class you name, via `setFetchMode(PDO::FETCH_CLASS, Appointment::class, $ctorArgs)`, `fetchAll(PDO::FETCH_CLASS, Appointment::class, $ctorArgs)` or `fetchObject(Appointment::class, $ctorArgs)`. For each row PDO: 1. Creates the object **without** running the constructor. 2. For each column, writes the value into the property **with the same name**. It writes from the class's own scope, so private and readonly properties are set too. 3. Calls the constructor, passing **only** `$ctorArgs`, which defaults to an empty array. With the flag **`PDO::FETCH_PROPS_LATE`** (`PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE`), steps 2 and 3 swap: the constructor runs first, and the column values overwrite whatever it set. As of PHP 8.5, `$ctorArgs` follows normal call semantics, so string keys act as named arguments. ## Why this fights modern value objects A typical PHP 8 entity for the clinic app looks like this: ```php final class Appointment { public function __construct( public readonly int $id, public readonly int $doctorId, public readonly string $startsAt, ) {} } ``` Neither order works: | Fetch style | What happens | |---|---| | `FETCH_CLASS`, no `ctorArgs` | columns are written first, then `__construct()` is called with no arguments: `ArgumentCountError` | | `FETCH_CLASS` with `ctorArgs` | the promoted assignment tries to set `$id` again, which PDO already initialized: `Error`, cannot modify readonly property | | `FETCH_CLASS \| FETCH_PROPS_LATE` | the constructor still needs arguments, and PDO's later writes to readonly properties fail for the same reason | The design of `FETCH_CLASS` predates constructor promotion and readonly properties. It assumes a mutable class with a no-argument constructor. ## Other traps - **Column names must match property names.** `doctor_id` will not fill `$doctorId`. The unmatched column is created as a **dynamic property**, which raises `E_DEPRECATED` since PHP 8.2 and throws `Error` for a readonly class or one that forbids dynamic properties. Alias in SQL (`doctor_id AS doctorId`) or map by hand. - **Types are whatever the driver returns.** Assigning a string such as a date column to an `int` or `DateTimeImmutable` property fails or coerces; PDO does not convert to value objects. - **A class cannot be made the connection default.** `PDO::ATTR_DEFAULT_FETCH_MODE` rejects the array form `[PDO::FETCH_CLASS, Appointment::class]` with `ValueError`, so set `FETCH_CLASS` and its class per statement. - **`FETCH_PROPS_LATE` and `FETCH_CLASSTYPE` are only valid with `FETCH_CLASS`**; using them with another mode throws `ValueError` in 8.5. ## One row, step by step Take the row `id = 101, doctor_id = 7, starts_at = '2026-10-01 09:00:00'` and a mutable class with `public int $id; public int $doctorId; public string $startsAt;` and an empty constructor: 1. PDO creates an `Appointment` without calling the constructor. 2. `id` matches `$id`, so `101` is assigned. 3. `doctor_id` matches nothing: a dynamic property `doctor_id` is created and a deprecation is raised, while `$doctorId` stays uninitialized. 4. `starts_at` likewise becomes a dynamic property, and `$startsAt` stays uninitialized. 5. The empty constructor runs. 6. Later, reading `$appointment->doctorId` throws `Error` because the typed property was never initialized. Aliasing the columns in SQL (`doctor_id AS doctorId, starts_at AS startsAt`) fixes steps 3 and 4 for a mutable class, and nothing fixes the ordering for a promoted readonly one. ## What to do instead - For **immutable objects**, fetch with `FETCH_ASSOC` and map explicitly: `array_map(Appointment::fromRow(...), $rows)`. The named constructor converts types (`(int) $row['id']`, `new DateTimeImmutable($row['starts_at'])`) and is the one place that knows the column names. - For **simple mutable DTOs** with a no-argument constructor and property names matching the aliased columns, `FETCH_CLASS` is fine and saves a mapping step. - For a **one-off object**, `fetchObject(Class::class, [...])` behaves like `FETCH_CLASS` for a single row and returns `false` when no row is left. The trade-off interviewers want to hear: `FETCH_CLASS` removes boilerplate but couples the class to column names and bypasses the constructor's guarantees, while explicit mapping costs a few lines and keeps invariants in one place.

  • When is FETCH_CLASS a reasonable choice?
    For a mutable DTO with a no-argument constructor (or none), public or private properties named exactly like the selected columns, and no invariants the constructor must enforce. Then it saves a mapping step. Alias columns in SQL to match the property names, and set the mode per statement.
  • What does FETCH_PROPS_LATE change, and does it rescue a promoted-constructor class?
    It calls the constructor before assigning column values, so the constructor sees default property values and the columns overwrite them afterwards. It does not rescue promoted readonly properties: the constructor still needs arguments, and PDO's later writes hit properties the constructor already initialized.

saying these in an interview costs you the question

  • FETCH_CLASS passes the row's columns to the constructor as arguments
  • PDO cannot set private properties, so they stay at their defaults
  • FETCH_CLASS converts snake_case columns to camelCase properties
  • FETCH_CLASS works with any class that has readonly promoted properties
  • ATTR_DEFAULT_FETCH_MODE can name a class to hydrate for every statement