Skip to content

Entities

Muhammet Şafak edited this page May 24, 2026 · 1 revision

Entities

An entity is a typed row container. The reference implementation is InitORM\ORM\Entity — values live in an internal $attributes array, with optional per-column accessor and mutator hooks.

The attribute bag

Every column lives in $attributes, an associative array column → value. The magic accessors expose it through property syntax:

$entity = new \InitORM\ORM\Entity(['title' => 'Hello', 'body' => 'World']);

$entity->toArray();        // ['title' => 'Hello', 'body' => 'World']
$entity->getAttributes();  // same
$entity->title;            // 'Hello'                    — via __get
$entity->title = 'New';    // sets via __set
$entity->title;            // 'New'
isset($entity->title);     // true                       — via __isset
unset($entity->title);     // via __unset
isset($entity->title);     // false

Three of the four magic methods route through the attribute bag:

  • __get($name) checks for an accessor method, otherwise returns $attributes[$name] ?? null.
  • __set($name, $value) checks for a mutator method, otherwise sets $attributes[$name] = $value.
  • __isset($name) returns isset($attributes[$name]).
  • __unset($name) removes the entry from $attributes.

Accessors — transforming on read

An accessor is a method named get{Column}Attribute(mixed $value). The column name is the PascalCase form of the snake_case column. $value is the current stored value (or null if the attribute is absent).

class PostEntity extends \InitORM\ORM\Entity
{
    public function getTitleAttribute(mixed $value): mixed
    {
        return is_string($value) ? ucwords($value) : $value;
    }
}

$entity = new PostEntity(['title' => 'hello world']);
$entity->title;                       // 'Hello World'   — transformed by the accessor
$entity->getAttribute('title');       // 'hello world'   — bypasses the accessor

Use accessors for presentation transformations:

  • Capitalising names.
  • Formatting dates / numbers.
  • Deserialising JSON / CSV columns.
  • Computing derived properties from siblings (use $this->getAttribute('other_col') inside the accessor — never $this->other_col, which would invoke that column's accessor recursively if defined).

Mutators — transforming on write

A mutator is a method named set{Column}Attribute(mixed $value). It runs on every property write — including assignments made by PDO during hydration.

class PostEntity extends \InitORM\ORM\Entity
{
    public function setTitleAttribute(mixed $value): void
    {
        $this->setAttribute('title', is_string($value) ? trim($value) : $value);
    }
}

$entity = new PostEntity();
$entity->title = '  hello  ';
$entity->getAttribute('title');   // 'hello'   — mutator stripped whitespace

⚠️ The setAttribute rule

A mutator body MUST write back via $this->setAttribute('col', $value). Plain $this->title = $value from inside a class method bypasses __set and creates a dynamic property on the object — the value never reaches $attributes, and:

  • PHP 8.2+ raises a "Creation of dynamic property" deprecation.
  • A future PHP version (likely 9.0) will make it fatal.
  • Subsequent reads of $entity->title find the dynamic property and return it directly, skipping __get and therefore the accessor.
// ❌ WRONG — creates a dynamic property, never reaches $attributes
public function setTitleAttribute(mixed $value): void
{
    $this->title = trim($value);
}

// ✅ RIGHT — explicit, future-proof, no surprises
public function setTitleAttribute(mixed $value): void
{
    $this->setAttribute('title', trim($value));
}

This is the single biggest pitfall in the entity API. The exception class hierarchy will not save you here — PHP just silently puts the value in the wrong place.

A mutator + accessor pair

The conventional pattern: mutator normalises on write, accessor presents on read.

class UserEntity extends \InitORM\ORM\Entity
{
    public function setEmailAttribute(mixed $value): void
    {
        $this->setAttribute(
            'email',
            is_string($value) ? strtolower(trim($value)) : $value,
        );
    }

    public function getEmailAttribute(mixed $value): mixed
    {
        // Stored lower-cased; presented as-is.
        return $value;
    }
}

$user = new UserEntity();
$user->email = '  Foo@Example.COM ';

$user->email;                        // 'foo@example.com'
$user->getAttribute('email');        // 'foo@example.com'

setAttribute / getAttribute — bypass the hooks

The helper methods read and write $attributes directly, skipping the magic accessors entirely. Use them:

  • Inside a mutator body, to write back without re-entering the mutator.
  • Inside an accessor body, to read a sibling column without re-entering that sibling's accessor.
  • In tests, to assert what was actually stored vs. what the accessor presents.
$entity->setAttribute('email', 'me@example.com');
$entity->getAttribute('email');       // 'me@example.com'   — no accessor involved

Dirty tracking baseline

Each entity captures the construction-time attribute bag as the "original" snapshot. Mutations after construction do not change it.

$entity = new \InitORM\ORM\Entity(['title' => 'Hello']);
$entity->title = 'Edited';

$entity->getOriginal();    // ['title' => 'Hello']
$entity->getAttributes();  // ['title' => 'Edited']

Call syncOriginal() to overwrite the snapshot with the current values — for example, after persisting via Model::save():

$model = new \App\Model\Posts();
$model->save($entity);
$entity->syncOriginal();   // entity is "clean" again

The package intentionally does not ship an isDirty() / getChanges() helper — diffing the two arrays in user code lets each project pick the semantics it needs:

function isDirty(\InitORM\ORM\Entity $e): bool
{
    return $e->getAttributes() !== $e->getOriginal();
}

function getDirtyColumns(\InitORM\ORM\Entity $e): array
{
    return array_diff_assoc($e->getAttributes(), $e->getOriginal());
}

The fallback __call

If a subclass does not declare a get{Column}Attribute or set{Column}Attribute method, calling it on the entity still works — Entity::__call provides a default implementation that routes directly to the attribute bag:

$entity = new \InitORM\ORM\Entity();

$entity->setPostTitleAttribute('Hello');   // sets $attributes['post_title']
$entity->getPostTitleAttribute();          // returns 'Hello'

This makes $entity->post_title and $entity->setPostTitleAttribute('Hello') round-trip identically when no custom transform is needed.

__call raises EntityException if the method name does not match the get{*}Attribute / set{*}Attribute pattern:

try {
    $entity->doSomething();
} catch (\InitORM\ORM\Exceptions\EntityException $e) {
    // Unknown entity method "doSomething".
}

Hydration from PDO

When a model read()s, the underlying DataMapper uses PDO::FETCH_CLASS to instantiate the entity. PDO:

  1. Creates the entity object (without calling __construct yet).
  2. Sets each column as a property. The properties are not declared, and __set is defined, so __set fires for every column — running mutators if any.
  3. Calls __construct() with no arguments. The default ?array $data = [] parameter is [], so fill([]) is a no-op; syncOriginal() then captures the PDO-set values as the original snapshot.

This order matters: setting properties before the constructor means that mutators run on raw fetched values, and syncOriginal() captures the post-mutator state.

Practical recipes

JSON column

class SettingEntity extends \InitORM\ORM\Entity
{
    public function getValueAttribute(mixed $value): mixed
    {
        return is_string($value) ? json_decode($value, true) : $value;
    }

    public function setValueAttribute(mixed $value): void
    {
        $this->setAttribute('value', is_string($value) ? $value : json_encode($value));
    }
}

$setting = new SettingEntity();
$setting->value = ['theme' => 'dark', 'lang' => 'en'];
$setting->getAttribute('value');     // '{"theme":"dark","lang":"en"}'   — stored as string
$setting->value;                     // ['theme' => 'dark', 'lang' => 'en']  — accessor decodes

Computed read-only column

class UserEntity extends \InitORM\ORM\Entity
{
    public function getFullNameAttribute(mixed $value): mixed
    {
        // $value is null — full_name is not actually stored
        return trim(($this->getAttribute('first_name') ?? '') . ' ' . ($this->getAttribute('last_name') ?? ''));
    }
}

$user = new UserEntity(['first_name' => 'Ada', 'last_name' => 'Lovelace']);
$user->full_name;   // 'Ada Lovelace'

(Note: full_name is not in $attributes, so toArray() won't include it. If you want the computed value to round-trip through toArray(), copy it into the bag via setAttribute in the constructor, or override toArray() to merge it in.)

Boolean flag

class PostEntity extends \InitORM\ORM\Entity
{
    public function getIsPublishedAttribute(mixed $value): bool
    {
        return (bool) $value;
    }

    public function setIsPublishedAttribute(mixed $value): void
    {
        $this->setAttribute('is_published', $value ? 1 : 0);
    }
}

Read also

Clone this wiki locally