Database
A schema-first layer over MySQLi: you describe each table once as a model, the build generates the typed classes around it, and a migration keeps the database in step. This page is the map — each piece has its own guide.
How it fits together
One annotated class is the source of truth. Everything else is derived from it — the first feature walkthrough takes one from a model to a working route in five steps:
ProductModel the model you write — fields, relations, rules
↓ ./framework build
ProductSchema the base class you extend
ProductQuery a typed query builder
ProductEntity a typed row
ProductColumn an enum of the columns
ProductRequest the typed route request
ProductStatus the status enum
↓ ./framework migrate
products the table, created and updated to match
So a change to a column is a change to one class, followed by a build and a migrate. Nothing repeats the column name, which is why a rename becomes a compile error instead of a silent failure at runtime.
The guides
| Guide | Covers |
|---|---|
| Models | Declaring a table — the #[Model] and #[Field] attributes, status states, relations, counts, expressions, sub-requests, validation and requests. |
| Generated Schema | The six generated classes and how to read and write with them. |
| Query builder | Building any statement by hand, for the data that has no model. |
| Migrations | Keeping the database in step, and the data migrations that run alongside. |
The model
A model is a class of typed properties, each described by an attribute. Beyond its own columns it can join other tables, count related rows, compute values in SQL and declare its validation:
#[Model(hasTimestamps: true, canCreate: true, canEdit: true)]
class ProductModel {
#[Field(isID: true)]
#[Requested(isID: true)]
public int $productID;
#[Field(isUnique: true, length: 100)]
#[Validate(isRequired: true, maxLength: 100)]
#[Requested]
public string $name;
#[Field(belongsTo: "Category")]
public int $categoryID;
#[Relation(fieldNames: [ "name" ])]
public CategoryModel $category;
}
The same declarations drive the table, the generated classes and the migration.
Reading
Your class extends the generated schema, and queries are built from typed properties rather than column strings:
class Product extends ProductSchema {
public static function getForCategory(int $categoryID): array {
$query = new ProductQuery();
$query->categoryID->equal($categoryID);
$query->status->equal(ProductStatus::Active);
$query->name->orderByAsc();
return self::getEntityList($query);
}
}
Rows come back as entities with a typed property per field — including the ones the relations, counts and sub-requests added:
$product = Product::getByID($productID);
$product->name; // string
$product->price; // float
$product->status; // ProductStatus
$product->categoryName; // brought by the #[Relation]
Writing
The generated create and edit take one typed parameter per field, so a
column that does not exist is a build error. Passing a request fills the
fields the model marked #[Requested] and leaves the rest untouched:
class Product extends ProductSchema {
public static function create(ProductRequest $request): int {
return self::createEntity($request, status: ProductStatus::Draft);
}
public static function edit(int $productID, ProductRequest $request): bool {
return self::editEntity($productID, $request);
}
public static function addView(int $productID): bool {
return self::editEntity($productID, views: Assign::increase());
}
}
Queries without a model
For a report, a one-off statement or a table no model owns, the general Query builder builds any statement, always binding its values:
$rows = Query::select("orders")
->columns("categoryID", "COUNT(*) AS total", "SUM(amount) AS amount")
->where("createdTime", ">", $fromTime)
->groupBy("categoryID")
->orderBy("amount", false)
->getAll();
Queries compose — one can be the source, the join or the condition of another — and the generated queries mix
with the generic ones, since both are QueryLike.
Migrating
The models are compared against the database and the tables are created or updated to match. Structural changes run first, then the framework and app discovery migrations, then the dated data migrations:
./framework migrate # apply everything pending
./framework migrate --canDelete # also drop what the models no longer declare
Renames are declared in a config file so a column keeps its data instead of being dropped and recreated.
The module
| Class | Is |
|---|---|
Database | The MySQLi wrapper — raw statements and the structural operations. |
Schema | The base every generated schema extends. |
Query | The general query builder. |
Assign | Values the database computes — counters, expressions, functions. |
SchemaFactory | Builds the schemas from the discovered models. |
SchemaModel | A model's definition — its table, fields and relations. |
Migration / SchemaMigration | Run the migrations. |