Storage Model
Two MySQL tables hold every entity type you will ever define. A per-type pivot view gives normal SQL its row-per-record shape back.
The Two Tables
The Entity Framework writes into just two tables, regardless of how many types you define:
entity_type
id (INT)
label (VARCHAR)
One row per type. label is always lowercase — the class's short name folded to lower case (User → user).
entity
type (INT)
id (INT)
attr (INT)
value (VARCHAR(255))
Primary key across all four columns. Partitioned by type — each new type gets its own partition via ALTER TABLE.
{name}_view
id, col_0, col_1, …
Auto-generated CREATE VIEW that pivots the EAV rows using MAX(CASE WHEN attr = N). This is what your SELECT statements should actually hit.
Schema Rows vs. Instance Rows
The entity table overloads id to carry two kinds of data:
| Row Type | Where id is | attr means | value means |
|---|---|---|---|
| Schema | id = 0 |
0-based column index | Column name (email, created_at…) |
| Record | id > 0 |
Column index (matches schema) | The actual value for that attribute |
So a type's schema is literally "the set of rows with id = 0." Adding an attribute is just another insert.
From EAV Rows to a Pivoted View
Suppose you call:
new \Zero\Entity\User(['email' => 'a@x.com', 'name' => 'Ada']); new \Zero\Entity\User(['email' => 'b@x.com', 'name' => 'Bob']);
Behind the scenes, the rows look like this:
| type | id | attr | value |
|---|---|---|---|
| 1 | 0 | 0 | |
| 1 | 0 | 1 | name |
| type | id | attr | value |
|---|---|---|---|
| 1 | 1 | 0 | a@x.com |
| 1 | 1 | 1 | Ada |
| 1 | 2 | 0 | b@x.com |
| 1 | 2 | 1 | Bob |
| id | name | |
|---|---|---|
| 1 | a@x.com | Ada |
| 2 | b@x.com | Bob |
The pivot view is regenerated every time you add an attribute to the schema — which means the normal shape of your table "just works" in SELECT, joins, and aggregates:
SELECT * FROM user_view WHERE email = 'a@x.com'; SELECT COUNT(*) FROM user_view; SELECT u.name, COUNT(e.id) AS event_count FROM user_view u LEFT JOIN event_view e ON e.user_id = u.id GROUP BY u.id;
Rule of thumb: read from {name}_view (joins, pagination, aggregation are easy), write through the Entity class (schema changes and partition management are handled for you).
Partitioning
Each new entity type adds one MySQL partition to the entity table:
ALTER TABLE entity REORGANIZE PARTITION p_rest INTO (PARTITION p_user VALUES IN (1), PARTITION p_rest VALUES IN (MAXVALUE));
Partitioning keeps queries for one type isolated from all the others on disk, which is what makes this scheme fast enough to be practical. MySQL's default cap is 1024 partitions per table, so a codebase that defines hundreds of transient types will hit the ceiling — compact unused types rather than accumulating.
Initialization
Just including entity/Entity.php connects to the database:
require_once ZERO_ROOT . '/entity/Entity.php'; // calls Database::getConnection() and Entity::init(...) require_once ZERO_ROOT . '/entity/User.php';
If you see this error, something referenced the Zero\Entity namespace without first require_once’ing entity/Entity.php. Entity classes are not autoloaded — include them explicitly. See Footguns for the canonical fallback pattern.