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 expression | What PHP receives | Why |
|---|---|---|
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.
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.