Creating Entity Types
Three supported paths to a new entity type. Pick by how stable the type is and how much code you want to write.
Long-lived, core type — hand-code it. Programmatic or admin-defined — GenericEntity::define(). One-off, no code — the admin UI.
1. Hand-Coded Subclass
Dead simple and what you want for anything core — User, Event, Product. The class is a thin marker: its lowercased short name becomes the entity_type.label. That's the whole binding.
<?php
namespace Zero\Entity;
class User extends Entity {
public $uniq = ['email'];
}
If you need defaults or conditional values, override the constructor — this is the canonical pattern used by Event:
<?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);
}
}
Use it:
require_once ZERO_ROOT . '/entity/Entity.php'; // also inits $pdo require_once ZERO_ROOT . '/entity/Event.php'; new \Zero\Entity\Event(['event' => 'login', 'detail' => 'ok']);
2. GenericEntity::define()
Programmatic type creation. One call defines the type, seeds the schema, and writes a class file into entity/:
require_once ZERO_ROOT . '/entity/GenericEntity.php';
GenericEntity::define('product', [
'attributes' => ['name', 'price', 'sku', 'status', 'created_at'],
'defaults' => [
'status' => 'active',
'created_at' => 'date:Y-m-d H:i:s',
],
'uniq' => ['sku'],
]);
// Now create records
$p = new GenericEntity('product', [
'name' => 'Widget',
'price' => '29.99',
'sku' => 'WDG-1',
]);
The first argument is snake_case and becomes both the lowercase label and (studly-cased) the generated class name — product → Zero\Entity\Product. The class file is written to entity/Product.php.
The generator won't stomp an existing class file unless you pass force: true. Add methods, override the constructor, and evolve it over time — treat the first generated version as a starting point, not a contract.
After defining, you can use either the class directly or the generic access pattern:
// Direct class (works because the generator wrote Product.php)
$p = new \Zero\Entity\Product(['name' => 'Gadget', 'sku' => 'GDG-1']);
// Or via GenericEntity
$p = new GenericEntity('product', ['name' => 'Gadget', 'sku' => 'GDG-1']);
$found = GenericEntity::type('product')->where('sku', 'GDG-1');
3. Admin UI
/admin/create-entity (see modules/Admin/Admin.php) wraps GenericEntity::define() with a form. Nothing you can do in the UI that you can't do with define() — but the UI is convenient for types you don't want to check into the codebase.
Side-by-Side Comparison
| Path | When to use | Stored where | Real example |
|---|---|---|---|
| Hand-coded subclass | Long-lived, well-understood types | Checked into entity/ by you |
User.php, Event.php |
GenericEntity::define() |
Code-defined but not core; dev-time fixtures | Generated into entity/ at first call |
Testcase.php, Anotherentity.php |
| Admin UI | One-off, admin-defined, no redeploy | Generated into entity/ |
Anything users create at runtime |
Anti-Pattern: Runtime Class Synthesis
modules/Ttrpg/CampaignEntity.php synthesizes Entity subclasses at runtime via PHP's code-evaluation construct, setting public $dynamicEntityName = 'foo' so that the base class uses that value instead of the PHP class name. It works — and it is the right call for genuinely per-tenant dynamic tables (e.g., campaign_1_npcs, campaign_2_npcs…) — but:
- Runtime-synthesized classes are invisible to grep, debuggers, and IDE tooling
- There is no generated file to hand-edit later
- Redeclaring the class in the same request throws, so you need a
class_exists()guard
Only copy the runtime-synthesis pattern if you genuinely need per-tenant or per-campaign tables and cannot enumerate them at code time. For everything else, GenericEntity::define().