Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 17 / 18 35 MIN

Services and persistence

Mapping Rows to Objects

Three ways to turn a result row into an object, which one the driver will fight you on, and why a Data Mapper is worth its code in some codebases and not others.

An array from fetch() is the fastest thing to write and the slowest thing to change. Every consumer reaches into it by string key, nothing reports a misspelled key until runtime, and the note column that used to be VARCHAR NOT NULL is now nullable without a single call site noticing. The usual response is to fetch into an object, and the usual way people do that leaves them with a class whose properties are set by PDO behind the constructor's back.

There is a real choice here with three positions, not two. A row can stay an array, become a passive object whose properties happen to match column names, or be constructed by a factory that decides what each column means. Only the third one gives you a type you can trust, and the price is one mapping function per table.

The queries and output below ran on PHP 8.5.6 against MySQL 9.6.0 with the five-row orders table, and ORD-1002 is the row used throughout because it has a null in the note column.

Three ways to hydrate the same row, side by side

Start with the query that all three paths share, and the shape each of them hands back.

<?php
declare(strict_types=1);

$pdo = new PDO($dsn, 'root', '', [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

const ORDER_SQL = 'SELECT id, reference, customer, total_cents, currency, note, placed_at
                     FROM orders WHERE reference = :reference';

// 1. an array
$statement = $pdo->prepare(ORDER_SQL);
$statement->execute(['reference' => 'ORD-1002']);
$row = $statement->fetch();
printf("%s\n", json_encode($row));
printf("total_cents is %s, note is %s\n", get_debug_type($row['total_cents']), get_debug_type($row['note']));
{"id":2,"reference":"ORD-1002","customer":"Devon Parker","total_cents":4500,"currency":"EUR","note":null,"placed_at":"2026-03-01 11:02:00"}
total_cents is int, note is null

That is the baseline. The array carries the column names, the driver's types and nothing else. There is no Order, so every function downstream takes array $order and documents the keys it needs in a docblock that the analyser cannot verify.

The second path asks PDO to assign the columns onto an instance. It requires a class with matching public properties and no constructor arguments, and the result is a passive record.

<?php
final class OrderRow
{
    public int $id;
    public string $reference;
    public string $customer;
    public int $total_cents;
    public string $currency;
    public ?string $note;
    public string $placed_at;
}

$statement = $pdo->prepare(ORDER_SQL);
$statement->execute(['reference' => 'ORD-1002']);
$order = $statement->fetchObject(OrderRow::class);

printf("id=%s total_cents=%s placed_at=%s\n",
    get_debug_type($order->id), get_debug_type($order->total_cents), get_debug_type($order->placed_at));
printf("json: %s\n", json_encode($order));
id=int total_cents=int placed_at=string
json: {"id":2,"reference":"ORD-1002","customer":"Devon Parker","total_cents":4500,"currency":"EUR","note":null,"placed_at":"2026-03-01 11:02:00"}

The declared property types do part of the work: assigning the id string to an int property coerces in non-strict mode, and the result is an honest int. What you do not get is a date. placed_at is declared string because that is what the property has to be for the assignment to succeed.

The third path builds the object in PHP, which is where the types become promises.

<?php
final class Order
{
    private function __construct(
        public readonly int $id,
        public readonly string $reference,
        public readonly string $customer,
        public readonly int $totalCents,
        public readonly string $currency,
        public readonly ?string $note,
        public readonly DateTimeImmutable $placedAt,
    ) {
    }

    public static function fromRow(array $row): self
    {
        return new self(
            id: (int) $row['id'],
            reference: (string) $row['reference'],
            customer: (string) $row['customer'],
            totalCents: (int) $row['total_cents'],
            currency: (string) $row['currency'],
            note: $row['note'] === null ? null : (string) $row['note'],
            placedAt: new DateTimeImmutable((string) $row['placed_at']),
        );
    }

    public function totalInEuros(): string
    {
        return number_format($this->totalCents / 100, 2);
    }
}

$statement = $pdo->prepare(ORDER_SQL);
$statement->execute(['reference' => 'ORD-1002']);
$order = Order::fromRow($statement->fetch());

printf("%s total, placed %s, note %s\n",
    $order->totalInEuros(), $order->placedAt->format('Y-m-d H:i'), var_export($order->note, true));
printf("totalCents is %s (a promise the array could not make)\n", get_debug_type($order->totalCents));
45.00 total, placed 2026-03-01 11:02, note NULL
totalCents is int (a promise the array could not make)

One class, one static method, and the caller gets an Order with a DateTimeImmutable instead of a string it would have to parse at every use site. The name change from total_cents to totalCents is deliberate: the object is now the domain, not the table, and the two are allowed to disagree about spelling.

fetchObject cannot build a class that requires a constructor

The obvious next question is whether you can skip fromRow() and let PDO call the constructor. You cannot, and the failure is worth seeing once because the message points at the wrong place.

<?php
$statement = $pdo->prepare(ORDER_SQL);
$statement->execute(['reference' => 'ORD-1002']);
$statement->fetchObject(Order::class);
Deprecated: Creation of dynamic property Order::$total_cents is deprecated
Deprecated: Creation of dynamic property Order::$placed_at is deprecated
ArgumentCountError: Too few arguments to function Order::__construct(), 0 passed and exactly 7 expected

Take the three lines in order. PDO allocated an Order without calling the constructor, assigned every column it could match, and only then invoked the constructor with no arguments. Two columns had no matching declared property, so PHP created them dynamically and printed a deprecation for each. The constructor then failed because it declares seven promoted properties that must be supplied.

The same mechanism produces a different error the moment a property has a type the driver cannot satisfy:

<?php
final class OrderWithDate
{
    public int $id;
    public string $reference;
    public DateTimeImmutable $placed_at;
}

$statement->setFetchMode(PDO::FETCH_CLASS, OrderWithDate::class);
$statement->fetch();
TypeError: Cannot assign string to property OrderWithDate::$placed_at of type DateTimeImmutable

That is the whole argument against the middle path in one line. FETCH_CLASS assigns raw strings to properties and lets PHP's coercion rules decide the rest. It converts scalars, it refuses objects, and it creates properties that do not exist. Nothing about it can call a parser, so every value that is not a scalar has to be a string property that some other layer converts later.

There is one more behaviour that surprises people, and it decides whether a constructor can validate at all. Without FETCH_PROPS_LATE, properties are assigned before the constructor runs. With it, the constructor runs first.

<?php
final class Late
{
    public array $seen = [];
    public int $id;

    public function __construct()
    {
        $this->seen = ['id was set to ' . var_export($this->id ?? null, true)];
    }
}

$statement->setFetchMode(PDO::FETCH_CLASS, Late::class);
printf("plain:      constructor saw %s\n", json_encode($statement->fetch()->seen));

$statement->setFetchMode(PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, Late::class);
printf("props late: constructor saw %s\n", json_encode($statement->fetch()->seen));
plain:      constructor saw ["id was set to 2"]
props late: constructor saw ["id was set to NULL"]

Careful reading: late properties means the constructor runs early, so a constructor that wants to validate its own fields has nothing to validate. Neither flag order gives you validation. A constructor that checks its arguments only works when PHP supplies the arguments, which is exactly what a factory does.

A static factory is a constructor that understands a row

Order::fromRow() is doing three jobs that a passive record cannot: choosing a representation for note, parsing the timestamp into an immutable object, and casting the numerics so the rest of the code can stop wondering whether it has a string. Keeping it static and the constructor private means the only way to make an Order is from a row or from another factory you write on purpose.

That last point is the one to hold on to. If you also need to build an Order from a form submission, the honest move is a second named constructor rather than a public constructor:

<?php
final class Order
{
    // ...
    public static function fromDraft(string $customer, int $totalCents, string $currency): self
    {
        return new self(
            id: 0,
            reference: 'PENDING',
            customer: $customer,
            totalCents: $totalCents,
            currency: $currency,
            note: null,
            placedAt: new DateTimeImmutable('now'),
        );
    }
}

An Order with id: 0 is not persisted yet, and that is a state the type system cannot express. If the distinction matters to your logic, split it: an OrderDraft that the repository turns into an Order after the insert. Two classes instead of one property that everyone has to remember to check.

A row is not an entity, and the driver is not your type system

A row is a snapshot of columns at one moment. An entity has identity that outlives any particular set of columns, and it is usually assembled from more than one query. The gap between them is where the bugs live.

The nullable column is the smallest version of the problem. note is NULL for three of the five orders, and the array path hands you null wherever it appears. The factory above makes that explicit and keeps null rather than turning it into an empty string, because '' and NULL mean different things in SQL and collapsing them loses the difference.

The larger version is type coercion, and the folklore is out of date. On this stack the driver converts what it can prove:

Column or expressionWhat PHP receivesWhy
INT UNSIGNED, INT int mysqlnd maps the integer protocol type directly.
DECIMAL(10,4) string A decimal does not fit a binary float, so it stays text to keep its precision.
DOUBLE float The protocol type is a float.
SUM(total_cents) over integers string The aggregate's result type is a wider decimal, not the column's type.
NULL null Unaffected unless ATTR_ORACLE_NULLS is changed.
DATETIME string No date type exists in the MySQL protocol; you parse it.

Verify that table on your own driver before trusting it, because the row for integers is not universal and the next section shows why.

<?php
$row = $pdo->query('SELECT id, total_cents, note, placed_at FROM orders WHERE id = 2')->fetch(PDO::FETCH_ASSOC);
foreach ($row as $column => $value) {
    printf("%-12s %s\n", $column, get_debug_type($value));
}

$agg = $pdo->query("SELECT SUM(total_cents) AS cents, AVG(total_cents) AS avg_cents, COUNT(*) AS n
                      FROM orders WHERE currency = 'EUR'")->fetch(PDO::FETCH_ASSOC);
foreach ($agg as $column => $value) {
    printf("%-10s %-8s %s\n", $column, get_debug_type($value), var_export($value, true));
}

$empty = $pdo->query("SELECT SUM(total_cents) AS cents FROM orders WHERE currency = 'USD'")->fetch(PDO::FETCH_ASSOC);
printf("no rows: cents = %s\n", var_export($empty['cents'], true));
id           int
total_cents  int
note         null
placed_at    string
cents      string   '383300'
avg_cents  string   '95825.0000'
n          int      4
no rows: cents = NULL

So id arrives as an int and SUM(id) would arrive as a string. Code that was written against one and then reused for the other breaks in the least helpful way: a strict comparison against an integer quietly returns false instead of failing loudly. The same connection with PDO::ATTR_STRINGIFY_FETCHES set to true turns id and total_cents into strings and leaves null alone, which is the switch to look for when a colleague's machine behaves differently with identical code.

<?php
// Same server, same query, a different driver.
foreach ($mysqli->query('SELECT id, total_cents, note FROM orders WHERE id = 2')->fetch_assoc() as $column => $value) {
    printf("mysqli  %-12s %s\n", $column, get_debug_type($value));
}
foreach ($pdo->query('SELECT id, total_cents, note FROM orders WHERE id = 2')->fetch(PDO::FETCH_ASSOC) as $column => $value) {
    printf("pdo     %-12s %s\n", $column, get_debug_type($value));
}
mysqli  id           string
mysqli  total_cents  string
mysqli  note         null
pdo     id           int
pdo     total_cents  int
pdo     note         null

That is the claim about MySQL returning strings for integer columns, and it is true of mysqli without MYSQLI_OPT_INT_AND_FLOAT_NATIVE, not of pdo_mysql with mysqlnd. A factory that casts explicitly does not care which of them it is talking to, which is the practical reason to write one even when the types look fine today.

Coercion is not validation

Assigning 'abc' to an int property in a non-strict file throws in PHP 8, but assigning '12abc' produces a deprecation and the value 12. A cast in your factory has the same shape of problem. If a column's value matters, check it, do not merely type it.

When a Data Mapper earns its keep, and when it is ceremony

Everything so far fits in one static method. A Data Mapper is the pattern where a separate object owns the translation between the database and the domain, so that the domain class has no knowledge of SQL and the queries have no knowledge of business rules. The version worth writing is small:

<?php
declare(strict_types=1);

final class OrderRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /** @return list<Order> */
    public function recentFor(string $currency, int $limit): array
    {
        $statement = $this->pdo->prepare(
            'SELECT id, reference, customer, total_cents, currency, note, placed_at
               FROM orders
              WHERE currency = :currency
           ORDER BY placed_at DESC
              LIMIT :limit'
        );
        $statement->bindValue('currency', $currency);
        $statement->bindValue('limit', $limit, PDO::PARAM_INT);
        $statement->execute();

        $orders = [];
        foreach ($statement as $row) {
            $orders[] = Order::fromRow($row);
        }

        return $orders;
    }
}
#5 ORD-1005 760.00 total, placed 2026-03-04
#3 ORD-1003 2899.00 total, placed 2026-03-02
#2 ORD-1002 45.00 total, placed 2026-03-01

Two things in that class are the whole point. The LIMIT is bound with PDO::PARAM_INT, because a placeholder in a limit clause has to arrive as a number or the emulated driver quotes it and MySQL rejects the statement. And the return type is list<Order>, so the caller gets objects it can pass on without a docblock explaining which keys exist.

Now the part the pattern's proponents leave out. If your application reads a row, checks two fields and returns JSON, a mapper plus a repository plus an entity is three files doing the work an array did. The ceremony is worth it when at least one of these is true: the same entity is written as well as read and the write path needs the same rules; more than one query builds the same entity, so a single fromRow() removes duplication; the domain has behaviour that belongs on the object rather than in a service; or the table will be split and you want the change confined to one file. It is not worth it because a book says repositories are correct.

The limit of the mapper deserves naming too. A repository that returns hydrated entities cannot express a query whose shape differs from its own, so reporting code ends up with its own SQL somewhere else, and now the table has two owners. The alternatives are a read-model class built for that one report, or accepting that the report queries the database directly and is not pretending to be domain code.

Lab 35 minutes

Add an order_lines table to the schema: an order id, a sku, a quantity and a unit price in cents. Name it order_lines and not lines, which is a reserved word in MySQL and produces a syntax error as a bare table name. Seed two or three lines against each existing order.

Write an OrderWithLines entity that holds the order fields plus a list<OrderLine>, and a repository method that loads one order and its lines. Decide before you write it whether that is one query with a join or two queries, and be ready to say what your answer does when an order has no lines.

Then answer: which of the three hydration paths from this lesson can your new entity use, and what does the answer tell you about the shape of the class?

Show the solution and the reasoning

The join is the tempting answer and it is usually wrong for this shape. One order with three lines comes back as three rows, so the mapper has to group them, and an order with no lines comes back as a single row with nulls in every line column. Both problems disappear with two queries:

<?php
declare(strict_types=1);

final class OrderLine
{
    private function __construct(
        public readonly int $orderId,
        public readonly string $sku,
        public readonly int $quantity,
        public readonly int $unitCents,
    ) {
    }

    public static function fromRow(array $row): self
    {
        return new self(
            orderId: (int) $row['order_id'],
            sku: (string) $row['sku'],
            quantity: (int) $row['quantity'],
            unitCents: (int) $row['unit_cents'],
        );
    }

    public function subtotalCents(): int
    {
        return $this->quantity * $this->unitCents;
    }
}

final class OrderWithLines
{
    /** @param list<OrderLine> $lines */
    private function __construct(
        public readonly Order $order,
        public readonly array $lines,
    ) {
    }

    /** @param list<OrderLine> $lines */
    public static function of(Order $order, array $lines): self
    {
        return new self($order, $lines);
    }

    public function totalCents(): int
    {
        $total = 0;
        foreach ($this->lines as $line) {
            $total += $line->subtotalCents();
        }

        return $total;
    }
}

The repository method runs two statements in a transaction or a repeatable-read snapshot, so the second query sees the order the first one found:

public function findWithLines(string $reference): ?OrderWithLines
{
    $statement = $this->pdo->prepare(
        'SELECT id, reference, customer, total_cents, currency, note, placed_at
           FROM orders WHERE reference = :reference'
    );
    $statement->execute(['reference' => $reference]);
    $row = $statement->fetch();

    if ($row === false) {
        return null;
    }

    $order = Order::fromRow($row);

    $order_lines = $this->pdo->prepare(
        'SELECT order_id, sku, quantity, unit_cents FROM order_lines WHERE order_id = :id ORDER BY id'
    );
    $order_lines->execute(['id' => $order->id]);

    $hydrated = [];
    foreach ($order_lines as $lineRow) {
        $hydrated[] = OrderLine::fromRow($lineRow);
    }

    return OrderWithLines::of($order, $hydrated);
}

Run against a two-line order, a line-less order and a reference that does not exist, that returns:

ORD-1001 -> 2 lines, lines total 12860 cents, order total 12900 cents, 2026-03-01 09:14
ORD-1003 -> 0 lines, lines total 0 cents, order total 289900 cents, 2026-03-02 16:41
ORD-9999 -> null

The empty case is the reason to prefer this. An order with no lines produces an empty $hydrated array, and totalCents() returns zero. The join version needs a null check on every line column, and it is easy to write the loop so that one order with no lines silently disappears from a list of orders.

Which path can carry this entity? Only the factory. FETCH_CLASS assigns one flat set of properties from one row, and OrderWithLines holds an object and a list, so there is no column to assign either one from. That is the tell: the moment an entity is built from more than one row, the passive-object approach stops being an option, and the factory is no longer a preference but a requirement.

One more design note. OrderWithLines::of() is a named constructor that takes already-built parts rather than a row, which keeps the aggregation logic out of the mapper. If you find yourself writing a second factory that takes a row plus an array of line rows, you have rediscovered why the two-query version is the simpler one to test.

Official reference

The PHP manual for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.