Schema: Data Validation
A practical library for validating and normalizing data structures against a given schema with a smart, easy-to-understand API.
Installation:
composer require nette/schema
Basic Usage
In the variable $schema
, we have a validation schema (we'll explain what this means and how to create one in a
moment), and in the variable $data
, we have the data structure we want to validate and normalize. This could be, for
example, data submitted by a user via an API, a configuration file, etc.
The task is handled by the Nette\Schema\Processor class, which processes the input and either returns normalized data or throws a Nette\Schema\ValidationException exception if an error occurs.
$processor = new Nette\Schema\Processor;
try {
$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
echo 'Data is invalid: ' . $e->getMessage();
}
The method $e->getMessages()
returns an array of all messages as strings, and
$e->getMessageObjects()
returns all messages as Nette\Schema\Message objects.
Defining the Schema
And now let's create the schema. The class Nette\Schema\Expect is used to define it; we essentially
define expectations for what the data should look like. Let's say the input data must be a structure (e.g., an array) containing
elements processRefund
of type bool and refundAmount
of type int.
use Nette\Schema\Expect;
$schema = Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
]);
We believe the schema definition looks understandable, even if you're seeing it for the first time.
Let's send the following data for validation:
$data = [
'processRefund' => true,
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, passes validation
The output, i.e., the value $normalized
, is a stdClass
object. If we wanted the output to be an
array, we would add casting ->castTo('array')
to the schema.
All elements of the structure are optional and have a default value of null
. Example:
$data = [
'refundAmount' => 17,
];
$normalized = $processor->process($schema, $data); // OK, passes validation
// $normalized = {'processRefund' => null, 'refundAmount' => 17}
The fact that the default value is null
does not mean it would accept 'processRefund' => null
in
the input data. No, the input must be a boolean, i.e. true
or false
only. We would have to explicitly
allow null
using Expect::bool()->nullable()
.
An item can be made mandatory using Expect::bool()->required()
. We can change the default value, for example,
to false
using Expect::bool()->default(false)
or shorthand Expect::bool(false)
.
And what if we wanted to accept 1
and 0
in addition to booleans? Then we list the values that we also
want to normalize to boolean:
$schema = Expect::structure([
'processRefund' => Expect::anyOf(true, false, 1, 0)->castTo('bool'),
'refundAmount' => Expect::int(),
]);
$normalized = $processor->process($schema, $data);
is_bool($normalized->processRefund); // true
Now you know the basics of defining a schema and how the structure items behave. We will now show what other elements you can use when defining a schema.
Data Types: type()
All standard PHP data types can be specified in the schema:
Expect::string($default = null)
Expect::int($default = null)
Expect::float($default = null)
Expect::bool($default = null)
Expect::null()
Expect::array($default = [])
And also all types supported by the Validators
class, for example Expect::type('scalar')
or shorthand Expect::scalar()
. Also class or interface
names, e.g., Expect::type('AddressEntity')
.
Union syntax can also be used:
Expect::type('bool|string|array')
The default value is always null
with the exception of array
and list
, where it is an
empty array. (A list is an array indexed by a sequence of numeric keys starting from zero, i.e. a non-associative array).
Array of Values: arrayOf() listOf()
An array represents a too general structure; it's more useful to specify precisely which elements it may contain. For example, an array whose elements can only be strings:
$schema = Expect::arrayOf('string');
$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERROR: 123 is not a string
The second parameter can specify keys (since version 1.2):
$schema = Expect::arrayOf('string', 'int');
$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERROR: 'a' is not an int
A list is an indexed array:
$schema = Expect::listOf('string');
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 is not a string
$processor->process($schema, ['key' => 'a']); // ERROR: not a list
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: also not a list
The parameter can also be a schema, so we can write:
Expect::arrayOf(Expect::bool())
The default value is an empty array. If you specify a default value, it will be merged with the passed data. This can be
disabled using mergeDefaults(false)
(since version 1.1).
Enumeration: anyOf()
anyOf()
represents a set of values or schemas that a value can take. Here's how to write an array of elements
that can be either 'a'
, true
, or null
:
$schema = Expect::listOf(
Expect::anyOf('a', true, null),
);
$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERROR: false does not belong there
The elements of the enumeration can also be schemas:
$schema = Expect::listOf(
Expect::anyOf(Expect::string(), true, null),
);
$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERROR
The anyOf()
method accepts variants as separate parameters, not as an array. To pass it an array of values, use
the unpack operator anyOf(...$variants)
.
The default value is null
. Use the firstIsDefault()
method to make the first item the default:
// default is 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();
Structures
Structures are objects with defined keys. Each key-value pair is referred to as a “property”.
Structures accept arrays and objects and return stdClass
objects.
By default, all properties are optional and have a default value of null
. You can define mandatory properties
using required()
:
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(), // default value is null
]);
$processor->process($schema, ['optional' => '']);
// ERROR: option 'required' is missing
$processor->process($schema, ['required' => 'foo']);
// OK, returns {'required' => 'foo', 'optional' => null}
If you do not want properties with default value in the output, use skipDefaults()
:
$schema = Expect::structure([
'required' => Expect::string()->required(),
'optional' => Expect::string(),
])->skipDefaults();
$processor->process($schema, ['required' => 'foo']);
// OK, returns {'required' => 'foo'}
Although null
is the default value for the optional
property, it is not allowed in input data (the
value must be a string). Properties accepting null
are defined using nullable()
:
$schema = Expect::structure([
'optional' => Expect::string(),
'nullable' => Expect::string()->nullable(),
]);
$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' expects to be string, null given.
$processor->process($schema, ['nullable' => null]);
// OK, returns {'optional' => null, 'nullable' => null}
The array of all structure properties is returned by the getShape()
method.
By default, no additional items can be present in the input data:
$schema = Expect::structure([
'key' => Expect::string(),
]);
$processor->process($schema, ['additional' => 1]);
// ERROR: Unexpected item 'additional'
This can be changed using otherItems()
. As a parameter, pass the schema to validate each extra item:
$schema = Expect::structure([
'key' => Expect::string(),
])->otherItems(Expect::int());
$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ERROR
You can create a new structure by extending another using extend()
:
$dog = Expect::structure([
'name' => Expect::string(),
'age' => Expect::int(),
]);
$dogWithBreed = $dog->extend([
'breed' => Expect::string(),
]);
Array
An array with defined keys. Everything that applies to structures applies to it.
$schema = Expect::array([
'required' => Expect::string()->required(),
'optional' => Expect::string(), // default value is null
]);
You can also define an indexed array, known as tuple:
$schema = Expect::array([
Expect::int(),
Expect::string(),
Expect::bool(),
]);
$processor->process($schema, [1, 'hello', true]); // OK
Deprecated Properties
You can mark a property as deprecated using the deprecated([string $message])
method. Information about
deprecation is returned using $processor->getWarnings()
:
$schema = Expect::structure([
'old' => Expect::int()->deprecated('The item %path% is deprecated'),
]);
$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]
Ranges: min() max()
Use min()
and max()
to limit the count for arrays:
// array, at least 10 items, maximum 20 items
Expect::array()->min(10)->max(20);
For strings, limit its length:
// string, at least 10 characters long, maximum 20 characters
Expect::string()->min(10)->max(20);
For numbers, limit its value:
// integer, between 10 and 20 inclusive
Expect::int()->min(10)->max(20);
Of course, it is possible to specify just min()
or just max()
:
// string, maximum 20 characters
Expect::string()->max(20);
Regular Expressions: pattern()
Using pattern()
, you can specify a regular expression that the entire input string must match (i.e. as if
it were wrapped in ^
and $
characters):
// exactly 9 digits
Expect::string()->pattern('\d{9}');
Custom Assertions: assert()
You can add any other constraints using assert(callable $fn)
.
$countIsEven = fn($v) => count($v) % 2 === 0;
$schema = Expect::arrayOf('string')
->assert($countIsEven); // the count must be even
$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 is not an even count
Or
Expect::string()->assert('is_file'); // file must exist
You can add a custom description to each assertion. It will be part of the error message.
$schema = Expect::arrayOf('string')
->assert($countIsEven, 'Even items in array');
$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Even items in array" for item with value array.
The method can be called repeatedly to add multiple constraints. It can be interleaved with calls to transform()
and castTo()
.
Transformation: transform()
Successfully validated data can be modified using a custom function:
// convert to uppercase:
Expect::string()->transform(fn(string $s) => strtoupper($s));
The method can be called repeatedly to add multiple transformations. It can be interleaved with calls to assert()
and castTo()
. The operations are performed in the order in which they are declared:
Expect::type('string|int')
->castTo('string')
->assert('ctype_lower', 'All characters must be lowercased')
->transform(fn(string $s) => strtoupper($s)); // convert to uppercase
The transform()
method can simultaneously transform and validate the value. This is often simpler and less code
duplication than chaining transform()
and assert()
. For this purpose, the function receives a Context object with an addError()
method,
which can be used to add information about validation problems:
Expect::string()
->transform(function (string $s, Nette\Schema\Context $context) {
if (!ctype_lower($s)) {
$context->addError('All characters must be lowercased', 'my.case.error');
return null;
}
return strtoupper($s);
});
Casting: castTo()
Successfully validated data can be cast:
Expect::scalar()->castTo('string');
In addition to native PHP types, you can also cast to classes. It distinguishes between a simple class without a constructor and a class with a constructor. If the class has no constructor, an instance is created, and all structure elements are written to the properties:
class Info
{
public bool $processRefund;
public int $refundAmount;
}
Expect::structure([
'processRefund' => Expect::bool(),
'refundAmount' => Expect::int(),
])->castTo(Info::class);
// creates '$obj = new Info' and writes to $obj->processRefund and $obj->refundAmount
If the class has a constructor, the structure elements are passed as named arguments to the constructor:
class Info
{
public function __construct(
public bool $processRefund,
public int $refundAmount,
) {
}
}
// creates $obj = new Info(processRefund: ..., refundAmount: ...)
Casting combined with a scalar parameter creates an object and passes the value as the single argument to the constructor:
Expect::string()->castTo(DateTime::class);
// creates new DateTime(...)
Normalization: before()
Before the validation itself, the data can be normalized using the before()
method. As an example, let's take an
element that must be an array of strings (e.g., ['a', 'b', 'c']
), but accepts input in the form of string
a b c
:
$explode = fn($v) => explode(' ', $v);
$schema = Expect::arrayOf('string')
->before($explode);
$normalized = $processor->process($schema, 'a b c');
// OK and returns ['a', 'b', 'c']
Mapping to Objects: from()
You can have the structure schema generated from a class. Example:
class Config
{
public string $name;
public string|null $password;
public bool $admin = false;
}
$schema = Expect::from(new Config);
$data = [
'name' => 'Frank',
];
$normalized = $processor->process($schema, $data);
// $normalized instanceof Config
// $normalized = {'name' => 'Frank', 'password' => null, 'admin' => false}
Anonymous classes are also supported:
$schema = Expect::from(new class {
public string $name;
public ?string $password;
public bool $admin = false;
});
Because the information obtained from the class definition may not be sufficient, you can supplement the elements with your own schema using the second parameter:
$schema = Expect::from(new Config, [
'name' => Expect::string()->pattern('\w:.*'),
]);