Framework
Database

Models

A model is one annotated class describing a table — its fields, relations and rules. The build reads it and generates the schema, query, entity and column classes you actually use, so the table is described once and everything else follows.

Declaring a model

A model lives in a Model folder, is named <Name>Model and carries the Model attribute. Each property is a column, described by Field:

// src/Product/Model/ProductModel.php
use Framework\Database\Model\Model;
use Framework\Database\Model\Field;

#[Model(hasTimestamps: true, canCreate: true, canEdit: true)]
class ProductModel {

    #[Field(isID: true)]
    public int $productID;

    #[Field(isUnique: true, length: 100)]
    public string $name;

    #[Field(decimals: 2)]
    public float $price;

    #[Field(isText: true)]
    public string $description;
}

The property type decides the column type — int, float, string, bool, an enum or a Date — and the attribute refines it.

The model is the source of truth for the table. A migration compares it against the database and creates or updates the table to match — new columns and keys are added, and columns no longer in the model are dropped. So a schema change is a change to this class, followed by ./framework build and ./framework migrate.

The Model attribute

Model describes the table as a whole. Its flags add the standard columns and declare which write operations the model allows:

ArgumentDoes
fantasyNameThe readable name used in messages and logs.
hasTimestampsAdds the created and modified time columns.
hasUsersAdds the columns tracking which user created and modified the row.
canCreate / canEditGenerates the create and edit operations.
canDeleteEnables soft deletes — the row is flagged rather than removed.
skipListLeaves the model out of the generated listings.
#[Model(
    fantasyName:   "Product",
    hasTimestamps: true,
    hasUsers:      true,
    canCreate:     true,
    canEdit:       true,
    canDelete:     true,
)]
class ProductModel {
    // ...
}

The Field attribute

Field turns a property into a column. Most fields need nothing but the attribute; the arguments are there for the ones that do.

Keys & identity

ArgumentDoes
isIDThe primary id, auto-incremented unless notAutoInc is set. A model has exactly one.
isPrimary / isKeyPart of the primary key, or an indexed key.
isUniqueThe value may not repeat.
isParentThe column pointing at the owning row.
isCodeA generated unique code.
isPosition / minPositionAn ordering column, kept contiguous as rows move.
belongsTo / otherFieldThe model this column points at, and the column it matches there.

A child model that hangs off a parent, ordered inside it, combines several of them:

#[Model(canCreate: true, canEdit: true)]
class ProductImageModel {

    #[Field(isID: true)]
    public int $productImageID;

    // The owning row — indexed, and used to scope every query
    #[Field(isParent: true, isKey: true, belongsTo: "Product")]
    public int $productID;

    // Keeps 1..n contiguous as images are added, moved or removed
    #[Field(isPosition: true, minPosition: 1)]
    public int $position;

    // ...
}

Shape & storage

ArgumentDoes
lengthThe column length — varchar(n) for strings, int(n) for numbers.
decimalsHow many decimals a float keeps (2 by default).
isSignedThe number may be negative.
isText / isLongTextStore as text or longtext instead of a varchar.
isEncryptEncrypt the value at rest, with the configured DB_KEY.
dateType / dateInput / hourInputHow a date column is stored and how it arrives from a form.
#[Model(hasTimestamps: true)]
class InvoiceModel {
    // ...

    // A negative balance is allowed, kept to 2 decimals
    #[Field(decimals: 2, isSigned: true)]
    public float $balance;

    // Long free text, stored as longtext
    #[Field(isLongText: true)]
    public string $notes;

    // Never stored in the clear
    #[Field(isEncrypt: true, length: 60)]
    public string $taxNumber;

    // A date coming from separate date and hour inputs
    #[Field(dateType: DateType::Middle, dateInput: "dueDate", hourInput: "dueHour")]
    public int $dueTime;
}

Files

ArgumentDoes
isFileThe column holds a file name.
hasFile / jsonFilesThe row owns a file, or a JSON list of them.
filePathThe registered path the files live in.
#[Model(canCreate: true, canEdit: true)]
class ProductModel {
    // ...

    // One image, stored in the registered "products" path
    #[Field(isFile: true, hasFile: true, filePath: "products")]
    public string $image;

    // Several files, kept as a JSON list
    #[Field(isFile: true, jsonFiles: true, filePath: "products")]
    public string $attachments;
}

A file field is worth more than the column it stores. The column holds only the file name, but the generated entity gains two more properties beside it, built from the registered path — so a listing has the urls it needs without resolving any paths itself:

$product = Product::getByID($productID);

$product->image;        // "photo.png" — what is stored
$product->imageUrl;     // the public url of the file
$product->imageThumb;   // the url of its thumbnail

Every file field gets the same pair, named after it — <field>Url and <field>Thumb.

Declaring a file also brings the model into the media migration. The build collects every file field across every model and generates a MediaSchema class, whose updatePaths() rewrites the stored names across all of those tables at once. That is what keeps the database honest when a file is renamed, moved or deleted in the media browser — the change reaches every row that pointed at it, without any model knowing about the others.

The underlying FieldType enum names every storage kind the layer knows — Number, Float, String, Text, LongText, Boolean, Date, Enum, JSON, Array, File and Encrypt.

How the values are stored

Several types are stored as something other than what you read back. The conversion happens in both directions, so the property type is what you work with and the column type is an implementation detail.

Floats are stored as integers. A float field is scaled by its decimals before it is written, which keeps money exact instead of drifting the way a SQL float would:

#[Model(canCreate: true, canEdit: true)]
class ProductModel {
    // ...

    // 19.99 is stored as the integer 1999
    #[Field(decimals: 2)]
    public float $price;

    // 1.2345 is stored as 12345
    #[Field(decimals: 4)]
    public float $rate;
}
// You always write and read a float
self::createEntity(price: 19.99);

$product = Product::getByID($productID);
$product->price;   // 19.99, back as a float

Raise decimals when you need more precision and lower it when you need less — the column is an integer either way. Because of that, comparisons in a generic query against the raw column use the scaled value.

Dates are stored as unix timestamps, and read back as a Date object, so no parsing happens in your code:

#[Model(hasTimestamps: true)]
class EventModel {
    // ...

    #[Field(dateType: DateType::Start)]
    public int $startTime;
}
$event = Event::getByID($eventID);

$event->startTime;                  // a Date instance
$event->startTime->toString();
$event->startTime->isPast();

// Writing takes the timestamp
self::editEntity($eventID, startTime: $date->toTime());

The dateType says which moment of the day a date-only input becomes — the start, the middle or the end — and dateInput / hourInput name the form fields the parts arrive in.

Enums are stored as their name. The column holds the case name as a string, and the entity gives you the case back — which is why a value that is no longer a case simply resolves to None instead of breaking:

#[Model]
class ProductModel {
    // ...

    #[Field]
    public ProductType $type;   // stored as "Physical", "Digital"…
}
$product->type;                     // a ProductType case
$product->type->toString();         // its name

$query->type->equal(ProductType::Digital);   // compared as a case

JSON and array fields are encoded on write and decoded on read. Declare the property as an array and the column holds the JSON text:

#[Model(canCreate: true, canEdit: true)]
class ProductModel {
    // ...

    /** @var list<string> */
    #[Field]
    public array $tags;

    /** @var array<string,mixed> */
    #[Field]
    public array $metadata;
}
// Written as JSON
self::editEntity($productID, tags: [ "new", "sale" ]);

// Read back as an array
$product->tags;       // [ "new", "sale" ]
$product->metadata;   // [ "color" => "red", … ]

To edit one inside the database rather than reading and rewriting it, the Assign helpers jsonReplace() and jsonRemove() work on the column in place. A field can also be typed as a Dictionary, which is what a sub-model relation returns.

Status

Many rows have a lifecycle — active, paused, cancelled. Rather than a loose column, a model declares its states with the repeatable State attribute, each with a color for the interface:

use Framework\Database\Status\State;
use Framework\Database\Status\StateColor;

#[Model(canCreate: true, canEdit: true)]
class ProductModel {
    // ...

    #[Field]
    #[State(name: "Draft",     color: StateColor::Gray)]
    #[State(name: "Active",    color: StateColor::Green)]
    #[State(name: "Paused",    color: StateColor::Yellow)]
    #[State(name: "Cancelled", color: StateColor::Red, isHidden: true)]
    public ProductStatus $status;
}

The colors come from the StateColor enum — Green, Yellow, Orange, Red, Blue and Gray — and isHidden keeps a state out of the pickers while still being a valid stored value, which is what you want for the terminal ones.

The build turns the declaration into a <Name>Status enum with its names, colors and select list ready to use. A model that declares no states falls back to the framework's Status — a plain Active / Inactive pair.

Like a file, a status is worth more than its column. The entity gains two extra properties beside it, already resolved — so a listing can render the label and its colored badge straight from the row:

$product = Product::getByID($productID);

$product->status;        // a ProductStatus case
$product->statusName;    // "Paused" — the translated name
$product->statusColor;   // "yellow" — the color of its state

statusName comes from the translations, so it arrives in the user's language, and statusColor from the StateColor you declared. Neither needs a lookup at render time.

Relations & computed fields

Beyond its own columns, a model can pull values in from other tables. Each of these is a property with its own attribute, and each becomes a real, typed field on the generated entity.

Relation

Relation joins another model and brings its fields along, so a product can carry its category name without a second query:

use Framework\Database\Model\Relation;

#[Model]
class ProductModel {
    // ...

    #[Field(belongsTo: "Category")]
    public int $categoryID;

    // Joins Category and adds its name as categoryName
    #[Relation(fieldNames: [ "name" ])]
    public CategoryModel $category;
}
ArgumentDoes
fieldNamesWhich fields of the related model to bring.
withPrefix / prefix / withoutPrefixHow the brought fields are named.
relationJoin / ownerJoinThe columns to join on, when they are not the obvious ones.
withDeletedJoin rows that were soft-deleted too.

A second relation to the same model, or a join on columns the framework cannot guess, needs the rest of the arguments:

#[Model]
class TicketModel {
    // ...

    #[Field(belongsTo: "Credential")]
    public int $createdUser;

    #[Field(belongsTo: "Credential")]
    public int $assignedUser;

    // Two joins to the same table, told apart by their prefix
    #[Relation(fieldNames: [ "firstName", "lastName" ], prefix: "creator", relationJoin: "createdUser")]
    public CredentialModel $creator;

    #[Relation(fieldNames: [ "firstName", "lastName" ], prefix: "assignee", relationJoin: "assignedUser")]
    public CredentialModel $assignee;
}

Count

Count adds a column holding how many related rows there are — the counts a listing shows without a query per row:

use Framework\Database\Model\Count;

#[Model]
class CategoryModel {
    // ...

    #[Count(modelName: "Product", fieldName: "categoryID")]
    public int $productCount;

    // Only the ones that are active
    #[Count(modelName: "Product", fieldName: "categoryID", query: "status = 'Active'")]
    public int $activeCount;
}

Expression

Expression adds a field computed by SQL, for values the database can work out better than PHP:

use Framework\Database\Model\Expression;

#[Model]
class ProductModel {
    // ...

    #[Expression("price * stock")]
    public float $totalValue;
}

Virtual

Virtual marks a property that is not a column. It exists on the generated entity — typed like any other field — but the database knows nothing about it, so your code fills it in after loading:

use Framework\Database\Model\Virtual;

#[Model]
class ProductModel {
    // ...

    #[Virtual]
    public string $priceText;
}

Use it for values derived from other fields, or for data another service provides, when you still want it to travel with the entity.

SubRequest

SubRequest fills a property from a second query, so each row can carry its children without a query per row. The framework runs one extra query for the whole result set and distributes the rows:

use Framework\Database\Model\SubRequest;

#[Model]
class ProductModel {
    // ...

    // The tags of every product, in one extra query
    #[SubRequest(modelName: "ProductTag", idName: "productID")]
    public array $tags;

    // Just the image names, newest first
    #[SubRequest(
        modelName: "ProductImage",
        idName:    "productID",
        fieldName: "image",
        orderBy:   "position",
        orderAsc:  false,
    )]
    public array $images;
}
ArgumentDoes
modelNameThe model the children come from.
idNameThe column linking a child back to this row.
fieldName / valueNameReturn just one column, or a name/value pair, instead of whole entities.
queryAn extra condition the children must meet.
orderBy / orderAscHow the children are sorted.

Validate

Validate declares the rules a value must satisfy, so the checks live next to the field instead of being repeated in every route. The generated schema runs them through validateRequest():

use Framework\Database\Model\Validate;

#[Model(canCreate: true, canEdit: true)]
class ProductModel {
    // ...

    #[Field(length: 100)]
    #[Validate(isRequired: true, maxLength: 100)]
    public string $name;

    #[Field(decimals: 2)]
    #[Validate(isPrice: true, minValue: 0)]
    public float $price;

    #[Field(belongsTo: "Category")]
    #[Validate(isRequired: true, belongsTo: "Category")]
    public int $categoryID;
}
ArgumentChecks
isRequiredA value was given.
isEmail / isUrlThe value is a valid address or url.
isNumeric / isPrice / isSignedThe value is a number, a price, or may be negative.
maxLengthThe text is not longer than this.
minValue / maxValueThe number falls inside the range.
greaterThanThe value is greater than another field.
belongsTo / withParent / belongsNameThe referenced row exists — optionally under the same parent.
typeOfThe value is a case of the given enum.
methodCall your own method for anything the arguments cannot express.
ifOnly apply the rule when another field has a value.

The conditional and custom rules cover what the flags cannot — a field required only when another is set, a range bounded by a sibling column, or a check of your own:

#[Model(canCreate: true, canEdit: true)]
class DiscountModel {
    // ...

    #[Field]
    #[Validate(isRequired: true, typeOf: "DiscountType")]
    public DiscountType $type;

    // Only required when the discount is a percentage
    #[Field(decimals: 2)]
    #[Validate(if: "isPercentage", isRequired: true, minValue: 0, maxValue: 100)]
    public float $percentage;

    // Must be after the start date
    #[Field(dateType: DateType::Start)]
    #[Validate(isRequired: true, greaterThan: "fromTime")]
    public int $toTime;

    // Anything the arguments cannot express
    #[Field(length: 40)]
    #[Validate(method: "validateCode")]
    public string $code;
}

A failed check becomes an entry in the Errors bag, keyed by the field, so the client can show each message beside its input.

Requested

Requested marks which fields make up the typed request a route receives. The build turns them into a <Name>Request class with a property per marked field:

use Framework\Database\Model\Requested;

#[Model(canCreate: true, canEdit: true)]
class ProductModel {

    #[Field(isID: true)]
    #[Requested(isID: true)]
    public int $productID;

    #[Field(length: 100)]
    #[Requested]
    public string $name;

    #[Field(decimals: 2)]
    #[Requested(isNumber: true)]
    public float $price;
}

By default the request field takes the type of the column. The arguments override how the incoming value is read, for the cases where the wire format differs from the storage:

ArgumentDoes
isID / isMultiIDThe identifier — one, or a list of them for bulk actions.
isString / isNumberForce the value to be read as text or as a number.
isJSONThe field arrives as JSON and is decoded.
isFileThe field is an uploaded file.
isDate / dateType / dateInput / hourInputThe field is a date, and how its parts arrive from the form.
useTimeZoneConvert the date from the user's time zone.
canEditWhether the field may be written, or is read-only.

A model without a table

A model does not have to describe a table. Declare the properties with #[Requested] and no #[Field], and nothing is created in the database — you simply get a typed request class for a route whose input does not map to a model:

// src/Media/Model/MediaModel.php
use Framework\Database\Model\Model;
use Framework\Database\Model\Requested;

#[Model]
class MediaModel {

    #[Requested]
    public string $path;

    #[Requested]
    public string $name;

    #[Requested]
    public string $newName;

    #[Requested(isFile: true)]
    public File $file;
}

The build writes a MediaRequest and nothing else, and a migration ignores the model entirely. That is how the media browser and other file or action endpoints get typed input without inventing a table for it.

What the build generates

From that one class the build writes a whole set of typed classes into a Schema folder beside the model — the schema you extend, a query builder, an entity, a column enum, the route request and the status enum. They are covered in Generated Schema.