Footguns
Battle-tested list of the sharp edges — things that look like they work, but don't, and the specific failure mode when they bite.
None of these are fatal — each has a straightforward workaround. But they are the places where new code tends to go wrong, so it pays to recognize them on sight.
1. where() Has a Variable Return Type
where() returns null, one Entity, or an array of Entity objects depending on how many matches it found. There is no "always an array" mode. Code that forgets to branch will fail silently (treating the single object as a 1-element collection, or indexing into a null).
$r = $model->where('email', $email);
if ($r === null) { /* not found */ }
elseif (is_array($r)) { /* multiple matches — usually a uniq bug */ }
else { /* single Entity */ }
2. VARCHAR(255) Truncation, Silently
Every attribute value is stored as VARCHAR(255). There are no type checks, no length checks, and no warnings. Values longer than 255 characters are silently truncated on write.
json_encode output of any non-trivial object will bust this limit. So will user-written prose, URLs with query strings, base64-encoded anything, and most log messages.
If you need fields longer than 255 chars, skip the EAV framework for that type. entity/ThingiverseQueue.php is a sibling class that chose a dedicated table instead — follow that precedent, or compress before writing.
3. Entity Classes Are Not Autoloaded
Composer / PSR-4 does not cover the Zero\Entity\* namespace. Every entity class must be require_once'd explicitly, or the call site will fatal with "class not found."
The canonical "try the generated class, fall back to GenericEntity" pattern (from modules/Admin/Admin.php):
$className = '\\Zero\\Entity\\' . studly($typeName);
$file = ZERO_ROOT . '/entity/' . studly($typeName) . '.php';
if (file_exists($file)) {
require_once $file;
$entity = new $className($data);
} else {
require_once ZERO_ROOT . '/entity/GenericEntity.php';
$entity = new GenericEntity($typeName, $data);
}
4. define(force: true) Does Not Re-Seed the Schema
Calling GenericEntity::define($name, $config, force: true) regenerates the class file, but does not rewrite the id = 0 schema rows. If your original schema is wrong — wrong columns, wrong order — regenerating the class is not enough.
To truly redefine a type:
DELETE FROM entity WHERE type = (SELECT id FROM entity_type WHERE label = 'product') AND id = 0;
Then call define(..., force: true). The next insert will rebuild the schema from scratch.
5. The Partition Limit
Every new entity type adds one MySQL partition to the entity table. MySQL's default cap is 1024 partitions per table. A codebase that defines hundreds of transient types — especially test fixtures — can hit the ceiling and fail the next ALTER TABLE.
Keep an eye on SELECT COUNT(*) FROM entity_type and compact types you no longer use.
6. Strict Identifier Validation in updateView()
Attribute names and entity-type labels must match /^[a-z_][a-z0-9_]*$/i or updateView() throws Exception("Unsafe SQL identifier: ...").
GenericEntity::validateDefinition() enforces the same rule at define time, so the normal paths are fine. But if you build the runtime class-synthesis pattern and construct entity-type labels from user input, you must validate yourself before new $className([...]) — otherwise a malformed label surfaces as an opaque SQL-identifier error mid-request.
Debugging Playbook
| Symptom | Likely Cause | Fix |
|---|---|---|
| "You must call 'init' first." | Used Zero\Entity\Foo without including Entity.php |
require_once ZERO_ROOT . '/entity/Entity.php'; |
| "Undefined entity type: X" | Called GenericEntity::type('X') with no prior define() in this request |
$typeConfigs is request-scoped — re-call define() or require the generated class. |
| "Duplicate entry found for X with id N" | uniq caught a collision |
Correct the input, or loosen $uniq. |
| "Unsafe SQL identifier: X" | Attribute name or entity label has a character outside [a-z0-9_] |
Rename the attribute; re-seed the id=0 schema row in SQL if the bad name was already written. |
| Record saves but attribute missing from the view | updateView() failed silently (permissions, view DEFINER) |
$entity->forceUpdateView() or DROP VIEW/re-run. |
| New partition fails | Type label collision, or the 1024-partition cap | Check entity_type for a stale row; compact types you no longer use. |
Reset (Dev Only)
mysql stavedev < /var/www/html/stave.dev/zero/entity/reset_entity.sql
Truncates both tables, resets partitioning to a single partition, drops all views. Destructive. Do not run in production.