stave.dev

Examples

Annotated tour of real entity types from the Zero codebase — from the simplest hand-coded class to a programmatic GenericEntity::define() showcase.

Example 1 — User: The Minimum Viable Entity

The User class is the simplest possible hand-coded entity. It does nothing but declare a uniqueness constraint; the schema is entirely defined by whoever calls the constructor first.

<?php
namespace Zero\Entity;

class User extends Entity {
    public $uniq = ['email'];
}

The first new User([...]) call sets the schema:

new \Zero\Entity\User([
    'email'      => 'ada@example.com',
    'name'       => 'Ada Lovelace',
    'created_at' => date('Y-m-d H:i:s'),
]);
// schema is now: email, name, created_at

From now on, user_view has columns (id, email, name, created_at), and attempting to insert a duplicate email throws.

Example 2 — Event: Constructor Defaults

The Event class overrides the constructor to fill in context that should never be left to the caller. This is the right pattern whenever a default depends on runtime state (current time, request IP, session data).

<?php
namespace Zero\Entity;

class Event extends Entity {
    public function __construct($data) {
        $data['datetime'] ??= date('Y-m-d H:i:s', time());
        $data['ip']       ??= $_SERVER['REMOTE_ADDR'];
        parent::__construct($data);
    }
}

Callers can stay tiny:

new \Zero\Entity\Event(['event' => 'login', 'detail' => 'ok']);
new \Zero\Entity\Event(['event' => 'password_reset', 'detail' => $email]);

Both writes land with correct datetime and ip — you cannot forget, and you cannot fake them from the call site.

Example 3 — Product: Programmatic Definition

A hypothetical Product type created with GenericEntity::define(). This is the pattern you want when the schema is fixed, but you'd rather not hand-author a tiny class file.

<?php
require_once ZERO_ROOT . '/entity/GenericEntity.php';

\Zero\Entity\GenericEntity::define('product', [
    'attributes' => [
        'name',
        'price',
        'sku',
        'status',
        'stock',
        'created_at',
    ],
    'defaults' => [
        'status'     => 'active',
        'stock'      => '0',
        'created_at' => 'date:Y-m-d H:i:s',
    ],
    'uniq' => ['sku'],
]);

Run once — the generator emits entity/Product.php, inserts an entity_type row, partitions the entity table, writes the six schema rows, and creates product_view. From that point on the class is usable either directly or via GenericEntity:

// Create
$p = new \Zero\Entity\Product([
    'name'  => 'Burnt Orange Candle',
    'price' => '22.00',
    'sku'   => 'CND-BRN-01',
]);

// Find
$p = \Zero\Entity\GenericEntity::type('product')->where('sku', 'CND-BRN-01');

// Update
$p->price = '24.00';           // immediate REPLACE INTO
$p->status = 'discontinued';   // another immediate write

// Browse via the pivot view
$db = \Zero\Core\Database::getConnection();
$rows = $db->query("SELECT id, name, price FROM product_view WHERE status = 'active' ORDER BY created_at DESC")->fetchAll();

Example 4 — SessionLog: Defaults Showcase

Every default-value prefix at once, for reference:

\Zero\Entity\GenericEntity::define('session_log', [
    'attributes' => ['token', 'ip', 'user_agent', 'status', 'created_at'],
    'defaults'   => [
        'token'      => 'uniqid:sess_',         // uniqid('sess_')
        'ip'         => 'server:REMOTE_ADDR',   // $_SERVER['REMOTE_ADDR']
        'user_agent' => 'server:HTTP_USER_AGENT',
        'status'     => 'active',               // literal string
        'created_at' => 'date:Y-m-d H:i:s',     // date('Y-m-d H:i:s', time())
    ],
]);

// All defaults fire — only the explicit keys actually need to be passed:
new \Zero\Entity\SessionLog([]);

Example 5 — Defensive where()

Every realistic use of where() looks like this — three branches, no shortcuts:

require_once ZERO_ROOT . '/entity/Entity.php';
require_once ZERO_ROOT . '/entity/User.php';

function findUserByEmail(string $email): ?\Zero\Entity\User {
    $r = (new \Zero\Entity\User())->where('email', $email);

    if ($r === null) {
        return null;
    }
    if (is_array($r)) {
        // Multiple users with the same email: that's a uniq bug.
        // Log and pick the earliest by id so callers still get a usable object.
        error_log("duplicate users for {$email}");
        usort($r, fn($a, $b) => $a->id <=> $b->id);
        return $r[0];
    }
    return $r;
}

Example 6 — Pattern: Fallback to GenericEntity

Generic code (admin UI, importers, fixtures) often doesn't know whether a hand-coded class exists for a given type. The admin module uses this try-class-then-generic pattern:

function loadEntity(string $type, int $id) {
    require_once ZERO_ROOT . '/entity/Entity.php';

    $studly = str_replace(' ', '', ucwords(str_replace('_', ' ', $type)));
    $file   = ZERO_ROOT . "/entity/{$studly}.php";
    $class  = "\\Zero\\Entity\\{$studly}";

    if (file_exists($file)) {
        require_once $file;
        return $class::getInstance()->withId($id);
    }

    require_once ZERO_ROOT . '/entity/GenericEntity.php';
    return \Zero\Entity\GenericEntity::type($type)->withId($id);
}

Further Reading