Framework
Database

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:

RuleChecks
isRequiredThe string is not empty.
maxLengthThe string has at most this many characters.
typeOfThe string is one a class accepts.
isUniqueNo 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:

RuleChecks
isRequiredThe number is not zero.
minValue / maxValueThe number is inside the range.
isSignedThe number can be negative. Without it, it cannot.
greaterThanThe number is greater than another field of the request.
typeOfThe number is one a class accepts.
belongsToThe number is the id of a row of another model.
isUniqueNo 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:

RuleChecks
isPriceThe value is a price.
isRequiredThe 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:

RuleChecks
isEmailThe value is a valid email address.
isRequiredThe string is not empty.
isUniqueNo 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:

RuleChecks
isUrlThe value is a valid url.
isRequiredThe 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:

RuleChecks
alwaysThe date can be parsed, and the hour too when the field names one.
isRequiredA 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:

RuleChecks
typeOfThe class accepts the value.
methodThe method to call instead of isValid().
isRequiredA 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:

RuleChecks
belongsToA row with this id exists in the given class.
methodThe method to call instead of exists().
withParentThe row must also be under the same parent.
belongsNameThe 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:

The generated validateRequest()
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:

The generated validateRequest()
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:

TypeOrder of the checks
TextisRequired, typeOf, isUnique, maxLength
NumberisRequired, typeOf, belongsTo, the number, isUnique, greaterThan
EmailisRequired, the address, isUnique
UrlisRequired, the url
PriceisRequired, the price
DateisRequired, the date, the hour, then the period
Listthe values, one by one
Statusthe status

Three consequences are worth knowing:

  • Without isRequired, an empty value passes. belongsTo, isUnique and the range checks skip a value that is not there, so an optional field does not report a missing row.
  • typeOf is the exception. It also runs on an empty value, and an empty value fails it.
  • A range runs before isUnique. With minValue: 1 on 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:

MemberWhat it holds
hasError()Whether any rule failed.
errorsThe Errors bag, keyed by field, ready to return.
canValidateWhether 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:

SuffixWritten by
_EMPTYisRequired, when another check follows it.
_INVALIDThe check of the value itself, when an empty check comes before it.
_LENGTHmaxLength. The limit is passed along.
_EXISTSisUnique.
_GREATERgreaterThan.

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 belongsTo error is named after the class it asked, because the missing row belongs to it: CATEGORIES_ERROR_EXISTS, or TAGS_ERROR_SOME_EXISTS for a list. belongsName renames that part — with belongsName: "Category" the key is CATEGORY_ERROR_EXISTS.
  • prefix replaces the model part of the key for one field, for a message that belongs to something other than the model: prefix: "Client" writes CLIENT_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_STATUS and 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.