DataMapper
PdoDataMapper maps database rows to typed PHP entities while keeping persistence methods out of those entities. It is a useful fit for domain-oriented code, services that inject persistence boundaries, and applications where the same entity should not depend on a particular storage workflow.
Use Active Record when the convenience of calling save() and delete() on records is more important than persistence independence.
How the mapping works
The mapper uses PHP attributes and reflection to build a property-to-column map:
Queries and ordering use entity property names. Hydration arrays use database column names. Returned entities expose their typed PHP properties.
Defining an entity
An entity must:
- extend
SerializableEntity; - declare one
#[Entity]attribute with a non-empty table name; - declare every mapped property as public and non-static;
- add exactly one
#[Property]mapping to every reflected property; - define a mapped property named
id, even when its database column has another name.
<?php
declare(strict_types=1);
namespace App\Entity;
use Qubus\Expressive\DataMapper\Entity;
use Qubus\Expressive\DataMapper\Property;
use Qubus\Expressive\DataMapper\SerializableEntity;
#[Entity('users')]
final class User extends SerializableEntity
{
#[Property('user_id')]
public int|string $id;
#[Property('username')]
public string $login;
#[Property('first_name')]
public string $firstName;
#[Property('last_name')]
public string $lastName;
#[Property('email')]
public string $email;
}
The PHP type of each property must accept the value returned by the selected PDO driver. IDs are commonly declared int|string because an application-generated UUID or ULID is a string and PDO's lastInsertId() also returns a string.
Mapper construction validates this metadata early. Invalid entity classes, missing attributes, non-public or static mapped properties, an empty table, and a missing id property raise DataMapperException.
Creating a mapper
Create a connection, then bind one mapper instance to one entity class:
<?php
use App\Entity\User;
use Qubus\Expressive\Connection\DriverConnection;
use Qubus\Expressive\DataMapper\PdoDataMapper;
$connection = DriverConnection::make(
'mysql://app_user:password@localhost:3306/app?charset=utf8mb4'
);
$users = new PdoDataMapper($connection, User::class);
The mapper keeps its connection in the public readonly $connection property. Different mapper instances may safely use different connections.
Reading entities
Find one by ID
findOne() uses the database column mapped from the entity's id property. It returns an entity or null:
The ID is bound as a PDO parameter.
Find a page of entities
findAll() returns an array keyed by entity ID:
$page = $users->findAll(
orderBy: 'login',
options: [
'direction' => 'ASC',
'limit' => 25,
'offset' => 0,
],
);
foreach ($page as $id => $user) {
echo $id . ': ' . $user->login;
}
Defaults are significant:
orderBydefaults to the entity propertyid;directiondefaults toASC;limitdefaults to10;offsetdefaults to0.
limit: 0 returns no rows. Limit and offset must be non-negative integers. Direction is case-insensitive but must be ASC or DESC.
Because results are keyed by ID, duplicate IDs overwrite earlier entries during hydration. A correctly keyed table should not produce duplicates.
Find entities by one property
findAllBy() supports an equality filter on one mapped entity property:
$matches = $users->findAllBy(
column: 'login',
value: 'person',
orderBy: 'lastName',
options: [
'direction' => 'DESC',
'limit' => 10,
'offset' => 0,
],
);
column and orderBy are PHP property names (login, lastName), not database names (username, last_name). The filter value is bound. Unknown properties raise DataMapperException, which also prevents arbitrary input from being treated as a SQL identifier.
findAllBy() supports equality only. Use the QueryBuilder escape hatch for multiple predicates, ranges, LIKE, joins, or other query shapes.
Hydrating existing rows
hydrate() converts an in-memory list of associative rows to entities. Input keys are database column names:
$entities = $users->hydrate([
[
'user_id' => '01K6TYX0XPE1KVWHC1QNA1NYMP',
'username' => 'person',
'first_name' => 'Pat',
'last_name' => 'Developer',
'email' => 'person@example.com',
],
]);
$user = $entities['01K6TYX0XPE1KVWHC1QNA1NYMP'];
Every mapped database column must be present in every row, including nullable columns. Use a null value for a nullable property rather than omitting its key. A missing column raises DataMapperException instead of returning a partially initialized entity.
Like database reads, hydrate() returns an array keyed by the mapped ID value.
Creating entities
Application-generated IDs
Initialize every mapped property, then pass the entity to create():
$user = new User();
$user->id = '01KDATAMAPPER00000000000001';
$user->login = 'person';
$user->firstName = 'Pat';
$user->lastName = 'Developer';
$user->email = 'person@example.com';
$created = $users->create($user);
An initialized id is included in the insert and preserved. create() returns the same entity instance; it does not clone it.
Auto-incrementing IDs
Leave the typed id property uninitialized when the database generates it:
$user = new User();
$user->login = 'person';
$user->firstName = 'Pat';
$user->lastName = 'Developer';
$user->email = 'person@example.com';
$users->create($user);
echo $user->id; // Assigned from PDO::lastInsertId().
Only an uninitialized id is omitted. Every other mapped property must be initialized before insertion, even when the database column has a default. A property initialized to null is included and must have a nullable PHP type.
Pass an instance of the entity class used to construct the mapper. The method accepts the common SerializableEntity base type, but its mapping metadata belongs to the configured class.
Updating entities
Change public properties and pass the entity to update():
$user = $users->findOne($id);
if ($user !== null) {
$user->email = 'new-address@example.com';
$updated = $users->update($user);
}
The mapper:
- writes every mapped property except
id; - binds
idin theWHEREclause; - requires every mapped property, including
id, to be initialized; - returns the same entity instance.
There is no dirty tracking, optimistic-lock column, lifecycle callback, or affected-row assertion. If those rules are part of the domain, enforce them in a repository or service around the mapper.
Deleting entities
Delete by mapped ID:
delete() binds the ID and returns void. It does not mutate the entity or report whether a row existed.
Transactions
The mapper does not start transactions around individual writes. Group related work with the connection's transactional() method:
$connection->transactional(function () use ($users, $user): void {
$users->create($user);
// Additional mapper or QueryBuilder writes participate in the same connection transaction.
});
Any Throwable escaping the callback triggers a rollback and is rethrown. See Connections for nested transaction behavior.
QueryBuilder escape hatch
queryBuilder() returns a builder already pointed at the entity table:
$rows = $users->queryBuilder()
->select(['user_id', 'username', 'email'])
->whereLike('username', 'pat%')
->orderBy('username', 'ASC')
->find();
Use database column names with this builder because it bypasses Data Mapper metadata. Its rows are QueryBuilder row objects, not mapped entities. If a custom query selects all mapped columns, convert its associative rows with hydrate() when entity objects are needed.
getPdo() is also available for PDO-specific operations:
Prefer mapper methods, QueryBuilder, or parameterized PDO statements over interpolating values into SQL.
Entity serialization
SerializableEntity implements Stringable. Casting an entity to a string JSON-encodes its public properties and throws JsonException if encoding fails:
This is deliberately small: the base entity does not implement JsonSerializable, hide properties, rename fields, or recursively load relationships. Add application-specific serialization in a DTO, presenter, or entity subclass when a public API needs a stable representation.
Errors and validation boundaries
DataMapperException is used for mapper-specific validation, including:
- invalid entity or property metadata;
- an unknown property used as a filter or sort identifier;
- invalid sort direction, limit, or offset;
- a missing column during hydration;
- an uninitialized property required for a write.
PDO statement and constraint failures are not translated into domain exceptions. Catch them at the persistence boundary if the application needs to turn them into validation, conflict, or retry behavior.
The mapper assigns database values directly to typed properties. Type mismatches can therefore raise TypeError. Choose property types that represent both the domain value and the values produced by the PDO driver, or normalize data in a repository layer.
Data Mapper and Active Record compared
| Concern | Data Mapper | Active Record |
|---|---|---|
| Persistence methods | Separate mapper | On models and rows |
| Entity/record properties | Declared, public, typed | Dynamic attributes in an internal array |
| Mapping | PHP attributes per property | Table and key properties on the model |
| Query result | Entity or ID-keyed entity array | Row or iterable Result |
| Relationships | Implement in repositories/services | Built-in relation objects and eager loading |
| Multiple connections | One connection per mapper | One shared model connection |
| Best fit | Domain and service boundaries | Direct CRUD and record-oriented code |
Neither abstraction replaces database constraints, transactions, authorization, or validation. Treat those as explicit application and schema responsibilities.