Querying Entities
Three APIs, from narrowest to broadest: load by id, find by attribute, or drop straight to SQL against the pivoted view.
Load by ID
$u = \Zero\Entity\User::getInstance()->withId(42); echo $u->email;
getInstance() returns a lightweight placeholder; withId() populates it from the database in a single query against the type's partition. If no row exists for that id, you get null.
Find by Attribute — where()
The primary lookup method on any entity class:
// Hand-coded class
$user = (new \Zero\Entity\User())->where('email', 'ada@example.com');
// GenericEntity (requires a prior define())
$prod = \Zero\Entity\GenericEntity::type('product')->where('sku', 'WDG-1');
where() returns null, a single Entity, or an array of Entity objects — depending on how many rows matched. This is the one behavioural quirk of the API. Always check the shape before using the result.
The canonical branching pattern:
$r = $model->where('email', $email);
if ($r === null) {
// No match
} elseif (is_array($r)) {
// Multiple matches — usually a bug for fields in $uniq
foreach ($r as $entity) { /* ... */ }
} else {
// Exactly one match — $r is an Entity
echo $r->email;
}
Richer Queries — Hit the View
For anything beyond a single-attribute lookup — ranges, ordering, joins, pagination, aggregation — query the pivoted view directly. The view is recreated every time a new attribute is added, so it's always in sync with the schema.
SELECT * FROM product_view WHERE price > 10 ORDER BY created_at DESC LIMIT 50;
Pattern used across the codebase for direct SQL:
$db = \Zero\Core\Database::getConnection();
$stmt = $db->prepare(
"SELECT * FROM product_view WHERE status = ? LIMIT ?"
);
$stmt->execute(['active', 50]);
$rows = $stmt->fetchAll();
modules/Admin/model/Entity.php is the reference implementation — it reads the EAV tables directly for the admin UI's list/filter/paginate screen. Follow that pattern for anything non-trivial.
Writes and Updates
The class API is intentionally thin. Property assignment is an immediate REPLACE INTO:
$p = (new \Zero\Entity\Product())->withId(7); $p->price = '19.99'; // __set writes a REPLACE INTO immediately $p->status = 'discontinued'; // each assignment is its own SQL statement
There is no save(), no dirty tracking, no transaction. Every assignment is a round trip. That is fine for admin screens and CRUD forms — it is not fine for a loop over 10,000 rows. Drop to raw SQL for bulk work.
Schema-on-Write
Assigning an attribute that does not yet exist in the schema will create it:
$p = (new \Zero\Entity\Product())->withId(7); $p->gift_wrap = 'yes'; // attr 'gift_wrap' is not in the schema // Framework: // 1. INSERT a new schema row (id=0, attr=N, value='gift_wrap') // 2. Rebuild product_view with the new column // 3. REPLACE INTO for the instance's new attribute
Schema-on-write is great when you're experimenting. It is dangerous in production — a typo in an attribute name becomes a permanent column, and the view rebuild is a side effect of an otherwise innocuous-looking assignment. Consider freezing the schema with explicit validation around user-supplied keys.
Unique Constraints
Declare uniqueness on the class:
class User extends Entity {
public $uniq = ['email'];
}
Or via the uniq key of a define() config. Enforcement happens inside __set with a SELECT before the REPLACE — it is not a database index, so races are possible under concurrent writes. For truly critical uniqueness, add a partial unique index in SQL as well.