puremapper / puremapper
A lightweight PHP data mapper and unit of work for pure PHP entities
Requires
- php: >=8.1
- ext-pdo: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.92
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.0 || ^11.0
- psr/simple-cache: ^3.0
Suggests
- psr/simple-cache: For metadata caching support (PSR-16)
README
PureMapper is a lightweight Data Mapper and Unit of Work library for pure PHP entities.
It is designed for developers who want:
- Pure PHP domain models (no annotations, no attributes)
- No Active Record, no magic methods
- Clear separation between domain and infrastructure
- A small, understandable alternative to heavy ORMs
- Zero external dependencies - only PHP core + PDO
Doctrine ideas, without Doctrine weight.
Table of Contents
- Quick Start
- Philosophy
- Requirements
- Installation
- Database Connection
- Defining Entities
- Mapping with Fluent DSL
- Type Conversion
- Relations
- Query Builder
- SQL Builder
- Unit of Work
- Metadata Caching
- Identity Map
- Repository Interface
- Advanced Usage
- Why PureMapper?
- When NOT to Use PureMapper
- Upgrading
- Roadmap
- License
Quick Start
// 1. Set up database connection (pure PDO) use PureMapper\Query\Connection; use PureMapper\Query\DatabaseDriver; $pdo = new PDO('mysql:host=localhost;dbname=myapp', 'root', ''); $connection = new Connection($pdo, DatabaseDriver::MySQL); // 2. Define a pure entity final class User { public ?int $id = null; public string $name; public string $email; public DateTimeImmutable $createdAt; } // 3. Set up PureMapper use PureMapper\EntityManager; use PureMapper\Mapping\EntityMapper; use PureMapper\Mapping\MetadataRegistry; use PureMapper\Type\TypeRegistry; $typeRegistry = new TypeRegistry(); $registry = new MetadataRegistry(); $registry->register( (new EntityMapper(User::class)) ->table('users') ->id('id') ->field('name', 'string') ->field('email', 'string') ->field('createdAt', 'datetime', column: 'created_at') ->build() ); $em = new EntityManager($connection, $registry, $typeRegistry); // 4. Query entities with relations $user = $em->query(User::class) ->with('posts') ->find(1); // Or use repositories for domain-specific queries class UserRepository implements RepositoryInterface { public function __construct( private EntityManager $em, ) {} public function findActiveWithPosts(): array { return $this->em->query(User::class) ->with('posts') ->where('status', '=', 'active') ->get(); } } // 5. Persist entities $user = new User(); $user->name = 'John'; $user->email = 'john@example.com'; $user->createdAt = new DateTimeImmutable(); $em->persist($user); $em->commit(); // INSERT executed, $user->id populated
Philosophy
PureMapper follows the Data Mapper pattern:
- Entities are plain PHP objects with no persistence awareness
- Mapping is defined externally using a fluent DSL
- Persistence logic lives outside your domain
- Infrastructure can be replaced without touching entities
Domain (Pure PHP Entities)
|
RepositoryInterface
|
EntityManager
|
EntityQuery + UnitOfWork + Hydrator
|
SqlBuilder + Connection (PDO)
|
Database
Requirements
- PHP 8.1+
- PDO extension (
ext-pdo)
PureMapper has zero external dependencies. Only PHP core and PDO are required.
Installation
composer require puremapper/puremapper
Database Connection
PureMapper uses PDO directly with a thin wrapper for database abstraction.
Supported Databases
| Database | Driver Enum | Identifier Quote |
|---|---|---|
| MySQL | DatabaseDriver::MySQL |
Backtick (`) |
| PostgreSQL | DatabaseDriver::PostgreSQL |
Double quote (") |
| SQLite | DatabaseDriver::SQLite |
Double quote (") |
Connection Setup
use PDO; use PureMapper\Query\Connection; use PureMapper\Query\DatabaseDriver; // MySQL $pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'password', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); $connection = new Connection($pdo, DatabaseDriver::MySQL); // PostgreSQL $pdo = new PDO('pgsql:host=localhost;dbname=myapp', 'user', 'password'); $connection = new Connection($pdo, DatabaseDriver::PostgreSQL); // SQLite $pdo = new PDO('sqlite:/path/to/database.db'); $connection = new Connection($pdo, DatabaseDriver::SQLite); // SQLite in-memory (for testing) $pdo = new PDO('sqlite::memory:'); $connection = new Connection($pdo, DatabaseDriver::SQLite);
Connection Methods
| Method | Returns | Description |
|---|---|---|
select(CompiledQuery) |
array |
Execute SELECT, return rows as assoc arrays |
execute(CompiledQuery) |
int |
Execute INSERT/UPDATE/DELETE, return affected rows |
insert(CompiledQuery) |
string |
Execute INSERT, return last insert ID |
table(string) |
SqlBuilder |
Create query builder for table |
beginTransaction() |
void |
Start transaction |
commit() |
void |
Commit transaction |
rollBack() |
void |
Rollback transaction |
getPdo() |
PDO |
Get underlying PDO instance |
statement(string) |
bool |
Execute raw SQL (DDL) |
Defining Entities
Entities are pure PHP classes with no persistence logic, no annotations, and no base class.
final class User { public ?int $id = null; public string $name; public string $email; /** @var Post[] */ public array $posts = []; public ?Profile $profile = null; } final class Post { public ?int $id = null; public string $title; public string $content; public DateTimeImmutable $publishedAt; }
Hydration assigns values directly to public properties. No setters required.
Mapping with Fluent DSL
Mappings are defined externally using a fluent builder API.
use PureMapper\Mapping\EntityMapper; $mapper = (new EntityMapper(User::class)) ->table('users') ->id('id') // Single primary key ->field('name', 'string') ->field('email', 'string') ->field('createdAt', 'datetime', column: 'created_at') ->hasMany('posts', Post::class, foreignKey: 'user_id') ->hasOne('profile', Profile::class, foreignKey: 'user_id'); $metadata = $mapper->build();
Composite Primary Keys
$mapper = (new EntityMapper(TenantUser::class)) ->table('tenant_users') ->id(['tenant_id', 'user_id']) // Composite key ->field('role', 'string');
Column Name Mapping
->field('createdAt', 'datetime', column: 'created_at') ->field('isActive', 'bool', column: 'is_active')
Type Conversion
PureMapper includes built-in type converters and supports custom converters.
Built-in Types
| Type | PHP Type | Database Type |
|---|---|---|
string |
string |
VARCHAR/TEXT |
int |
int |
INTEGER |
float |
float |
DECIMAL/FLOAT |
bool |
bool |
BOOLEAN/TINYINT |
datetime |
DateTimeImmutable |
DATETIME |
date |
DateTimeImmutable |
DATE |
json |
array |
JSON/TEXT |
enum |
BackedEnum |
VARCHAR/INTEGER |
Custom Type Converters
use PureMapper\Type\TypeConverter; final class MoneyConverter implements TypeConverter { public function toPHP(mixed $value): Money { return Money::fromCents((int) $value); } public function toDatabase(mixed $value): int { return $value->cents(); } } // Register custom type $typeRegistry->register('money', new MoneyConverter()); // Use in mapping ->field('price', 'money')
Relations
Supported Relation Types
| Relation | Example |
|---|---|
hasOne |
->hasOne('profile', Profile::class, 'user_id') |
hasMany |
->hasMany('posts', Post::class, 'user_id') |
belongsTo |
->belongsTo('author', User::class, 'author_id') |
manyToMany |
->manyToMany('tags', Tag::class, 'post_tags', 'post_id', 'tag_id') |
Loading Strategy
Relations use eager loading only - no lazy loading or N+1 surprises. Use the Query Builder's with() method to load relations.
Query Builder
PureMapper provides a fluent Query Builder for querying entities with eager-loaded relations.
Basic Queries
// Get all users $users = $em->query(User::class)->get(); // Find by primary key $user = $em->query(User::class)->find(1); // Find with conditions $users = $em->query(User::class) ->where('status', '=', 'active') ->where('created_at', '>', '2024-01-01') ->orderBy('name', 'asc') ->limit(10) ->get(); // Get first matching result $user = $em->query(User::class) ->where('email', '=', 'john@example.com') ->first();
Eager Loading Relations
Use the with() method to eager load relations in a single query batch:
// Load user with posts $user = $em->query(User::class) ->with('posts') ->find(1); // Load multiple relations $users = $em->query(User::class) ->with('posts', 'profile', 'roles') ->where('status', '=', 'active') ->get();
How Eager Loading Works
Relations are loaded using separate queries (not JOINs) to avoid cartesian product issues:
$users = $em->query(User::class) ->with('posts') ->where('status', '=', 'active') ->get(); // Executes: // 1. SELECT * FROM users WHERE status = 'active' // 2. SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...)
SQL Builder
PureMapper includes a minimal SQL Builder for direct database operations.
Basic Usage
// SELECT $query = $connection ->table('users') ->select('id', 'name', 'email') ->where('status', '=', 'active') ->whereIn('role', ['admin', 'editor']) ->orderBy('created_at', 'DESC') ->limit(10) ->toSelect(); $rows = $connection->select($query); // $query->sql = 'SELECT "id", "name", "email" FROM "users" WHERE "status" = ? AND "role" IN (?, ?) ORDER BY "created_at" DESC LIMIT 10' // $query->params = ['active', 'admin', 'editor'] // INSERT $query = $connection ->table('users') ->toInsert(['name' => 'John', 'email' => 'john@example.com']); $lastId = $connection->insert($query); // UPDATE $query = $connection ->table('users') ->where('id', '=', 1) ->toUpdate(['name' => 'Jane']); $affected = $connection->execute($query); // DELETE $query = $connection ->table('users') ->where('id', '=', 1) ->toDelete(); $affected = $connection->execute($query);
SqlBuilder Methods
| Method | Description |
|---|---|
table(string) |
Set table name |
select(string...) |
Set columns to select (default: *) |
where(column, operator, value) |
Add AND WHERE condition |
orWhere(column, operator, value) |
Add OR WHERE condition |
whereIn(column, array) |
Add WHERE IN condition |
whereNull(column) |
Add WHERE column IS NULL condition |
whereNotNull(column) |
Add WHERE column IS NOT NULL condition |
orderBy(column, direction) |
Add ORDER BY clause |
limit(int) |
Set LIMIT |
offset(int) |
Set OFFSET |
join(table, first, operator, second) |
Add INNER JOIN |
leftJoin(table, first, operator, second) |
Add LEFT JOIN |
toSelect() |
Compile SELECT query |
toInsert(array) |
Compile INSERT query |
toUpdate(array) |
Compile UPDATE query |
toDelete() |
Compile DELETE query |
CompiledQuery
All compile methods return a CompiledQuery object:
final readonly class CompiledQuery { public function __construct( public string $sql, // SQL with placeholders public array $params, // Bound parameters ) {} }
Unit of Work
The Unit of Work tracks entity state and coordinates persistence.
// Create new entities $user = new User(); $user->name = 'John'; $em->persist($user); // Modify existing entities (explicit dirty marking) $user->email = 'new@example.com'; $em->markDirty($user); // Remove entities $em->remove($user); // Commit all changes in a transaction $em->commit();
Change Tracking
PureMapper uses explicit change tracking. You must call markDirty() on modified entities:
$user = $em->query(User::class)->find(1); $user->name = 'Updated Name'; $em->markDirty($user); // Required to trigger UPDATE $em->commit();
This design is intentional - no hidden magic, no unexpected queries.
Transaction Control
By default, commit() wraps all operations in a transaction. For manual control:
// Auto transaction (default) $em->commit(); // Manual transaction control $em->getUnitOfWork()->setAutoTransaction(false); $connection->beginTransaction(); try { $em->commit(); $connection->commit(); } catch (Exception $e) { $connection->rollBack(); throw $e; }
Metadata Caching
For production environments, PureMapper supports PSR-16 metadata caching.
use PureMapper\Mapping\MetadataRegistry; use PureMapper\Mapping\CachedMetadataRegistry; // Development - no caching $registry = new MetadataRegistry(); // Production - with PSR-16 cache $cachedRegistry = new CachedMetadataRegistry( $registry, $cache, // PSR-16 CacheInterface prefix: 'puremapper_metadata_', ttl: 3600, ); // Warm cache during deployment $cachedRegistry->warm(); // Invalidate when mappings change $cachedRegistry->invalidate(User::class); $cachedRegistry->invalidateAll();
Identity Map
The Unit of Work maintains an identity map to ensure:
- Same database row always returns the same object instance
- Circular references are handled correctly
- Entity identity is preserved across operations
$user1 = $em->query(User::class)->find(1); $user2 = $em->query(User::class)->find(1); assert($user1 === $user2); // Same instance
The identity map is cleared after commit() or clear().
Repository Interface (OPTIONAL)
PureMapper provides a repository interface. Implementation is yours:
use PureMapper\Repository\RepositoryInterface; final class UserRepository implements RepositoryInterface { public function __construct( private EntityManager $em, ) {} public function find(int|string|array $id): ?User { return $this->em->query(User::class)->find($id); } public function findAll(): array { return $this->em->query(User::class)->get(); } public function findBy(array $criteria): array { $query = $this->em->query(User::class); foreach ($criteria as $field => $value) { $query->where($field, '=', $value); } return $query->get(); } public function findActiveWithPosts(): array { return $this->em->query(User::class) ->with('posts') ->where('status', '=', 'active') ->orderBy('created_at', 'desc') ->get(); } }
Advanced Usage
Raw SQL Queries
For complex queries, use the Connection directly:
// Raw SELECT $rows = $connection->select( new CompiledQuery( 'SELECT u.*, COUNT(p.id) as post_count FROM users u LEFT JOIN posts p ON p.user_id = u.id GROUP BY u.id', [] ) ); // Hydrate results $hydrator = $em->getHydrator(); $users = array_map( fn($row) => $hydrator->hydrate(User::class, $row), $rows ); // DDL statements $connection->statement('CREATE INDEX idx_users_email ON users(email)');
Custom TypeConverter
use PureMapper\Type\TypeConverter; final class UuidConverter implements TypeConverter { public function toPHP(mixed $value): Uuid { return Uuid::fromString((string) $value); } public function toDatabase(mixed $value): string { return $value->toString(); } } $typeRegistry->register('uuid', new UuidConverter()); $mapper = (new EntityMapper(Product::class)) ->table('products') ->id('id', 'uuid') ->field('name', 'string') ->build();
Why PureMapper?
| Feature | PureMapper | Doctrine | Eloquent |
|---|---|---|---|
| Pure entities | Yes | Partial | No |
| No annotations/attributes | Yes | No | No |
| Zero dependencies | Yes | No | No |
| Lightweight | Yes | No | No |
| Explicit mapping | Yes | Partial | No |
| Framework agnostic | Yes | Yes | No |
| Explicit change tracking | Yes | No | No |
| No proxy generation | Yes | No | Yes |
When NOT to Use PureMapper
PureMapper is intentionally minimal. Do not use it if you need:
- Automatic graph synchronization
- Schema migrations (use dedicated migration tools)
- Automatic dirty checking
- Lazy loading
- Complex inheritance mapping
- Query caching
Upgrading
See UPGRADE.md for migration guides between major versions.
Roadmap
Completed
- Metadata Caching (PSR-16 support)
- Pure PDO (removed illuminate/database dependency)
Planned
- Event dispatching (prePersist, postPersist, preUpdate, postUpdate, preRemove, postRemove, postLoad)
- Embedded/Value Objects
- Soft Deletes
License
MIT