Holloway's datamapper architecture provides complete decoupling between your entities and persistence logic. This means you have full freedom in how you design your entities. This guide showcases different approaches to entity design and how to configure mappers to work with each pattern.
- Pattern Comparison
- Magic Accessor Pattern (PHP 8.0-8.3)
- Property Hooks Pattern (PHP 8.4+)
- Array-Based Entities
- Public Properties Pattern
- Getters/Setters Pattern
- Immutable Entities
| Pattern | PHP Version | Best For | Complexity |
|---|---|---|---|
| Magic Accessor Pattern | 8.0+ | Complex apps, value objects, DRY | Medium |
| Property Hooks Pattern | 8.4+ | Modern apps, clean syntax | Low |
| Array-Based Entities | Any | Dynamic schemas, flexibility | Low |
| Public Properties | Any | Simple apps, quick prototypes | Very Low |
| Getters/Setters | Any | Strict encapsulation | High |
| Immutable Entities | 8.1+ | Functional programming, thread-safety | High |
This pattern combines protected properties with magic __get() accessor and a mapperFill() method for hydration. Used in production by a multi-tenant SaaS application, this pattern scales exceptionally well for complex domains.
<?php
namespace App\Entities;
abstract class Entity
{
protected int|string|null $id = null;
/**
* Fill entity properties from an array.
* Used ONLY by mappers during hydration from database.
*/
public function mapperFill(array $properties): self
{
foreach($properties as $propertyName => $propertyValue) {
$this->$propertyName = $propertyValue;
}
return $this;
}
/**
* Convert entity to array for persistence.
*/
public function toArray(): array
{
return get_object_vars($this);
}
/**
* Magic accessor for read-only property access.
*/
public function __get(string $name)
{
if (property_exists($this, $name)) {
return $this->$name;
}
}
/**
* Check if property exists.
*/
public function __isset(string $name): bool
{
return property_exists($this, $name);
}
}<?php
namespace App\Entities;
use App\ValueObjects\Email;
use App\ValueObjects\Address;
use Money\Money;
class Client extends Entity
{
// Protected properties - not directly accessible from outside
protected string $tenant_id;
protected string $first_name;
protected string $last_name;
protected Email $email;
protected ?Address $billing_address = null;
protected Money $total_revenue;
protected Money $outstanding_balance;
protected ClientStatus $status;
/**
* Constructor for CREATING new clients (validation here)
*/
public function __construct(
ClientCompany $company,
string $first_name,
string $last_name,
string $job_title,
Email $email
) {
// Validation for NEW clients
if (empty($first_name)) {
throw new InvalidArgumentException('First name is required');
}
if (empty($last_name)) {
throw new InvalidArgumentException('Last name is required');
}
// Initialize from domain objects
$this->tenant_id = $company->tenant_id;
$this->company_id = $company->id;
$this->first_name = $first_name;
$this->last_name = $last_name;
$this->job_title = $job_title;
$this->email = $email;
// Sensible defaults
$this->total_revenue = Money::USD(0);
$this->outstanding_balance = Money::USD(0);
$this->status = ClientStatus::Active;
}
/**
* Domain methods for business logic
*/
public function changeName(string $first, string $last): void
{
if (empty($first) || empty($last)) {
throw new InvalidArgumentException('Names cannot be empty');
}
$this->first_name = $first;
$this->last_name = $last;
}
public function changeEmail(Email $newEmail): void
{
$this->email = $newEmail;
}
public function updateBillingAddress(Address $address): void
{
$this->billing_address = $address;
}
public function addRevenue(Money $amount): void
{
$this->total_revenue = $this->total_revenue->add($amount);
}
public function addOutstandingBalance(Money $amount): void
{
$this->outstanding_balance = $this->outstanding_balance->add($amount);
}
public function getFullName(): string
{
return "{$this->first_name} {$this->last_name}";
}
public function activate(): void
{
$this->status = ClientStatus::Active;
}
public function deactivate(): void
{
$this->status = ClientStatus::Inactive;
}
}<?php
namespace App\Mappers;
use App\Entities\Client;
class ClientMapper extends Mapper
{
protected string $table = 'clients';
protected string $entityClassName = Client::class;
// Declare type transformations
protected array $mappings = [
'email' => 'email',
'billing_address' => 'address',
'total_revenue' => 'money',
'outstanding_balance' => 'money',
'status' => 'client_status_enum',
];
public function defineRelations(): void
{
$this->belongsTo('company', ClientCompany::class);
$this->hasMany('invoices', ClientInvoice::class);
$this->hasMany('services', Service::class);
}
// That's it! Base mapper handles hydrate/dehydrate automatically
}// Creating a NEW client (uses constructor with validation)
$client = new Client(
company: $company,
first_name: 'John',
last_name: 'Doe',
job_title: 'CEO',
email: new Email('john@example.com')
);
$clientMapper->save($client);
// Loading EXISTING client (bypasses constructor, uses mapperFill)
$client = $clientMapper->find(1);
// Accessing properties via magic __get
echo $client->first_name; // "John"
echo $client->email->value; // "john@example.com"
echo $client->total_revenue; // Money object
// Modifying via domain methods
$client->changeName('Jane', 'Smith');
$client->changeEmail(new Email('jane@example.com'));
$client->addRevenue(Money::USD(10000));
$clientMapper->save($client);This pattern works well with traits for cross-cutting concerns:
// HasTenant.php
trait HasTenant
{
protected string $tenant_id;
public function setTenantId(string $tenantId): void
{
$this->tenant_id = $tenantId;
}
}
// HasTimestamps.php
trait HasTimestamps
{
protected ?Chronos $created_at = null;
protected ?Chronos $updated_at = null;
public function setCreatedAt(Chronos $createdAt): void
{
$this->created_at = $createdAt;
}
public function setUpdatedAt(Chronos $updatedAt): void
{
$this->updated_at = $updatedAt;
}
}
// Usage in entity
class Client extends Entity
{
use HasTenant, HasTimestamps;
// ... rest of entity
}✅ Scales to complex entities
- Define properties once, use everywhere
- No verbose getter/setter methods
- Works seamlessly with value objects
✅ Clean separation of concerns
- Constructor for creation (with validation)
- mapperFill() for hydration (no validation)
- Domain methods for business logic
✅ Type safety
- Protected properties with type declarations
- IDE autocomplete works
- Static analysis tools work
✅ DRY (Don't Repeat Yourself)
- toArray() works automatically
- No manual property mapping in mapper
- Type transformations handled declaratively
✅ Read-only by default
- Properties accessible via __get but not __set
- Forces use of domain methods
- Prevents accidental mutations
✅ Flexible
- Can add custom getters when needed
- Can add validation in domain methods
- Works with traits for cross-cutting concerns
Best for:
- ✅ PHP 8.0-8.3 projects (pre-property hooks)
- ✅ Complex applications with many entities
- ✅ Heavy use of value objects (Email, Money, Address)
- ✅ Want clean separation of creation vs hydration
- ✅ Building reusable, scalable architecture
- ✅ Team prefers less boilerplate
Avoid when:
- ❌ Using PHP 8.4+ (use Property Hooks Pattern instead)
- ❌ Simple CRUD applications
- ❌ Team unfamiliar with magic methods
See also:
- Entity Hydration - How mapperFill() works
- Entity Lifecycle - Creation vs hydration
- Type Transformations - The mappings system
- Value Objects - Email, Money, Address patterns
PHP 8.4 introduces property hooks, eliminating the need for magic methods while providing clean, declarative property behavior. This is the modern recommended approach for new projects on PHP 8.4+.
<?php
namespace App\Entities;
abstract class Entity
{
public int|string|null $id {
get => $this->id;
set => $this->id = $value;
}
/**
* Fill entity properties from an array.
* Used ONLY by mappers during hydration from database.
*/
public function mapperFill(array $properties): self
{
foreach($properties as $propertyName => $propertyValue) {
$this->$propertyName = $propertyValue;
}
return $this;
}
/**
* Convert entity to array for persistence.
*/
public function toArray(): array
{
return get_object_vars($this);
}
}<?php
namespace App\Entities;
use App\ValueObjects\Email;
use App\ValueObjects\Address;
use Money\Money;
class Client extends Entity
{
// Property hooks for validation and transformation
public string $tenant_id {
get => $this->tenant_id;
set {
if (empty($value)) {
throw new \InvalidArgumentException('Tenant ID required');
}
$this->tenant_id = $value;
}
}
public string $first_name {
get => $this->first_name;
set => $this->first_name = trim($value);
}
public string $last_name {
get => $this->last_name;
set => $this->last_name = trim($value);
}
// Value object with automatic conversion
public Email $email {
get => $this->email;
set {
// Set hook can transform primitives to value objects
$this->email = $value instanceof Email ? $value : new Email($value);
}
}
public ?Address $billing_address {
get => $this->billing_address ?? null;
set => $this->billing_address = $value;
}
public Money $total_revenue {
get => $this->total_revenue;
set => $this->total_revenue = $value;
}
public Money $outstanding_balance {
get => $this->outstanding_balance;
set => $this->outstanding_balance = $value;
}
public ClientStatus $status {
get => $this->status;
set => $this->status = $value;
}
// Computed property (get-only hook)
public string $full_name {
get => "{$this->first_name} {$this->last_name}";
}
/**
* Constructor for CREATING new clients (validation here)
*/
public function __construct(
string $tenant_id,
string $first_name,
string $last_name,
Email|string $email
) {
$this->tenant_id = $tenant_id;
$this->first_name = $first_name;
$this->last_name = $last_name;
$this->email = $email; // Set hook converts string to Email if needed
// Sensible defaults
$this->total_revenue = Money::USD(0);
$this->outstanding_balance = Money::USD(0);
$this->status = ClientStatus::Active;
}
/**
* Domain methods for business logic
*/
public function changeName(string $first, string $last): void
{
$this->first_name = $first; // Trimmed via set hook
$this->last_name = $last; // Trimmed via set hook
}
public function changeEmail(Email|string $newEmail): void
{
$this->email = $newEmail; // Converted via set hook
}
public function addRevenue(Money $amount): void
{
$this->total_revenue = $this->total_revenue->add($amount);
}
public function activate(): void
{
$this->status = ClientStatus::Active;
}
public function deactivate(): void
{
$this->status = ClientStatus::Inactive;
}
}<?php
namespace App\Mappers;
use App\Entities\Client;
class ClientMapper extends Mapper
{
protected string $table = 'clients';
protected string $entityClassName = Client::class;
// Declare type transformations
protected array $mappings = [
'email' => 'email',
'billing_address' => 'address',
'total_revenue' => 'money',
'outstanding_balance' => 'money',
'status' => 'client_status_enum',
];
public function defineRelations(): void
{
$this->belongsTo('company', ClientCompany::class);
$this->hasMany('invoices', ClientInvoice::class);
$this->hasMany('services', Service::class);
}
}// Creating a NEW client
$client = new Client(
tenant_id: 'tenant-123',
first_name: ' John ', // Trimmed via set hook
last_name: 'Doe',
email: 'john@example.com' // Converted to Email via set hook
);
$clientMapper->save($client);
// Loading EXISTING client
$client = $clientMapper->find(1);
// Direct property access (hooks execute automatically)
echo $client->first_name; // "John" (trimmed)
echo $client->email->value; // Email object (converted via hook)
echo $client->full_name; // "John Doe" (computed property)
// Modifying (set hooks execute)
$client->first_name = ' Jane '; // Automatically trimmed
$client->email = 'jane@example.com'; // Automatically converted to Email
$clientMapper->save($client);✅ Modern PHP syntax
- No magic methods needed
- Cleaner, more explicit code
- Better static analysis support
✅ Computed properties
get-only hooks for derived values- No separate getter method needed
- Accessed like regular properties
✅ Validation at assignment
- Set hooks validate on write
- Fail fast on invalid data
- No separate validation layer needed
✅ Automatic type coercion
- Convert strings to value objects
- Transform data on assignment
- Cleaner API for consumers
✅ IDE support
- Full autocomplete
- Better refactoring tools
- Type hints work perfectly
✅ Less boilerplate
- No
__get()magic method - No separate accessor methods
- Properties are self-documenting
Best for:
- ✅ PHP 8.4+ projects
- ✅ New projects starting fresh
- ✅ Teams wanting modern PHP features
- ✅ Complex validation requirements
- ✅ Value object transformations
- ✅ Computed properties
Avoid when:
- ❌ Must support PHP < 8.4
- ❌ Team unfamiliar with property hooks
- ❌ Migrating large legacy codebase
See also:
- PHP 8.4 Property Hooks Documentation
- Entity Hydration - How mapperFill() works with hooks
- Type Transformations - The mappings system
- Value Objects - Using with property hooks
Some developers prefer using arrays or array-like structures for maximum flexibility.
class User
{
private array $attributes = [];
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
public function getAttribute(string $key): mixed
{
return $this->attributes[$key] ?? null;
}
public function setAttribute(string $key, mixed $value): void
{
$this->attributes[$key] = $value;
}
public function getAttributes(): array
{
return $this->attributes;
}
public function setAttributes(array $attributes): void
{
$this->attributes = $attributes;
}
// Convenience methods for common attributes
public function getId(): ?int
{
return $this->getAttribute('id');
}
public function getName(): ?string
{
return $this->getAttribute('name');
}
public function setName(string $name): void
{
$this->setAttribute('name', $name);
}
}Mapper Configuration:
class UserMapper extends Mapper
{
protected $table = 'users';
public function hydrate(array $attributes): User
{
return new User($attributes);
}
public function dehydrate($entity): array
{
return $entity->getAttributes();
}
public function getIdentifier($entity): mixed
{
return $entity->getId();
}
public function setIdentifier($entity, $identifier): void
{
$entity->setAttribute('id', $identifier);
}
}class User implements ArrayAccess
{
private array $attributes = [];
public function __construct(array $attributes = [])
{
$this->attributes = $attributes;
}
// ArrayAccess implementation
public function offsetExists($offset): bool
{
return isset($this->attributes[$offset]);
}
public function offsetGet($offset): mixed
{
return $this->attributes[$offset] ?? null;
}
public function offsetSet($offset, $value): void
{
$this->attributes[$offset] = $value;
}
public function offsetUnset($offset): void
{
unset($this->attributes[$offset]);
}
public function toArray(): array
{
return $this->attributes;
}
}Usage:
$user = new User(['name' => 'John', 'email' => 'john@example.com']);
$user['age'] = 30;
echo $user['name']; // JohnMany developers prefer explicit property definitions for better IDE support and type safety.
class User
{
public ?int $id = null;
public string $name;
public string $email;
public bool $active = true;
public DateTime $createdAt;
public function __construct(string $name, string $email)
{
$this->name = $name;
$this->email = $email;
$this->createdAt = new DateTime();
}
}Mapper Configuration:
class UserMapper extends Mapper
{
protected $table = 'users';
public function hydrate(array $attributes): User
{
$user = new User($attributes['name'], $attributes['email']);
$user->id = $attributes['id'] ?? null;
$user->active = $attributes['active'] ?? true;
$user->createdAt = new DateTime($attributes['created_at']);
return $user;
}
public function dehydrate($entity): array
{
return [
'id' => $entity->id,
'name' => $entity->name,
'email' => $entity->email,
'active' => $entity->active,
'created_at' => $entity->createdAt->format('Y-m-d H:i:s'),
];
}
public function getIdentifier($entity): mixed
{
return $entity->id;
}
public function setIdentifier($entity, $identifier): void
{
$entity->id = $identifier;
}
}class User
{
protected ?int $id = null;
protected string $name;
protected string $email;
protected bool $active = true;
protected DateTime $createdAt;
public function __construct(string $name, string $email)
{
$this->name = $name;
$this->email = $email;
$this->createdAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): void
{
$this->id = $id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
$this->email = $email;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): void
{
$this->active = $active;
}
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
}Some prefer immutable entities for better consistency and thread safety.
class User
{
private readonly ?int $id;
private readonly string $name;
private readonly string $email;
private readonly bool $active;
private readonly DateTime $createdAt;
public function __construct(
string $name,
string $email,
?int $id = null,
bool $active = true,
?DateTime $createdAt = null
) {
$this->id = $id;
$this->name = $name;
$this->email = $email;
$this->active = $active;
$this->createdAt = $createdAt ?? new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getEmail(): string
{
return $this->email;
}
public function isActive(): bool
{
return $this->active;
}
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
// Create new instances for changes
public function withName(string $name): self
{
return new self($name, $this->email, $this->id, $this->active, $this->createdAt);
}
public function withEmail(string $email): self
{
return new self($this->name, $email, $this->id, $this->active, $this->createdAt);
}
public function activate(): self
{
return new self($this->name, $this->email, $this->id, true, $this->createdAt);
}
public function deactivate(): self
{
return new self($this->name, $this->email, $this->id, false, $this->createdAt);
}
}Mapper Configuration for Immutable Entities:
class UserMapper extends Mapper
{
protected $table = 'users';
public function hydrate(array $attributes): User
{
return new User(
$attributes['name'],
$attributes['email'],
$attributes['id'] ?? null,
$attributes['active'] ?? true,
isset($attributes['created_at']) ? new DateTime($attributes['created_at']) : null
);
}
public function dehydrate($entity): array
{
return [
'id' => $entity->getId(),
'name' => $entity->getName(),
'email' => $entity->getEmail(),
'active' => $entity->isActive(),
'created_at' => $entity->getCreatedAt()->format('Y-m-d H:i:s'),
];
}
public function getIdentifier($entity): mixed
{
return $entity->getId();
}
public function setIdentifier($entity, $identifier): User
{
// For immutable entities, return new instance
return new User(
$entity->getName(),
$entity->getEmail(),
$identifier,
$entity->isActive(),
$entity->getCreatedAt()
);
}
public function store($entity): User
{
if ($entity->getId() === null) {
// Insert and return new entity with ID
$attributes = $this->dehydrate($entity);
unset($attributes['id']);
$id = $this->insertGetId($attributes);
return $this->setIdentifier($entity, $id);
} else {
// Update existing
$this->updateEntity($entity);
return $entity;
}
}
}Using value objects for better domain modeling.
class Email
{
private string $value;
public function __construct(string $email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email format');
}
$this->value = strtolower($email);
}
public function getValue(): string
{
return $this->value;
}
public function getDomain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function __toString(): string
{
return $this->value;
}
}
class User
{
private ?int $id = null;
private string $name;
private Email $email;
private bool $active = true;
public function __construct(string $name, Email $email)
{
$this->name = $name;
$this->email = $email;
}
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): void
{
$this->id = $id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getEmail(): Email
{
return $this->email;
}
public function setEmail(Email $email): void
{
$this->email = $email;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): void
{
$this->active = $active;
}
}Mapper Configuration with Value Objects:
class UserMapper extends Mapper
{
protected $table = 'users';
public function hydrate(array $attributes): User
{
$user = new User(
$attributes['name'],
new Email($attributes['email'])
);
$user->setId($attributes['id'] ?? null);
$user->setActive($attributes['active'] ?? true);
return $user;
}
public function dehydrate($entity): array
{
return [
'id' => $entity->getId(),
'name' => $entity->getName(),
'email' => $entity->getEmail()->getValue(),
'active' => $entity->isActive(),
];
}
public function getIdentifier($entity): mixed
{
return $entity->getId();
}
public function setIdentifier($entity, $identifier): void
{
$entity->setId($identifier);
}
}Using traits for common entity behavior.
trait HasAttributes
{
private array $attributes = [];
public function getAttribute(string $key): mixed
{
return $this->attributes[$key] ?? null;
}
public function setAttribute(string $key, mixed $value): void
{
$this->attributes[$key] = $value;
}
public function getAttributes(): array
{
return $this->attributes;
}
public function fill(array $attributes): void
{
foreach ($attributes as $key => $value) {
$this->setAttribute($key, $value);
}
}
}
trait HasTimestamps
{
private ?DateTime $createdAt = null;
private ?DateTime $updatedAt = null;
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function setCreatedAt(DateTime $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?DateTime
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTime $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function touch(): void
{
$this->updatedAt = new DateTime();
}
}
class User
{
use HasAttributes, HasTimestamps;
private ?int $id = null;
public function __construct(array $attributes = [])
{
$this->fill($attributes);
$this->createdAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): void
{
$this->id = $id;
}
public function getName(): ?string
{
return $this->getAttribute('name');
}
public function setName(string $name): void
{
$this->setAttribute('name', $name);
}
public function getEmail(): ?string
{
return $this->getAttribute('email');
}
public function setEmail(string $email): void
{
$this->setAttribute('email', $email);
}
}Different entity patterns may require specific mapper configurations:
- Override
hydrate()anddehydrate()methods - Handle attribute access patterns
- Consider mass assignment protection
- Override
setIdentifier()to return new instance - Modify
store()method to handle immutability - Handle relationships carefully
- Implement proper serialization/deserialization
- Handle nested object hydration
- Consider caching implications
- Ensure trait methods are accessible
- Handle multiple inheritance patterns
- Consider method conflicts
Array-Based: Best for maximum flexibility and dynamic schemas Defined Properties: Best for type safety and IDE support Immutable: Best for consistency and functional programming approaches Value Objects: Best for rich domain models with validation Traits: Best for sharing common behavior across entities
The beauty of Holloway's datamapper pattern is that your choice of entity design doesn't constrain your persistence strategy - the mapper handles the translation between your domain objects and the database.
- Mappers - Implementing mappers for different entity patterns
- Relationships - Handling relationships with various entity designs
- Best Practices - Recommended patterns for different use cases