stave.dev

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.

Which One Do I Want?

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 — productZero\Entity\Product. The class file is written to entity/Product.php.

The Generated File Is Yours

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:

Prefer define() When You Can

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().