Framework
Database

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

GuideCovers
ModelsDeclaring a table — the #[Model] and #[Field] attributes, status states, relations, counts, expressions, sub-requests, validation and requests.
Generated SchemaThe six generated classes and how to read and write with them.
Query builderBuilding any statement by hand, for the data that has no model.
MigrationsKeeping 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

ClassIs
DatabaseThe MySQLi wrapper — raw statements and the structural operations.
SchemaThe base every generated schema extends.
QueryThe general query builder.
AssignValues the database computes — counters, expressions, functions.
SchemaFactoryBuilds the schemas from the discovered models.
SchemaModelA model's definition — its table, fields and relations.
Migration / SchemaMigrationRun the migrations.