Validation
The framework validates the fields of a request automatically. You declare the rules on the
model, next to each field. The build generates a validateRequest()
method on the schema that runs them all. A route calls that method and
returns the errors it found.
Marking the fields
Add a Validate attribute to each field you want checked. A field
without one is never checked:
use Framework\Database\Model\Validate;
#[Model(canCreate: true, canEdit: true)]
class ProductModel {
#[Field(length: 100), Requested]
#[Validate(isRequired: true, maxLength: 100)]
public string $name = "";
#[Field(decimals: 2), Requested]
#[Validate(isPrice: true, isRequired: true)]
public float $price = 0;
}
How a value is validated depends on the PHP type of the field. A string is validated as
text, an int or a float as a number, a Date as a date, and an enum as
one of its cases. Some options of the attribute narrow that further: isPrice turns a float into
a price, and isEmail, isUrl, isNumeric or typeOf give a
string a stricter check. So the type picks the section below, and the option refines it.
Two things apply to every type. The rules are read at
./framework build, so a change to them needs a build. And
the rules check the value that arrives in the request, not the one in the database, so a validated field
must also carry the Requested attribute — every example here does.
Text
A string field is validated as text when no option narrows it:
#[Field(length: 100), Requested]
#[Validate(isRequired: true, maxLength: 100)]
public string $name = "";
#[Field(isText: true), Requested]
#[Validate(maxLength: 500)]
public string $description = "";
Text has two rules of its own, and can also be unique:
| Rule | Checks |
|---|---|
isRequired | The string is not empty. |
maxLength | The string has at most this many characters. |
typeOf | The string is one a class accepts. |
isUnique | No other row has this value. Declared on the field. |
The length error includes the limit, so the message can show how many characters are allowed.
Numbers
An int or a float field is validated as a number. Zero counts as empty:
#[Field, Requested]
#[Validate(isRequired: true, minValue: 1, maxValue: 99)]
public int $quantity = 0;
// Can be negative
#[Field(isSigned: true), Requested]
#[Validate(isSigned: true)]
public int $balance = 0;
// Compared against another field of the request
#[Field, Requested]
#[Validate(greaterThan: "minWeight")]
public int $maxWeight = 0;
A number can be bounded, compared, looked up or unique:
| Rule | Checks |
|---|---|
isRequired | The number is not zero. |
minValue / maxValue | The number is inside the range. |
isSigned | The number can be negative. Without it, it cannot. |
greaterThan | The number is greater than another field of the request. |
typeOf | The number is one a class accepts. |
belongsTo | The number is the id of a row of another model. |
isUnique | No other row has this value. Declared on the field. |
greaterThan names a field of the same request, not a column of the table. Both values are
compared as they arrived, before anything is saved.
Numbers stored as strings
Sometimes the column has to be a string, but the value has to be a number: a phone, a document number, a
code with leading zeros. isNumeric validates such a field as a number without changing its
type:
#[Field(length: 20), Requested]
#[Validate(isNumeric: true, isRequired: true)]
public string $taxNumber = "";
A field that already is an int or a float does not need it. It is checked as a
number anyway.
Prices
A price is a float field narrowed with isPrice. The check is that the value has
at most two decimals and is not negative:
#[Field(decimals: 2), Requested]
#[Validate(isPrice: true, isRequired: true)]
public float $price = 0;
A price only combines with the required flag:
| Rule | Checks |
|---|---|
isPrice | The value is a price. |
isRequired | The price is not zero. |
The column stores the price as a whole number of cents. A value with more than two decimals is rejected, not rounded.
Emails
A string field narrowed with isEmail must hold a valid email address:
#[Field, Requested]
#[Validate(isEmail: true, isRequired: true)]
public string $email = "";
An email can also be required and unique:
| Rule | Checks |
|---|---|
isEmail | The value is a valid email address. |
isRequired | The string is not empty. |
isUnique | No other row has this email. Declared on the field. |
An empty value passes unless the field is required. The error messages are shared instead of named after
the model: GENERAL_ERROR_EMAIL_EMPTY and GENERAL_ERROR_EMAIL_INVALID.
Urls
A string field narrowed with isUrl must hold a valid url:
#[Field, Requested]
#[Validate(isUrl: true)]
public string $website = "";
A url only combines with the required flag:
| Rule | Checks |
|---|---|
isUrl | The value is a valid url. |
isRequired | The string is not empty. |
An empty value passes unless the field is required, so an optional url can be left blank. The messages
are shared too: GENERAL_ERROR_URL_EMPTY and GENERAL_ERROR_URL_INVALID.
Dates
A Date field is validated as a date. The frontend sends the date, and optionally an hour,
as inputs of their own. dateInput and hourInput on the
Field declare the names of those inputs, and the
Request reads them and builds the Date the rules check:
// Just a date
#[Field(dateInput: "publishDate"), Requested]
#[Validate(isRequired: true)]
public ?Date $publishTime = null;
// A date and an hour
#[Field(dateInput: "startDate", hourInput: "startHour"), Requested]
#[Validate(isRequired: true)]
public ?Date $startTime = null;
A date is always checked for being readable. The attribute only adds the required flag:
| Rule | Checks |
|---|---|
| always | The date can be parsed, and the hour too when the field names one. |
isRequired | A date was given, and an hour if the field names one. |
The errors use the names of the inputs, not the field: publishDate instead of
publishTime. Those are the inputs the frontend shows.
Periods
Two dates can form a period: a from field followed by a to field, each with or
without an hour. The framework then also checks that the end is not before the start:
#[Field(dateInput: "fromDate"), Requested]
#[Validate(isRequired: true)]
public ?Date $fromTime = null;
#[Field(dateInput: "toDate"), Requested]
#[Validate]
public ?Date $toTime = null;
The names of the fields make the period: one starts with from and the next with
to. The period check is separate from the rules of each date, so a backwards period is
reported even when both dates are valid on their own.
Enums
A field typed as an enum is validated against its cases. No option is needed — the framework knows the enum from the type and adds the check itself:
#[Field, Requested]
#[Validate]
public DiscountType $type = DiscountType::None;
An enum does not need isRequired either. None is the empty case of every enum,
and the check already rejects it.
The Status
The Status of a model works like an enum. The attribute alone is enough, and the value is checked against the states the model declares:
#[Field, Requested]
#[Validate]
public Status $status = Status::None;
The check uses the generated <Name>Status enum, so only a declared state passes. The
error is shared: GENERAL_ERROR_STATUS.
Values checked by a class
A string or an int field can hold a value that only some class can verify: a
language code, a numeric code, the id of something in an external service. typeOf names that
class, and the check calls isValid() on it. method picks another method when the
class answers under a different name:
#[Field, Requested]
#[Validate(typeOf: Language::class, isRequired: true)]
public string $language = "";
#[Field, Requested]
#[Validate(typeOf: OpenAI::class, method: "modelExists", isRequired: true)]
public string $externalID = "";
The class decides, so the attribute only carries these:
| Rule | Checks |
|---|---|
typeOf | The class accepts the value. |
method | The method to call instead of isValid(). |
isRequired | A value was given. |
The two types treat an empty value differently. On a string, typeOf also runs on an empty
value, so add isRequired when an empty value should get its own message instead of the invalid
one. On an int, a zero skips the check, like every other number rule.
Colors
A color is a string field checked against the Color enum of the framework,
which holds the palette as hex values. It is declared like any other typeOf, and the framework
recognizes it:
use Framework\Utils\Color;
#[Field, Requested]
#[Validate(typeOf: Color::class)]
public string $color = "";
The error uses a shared key instead of the model, since a color is a color everywhere:
GENERAL_ERROR_COLOR. Like every typeOf on a string, the check also runs on an
empty value, and an empty value fails it.
Ids of another model
A field that holds the id of another model can check that the row exists. belongsTo takes
the class to ask, and calls exists() on it. Use the class of your app over the
generated schema, since the model itself has no exists():
#[Field(belongsTo: "Category"), Requested]
#[Validate(belongsTo: Categories::class, isRequired: true)]
public int $categoryID = 0;
The lookup can be narrowed, and its message renamed:
| Rule | Checks |
|---|---|
belongsTo | A row with this id exists in the given class. |
method | The method to call instead of exists(). |
withParent | The row must also be under the same parent. |
belongsName | The name to use in the error message instead of the class. |
withParent stops a request from using an id of another tenant. The row is only accepted when
it has the same parent as the one being saved.
Lists
A JSON field can hold a list of ids. belongsTo checks each one, and the first
missing id reports the error for the whole list:
#[Field, Requested(isJSON: true)]
#[Validate(belongsTo: Tags::class)]
public JSON $tagIDs;
typeOf works on a list too. Each value is passed to the class instead of looked up. In both
cases the values are read as numbers.
Unique values
isUnique checks that every row has a different value in this field: the request is rejected
when another row already has the one it brings. It is declared on the
field, and the check is generated as soon as the field also has a
Validate attribute:
#[Field(isUnique: true), Requested]
#[Validate(isRequired: true)]
public string $code = "";
The check skips the row being edited, so saving a row without changing the value does not clash with itself. An empty value is not checked either, unless the field is required. A column that many rows leave empty is not a conflict.
Conditional rules
if makes the whole rule apply only when a condition is true. The condition has three parts:
a field of the request, an operator, and a value:
#[Field]
#[Requested]
public string $kind = "";
// Only required when the discount is a percentage
#[Field(decimals: 2), Requested]
#[Validate(if: "kind = Percentage", isRequired: true, maxValue: 100)]
public float $percentage = 0;
The build turns the condition into an if around the checks of the field, so nothing runs
when the condition is false — not even the required one:
if ($request->kind === "Percentage") {
if ($request->percentage === 0.0) {
$errors->percentage = "DISCOUNT_ERROR_PERCENTAGE_EMPTY";
} elseif (!Numbers::isValid($request->percentage, 0, 100)) {
$errors->percentage = "DISCOUNT_ERROR_PERCENTAGE_INVALID";
}
}
The operator can be =, == or === for equal, and !=,
!== or <> for not equal. All of them generate the strict comparison. The
value can be a number, true, false, or anything else as a string. The field must
be part of the request or a parent of the model. When it is not, the condition is dropped and the rule
always applies.
How the rules combine
This part matters when a field has several rules. They become one chain of if and
elseif, so the field only reports the first rule that fails. A name
that is required and too long reports that it is empty, never that it is too long:
if ($request->name === "") {
$errors->name = "PRODUCT_ERROR_NAME_EMPTY";
} elseif (Strings::length($request->name) > 100) {
$errors->add("name", "PRODUCT_ERROR_NAME_LENGTH", 100);
}
The order of the chain is fixed. It does not follow the order of the arguments, and it goes from the cheapest check to the ones that query the database:
| Type | Order of the checks |
|---|---|
| Text | isRequired, typeOf, isUnique, maxLength |
| Number | isRequired, typeOf, belongsTo, the number, isUnique, greaterThan |
isRequired, the address, isUnique | |
| Url | isRequired, the url |
| Price | isRequired, the price |
| Date | isRequired, the date, the hour, then the period |
| List | the values, one by one |
| Status | the status |
Three consequences are worth knowing:
- Without
isRequired, an empty value passes.belongsTo,isUniqueand the range checks skip a value that is not there, so an optional field does not report a missing row. typeOfis the exception. It also runs on an empty value, and an empty value fails it.- A range runs before
isUnique. WithminValue: 1on a unique column, an empty value fails the range and never reaches the query.
Running the rules
The generated method takes the request and returns a
Result. It exists as soon as one field has a rule, so the route
only has to call it:
#[Route("/products/edit", Access::Admin)]
public static function edit(ProductRequest $request): Response {
$result = Product::validateRequest($request);
if ($result->hasError()) {
return Response::error($result->errors);
}
Product::edit($request);
return Response::success("PRODUCT_SAVED");
}
The result tells whether the rules ran and what they found:
| Member | What it holds |
|---|---|
hasError() | Whether any rule failed. |
errors | The Errors bag, keyed by field, ready to return. |
canValidate | Whether the rules ran at all. False when the row cannot be edited or was not found. |
addError($error, $message, ...$value) | Adds an error of your own before returning. |
Two checks run before the fields: canEdit(), and on an edit, that the row exists. When
either fails, the form error is set and canValidate stays false. A request for a
missing row gets one error, not a page of field errors.
The generated canEdit() answers true. Override it in the class of your app to say when the
rows can be edited at all — a closed period, a role that only reads — and every validation run through that
class asks it first.
Models with a parent
A field marked isParent on the Field scopes the whole
schema, and the validation with it. The generated method takes the id of the parent after the request:
$result = Product::validateRequest($request, $clientID);
The parent reaches every check that touches the database. canEdit() receives it, the row
lookup of an edit only finds rows under that parent, and a unique value only has to be unique among its
siblings — two clients can both have a product with the same code. A belongsTo can be scoped
the same way with withParent.
The error messages
A failed rule writes a key, not a sentence. The language files turn the key into the message the user reads, so every key a model can write needs an entry there.
How a key is built
The key has two parts joined by _ERROR_: the name of the model and the name of the field,
both in constant case. The model part comes from the fantasyName of the
Model, or from the class name when it has none. The field part drops a
trailing ID, since the message is about the thing rather than its id:
#[Model(fantasyName: "Product", canCreate: true, canEdit: true)]
class ProductModel {
#[Field(length: 100), Requested]
#[Validate(isRequired: true)]
public string $name = ""; // PRODUCT_ERROR_NAME
#[Field, Requested]
#[Validate(isRequired: true)]
public string $subTitle = ""; // PRODUCT_ERROR_SUB_TITLE
#[Field(belongsTo: "Category"), Requested]
#[Validate(isRequired: true)]
public int $categoryID = 0; // PRODUCT_ERROR_CATEGORY
}
One check, one key
A field with a single check writes the bare key, like the three above: name is only
required, so its one message is PRODUCT_ERROR_NAME. When a field has more than one check, each
message gets a suffix so the language file can tell them apart:
| Suffix | Written by |
|---|---|
_EMPTY | isRequired, when another check follows it. |
_INVALID | The check of the value itself, when an empty check comes before it. |
_LENGTH | maxLength. The limit is passed along. |
_EXISTS | isUnique. |
_GREATER | greaterThan. |
So a name that is required and has a maximum length needs two entries,
PRODUCT_ERROR_NAME_EMPTY and PRODUCT_ERROR_NAME_LENGTH, and one that is only
required needs PRODUCT_ERROR_NAME alone.
The special cases
Some keys are not built from the model and the field:
- A
belongsToerror is named after the class it asked, because the missing row belongs to it:CATEGORIES_ERROR_EXISTS, orTAGS_ERROR_SOME_EXISTSfor a list.belongsNamerenames that part — withbelongsName: "Category"the key isCATEGORY_ERROR_EXISTS. prefixreplaces the model part of the key for one field, for a message that belongs to something other than the model:prefix: "Client"writesCLIENT_ERROR_NAME.- Emails, urls, dates, colors and the status use shared keys instead of the model, since their
messages read the same everywhere:
GENERAL_ERROR_EMAIL_INVALID,GENERAL_ERROR_URL_INVALID,GENERAL_ERROR_FROM_DATE_EMPTY,GENERAL_ERROR_COLOR,GENERAL_ERROR_STATUSand the rest of the family.
Beyond the rules
Each rule checks one value. A check that needs more than one, like a total that must match its lines or a date that depends on stored data, goes in the class of your app. Run the generated rules first and add to the result:
public static function validate(ProductRequest $request): Result {
$result = self::validateRequest($request);
if ($result->hasError()) {
return $result;
}
if ($request->price < self::getCost($request->id)) {
$result->addError("price", "PRODUCT_ERROR_PRICE_BELOW_COST");
}
return $result;
}
The declared rules stay on the model, next to the columns. The class only keeps the checks that need more than the request.