An entity is a typed-ish row container. Each instance carries:
- An attribute bag (
protected array $attributes) — the actual column → value map. - A snapshot (
protected array $attributesOriginal) taken at construction time, useful for dirty tracking.
Reading or writing a column through $entity->column dispatches through __get / __set, which gives subclasses a clean place to hang accessor / mutator hooks.
namespace App\Entity;
use InitPHP\Database\Entity;
final class PostEntity extends Entity
{
}Wire it into a model:
final class Posts extends \InitPHP\Database\Model
{
protected string $entity = \App\Entity\PostEntity::class;
}read() will then hydrate rows into instances of your subclass.
$post = new PostEntity(['id' => 1, 'title' => 'Hello']);
echo $post->title; // 'Hello'
$post->title = 'Edited'; // routed through __set
$post->toArray(); // ['id' => 1, 'title' => 'Edited']
$post->getAttributes(); // same as toArray()
$post->getOriginal(); // ['id' => 1, 'title' => 'Hello'] — snapshot at construct timeisset($post->title) and unset($post->title) work via __isset / __unset.
syncOriginal() refreshes the snapshot — call it after a save() if you want subsequent dirty-tracking to be relative to the just-persisted state.
Define get{Column}Attribute($value) and the entity routes property reads through it:
final class PostEntity extends Entity
{
public function getTitleAttribute(?string $value): string
{
return (string) ($value ?? '(untitled)');
}
}
$post = new PostEntity(['title' => null]);
echo $post->title; // '(untitled)'The method receives whatever currently sits in the attribute bag (or null when nothing has been stored yet) and may return anything.
Define set{Column}Attribute($value) and the entity routes property writes through it. This is where almost every entity bug in the wild comes from, so read this section carefully.
final class PostEntity extends Entity
{
public function setTitleAttribute(string $value): void
{
$this->setAttribute('title', mb_strtolower($value));
}
}
$post = new PostEntity();
$post->title = 'Hello';
echo $post->title; // 'hello'final class PostEntity extends Entity
{
public function setTitleAttribute(string $value): void
{
$this->title = mb_strtolower($value); // ⚠️ does NOT go through __set
}
}Inside a class method, PHP resolves $this->column = $value directly on the object, bypassing __set. That means:
- The transformed value never reaches
$attributes. - PHP creates a dynamic property instead — deprecated since 8.2, fatal in a future PHP release.
Always use $this->setAttribute($name, $value) from inside a mutator.
The column-name → method-name translation is snake_case → PascalCase:
| Column | Accessor | Mutator |
|---|---|---|
title |
getTitleAttribute |
setTitleAttribute |
author_id |
getAuthorIdAttribute |
setAuthorIdAttribute |
post_meta_data |
getPostMetaDataAttribute |
setPostMetaDataAttribute |
Entity::__call() covers the *Attribute family even when you don't define a real method — the fallback simply forwards to the attribute bag.
There are two ways an entity gets populated:
- Direct construction —
new PostEntity(['title' => 'Hello']). The constructor dispatches every key through__set, so mutators run. read()->asClass(...)— PDO'sFETCH_CLASSpopulates the entity for you. PDO writes properties directly on the object, so mutators do NOT run on hydration. If you need transformation on read, do it via aget{Column}Attribute()accessor.
var_dump($post);__debugInfo() returns the attribute bag, so var_dump shows the row data rather than the internal fields.
- 04 — Models for the table side of the pair.
- 03 — CRUD for
save()semantics (insert-or-update from an entity).