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);
}