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.
./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:
| Argument | Does |
|---|---|
fantasyName | The readable name used in messages and logs. |
hasTimestamps | Adds the created and modified time columns. |
hasUsers | Adds the columns tracking which user created and modified the row. |
canCreate / canEdit | Generates the create and edit operations. |
canDelete | Enables soft deletes — the row is flagged rather than removed. |
skipList | Leaves 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
| Argument | Does |
|---|---|
isID | The primary id, auto-incremented unless notAutoInc is set. A model has exactly one. |
isPrimary / isKey | Part of the primary key, or an indexed key. |
isUnique | The value may not repeat. |
isParent | The column pointing at the owning row. |
isCode | A generated unique code. |
isPosition / minPosition | An ordering column, kept contiguous as rows move. |
belongsTo / otherField | The 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
| Argument | Does |
|---|---|
length | The column length — varchar(n) for strings, int(n) for numbers. |
decimals | How many decimals a float keeps (2 by default). |
isSigned | The number may be negative. |
isText / isLongText | Store as text or longtext instead of a varchar. |
isEncrypt | Encrypt the value at rest, with the configured DB_KEY. |
dateType / dateInput / hourInput | How 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
| Argument | Does |
|---|---|
isFile | The column holds a file name. |
hasFile / jsonFiles | The row owns a file, or a JSON list of them. |
filePath | The 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;
}
| Argument | Does |
|---|---|
fieldNames | Which fields of the related model to bring. |
withPrefix / prefix / withoutPrefix | How the brought fields are named. |
relationJoin / ownerJoin | The columns to join on, when they are not the obvious ones. |
withDeleted | Join 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;
}
| Argument | Does |
|---|---|
modelName | The model the children come from. |
idName | The column linking a child back to this row. |
fieldName / valueName | Return just one column, or a name/value pair, instead of whole entities. |
query | An extra condition the children must meet. |
orderBy / orderAsc | How 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;
}
| Argument | Checks |
|---|---|
isRequired | A value was given. |
isEmail / isUrl | The value is a valid address or url. |
isNumeric / isPrice / isSigned | The value is a number, a price, or may be negative. |
maxLength | The text is not longer than this. |
minValue / maxValue | The number falls inside the range. |
greaterThan | The value is greater than another field. |
belongsTo / withParent / belongsName | The referenced row exists — optionally under the same parent. |
typeOf | The value is a case of the given enum. |
method | Call your own method for anything the arguments cannot express. |
if | Only 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:
| Argument | Does |
|---|---|
isID / isMultiID | The identifier — one, or a list of them for bulk actions. |
isString / isNumber | Force the value to be read as text or as a number. |
isJSON | The field arrives as JSON and is decoded. |
isFile | The field is an uploaded file. |
isDate / dateType / dateInput / hourInput | The field is a date, and how its parts arrive from the form. |
useTimeZone | Convert the date from the user's time zone. |
canEdit | Whether 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.