PHP code example of vanilla / garden-schema

1. Go to this page and download the library: Download vanilla/garden-schema library. Choose the download type require.

2. Extract the ZIP file and open the index.php.

3. Add this code to the index.php.
    
        
<?php
require_once('vendor/autoload.php');

/* Start to develop here. Best regards https://php-download.com/ */

    

vanilla / garden-schema example snippets


namespace Garden\Schema;

$schema = Schema::parse([...]);
try {
    $valid = $schema->validate($data);
} catch (ValidationException $ex) {
    ...
}

[
    '<property>', // basic property, can be any type
    '<property>?', // optional property
    '<property>:<type>?', // optional property with specific type

    '<property>:<type>?' => 'Description', // optional, typed property with description
    '<property>?' => ['type' => '<type'>, 'description' => '...'], // longer format

    '<property>:o' => [ // object property with nested schema
        '<property>:<type>' => '...',
        ...
    ],
    '<property>:a' => '<type>', // array property with element type
    '<property>:a' => [ // array property with object element type
        '<property>:<type>' => '...',
        ...
    ]
]
 

$schema = Schema::parse([
    'items:a', // array of any type
    'tags:a' => 's', // array of strings

    'attributes:o', // object of any type
    'user:o' => [ // an object with specific properties
        'name:s',
        'email:s?'
    ]
]);

$userSchema = Schema::parse([
    'name:s',
    'email:s?'
]);

$recordSchema = Schema::parse([
    "uuid:s",
    "body:s",
    // User schema is userSchema,
]);

$recordSchema = Schema::parse([
    "uuid:s",
    "body:s",
    // Array of user schema objects.
    "users:a" => $userSchema,
]);

enum MyEnum: string {
    One: 'one',
    Two: 'two',
    Three: 'three',
}

// Shorthand
$schema = Schema::parse([
    "numberField" => MyEnum::class,
]);

// Long form
$schema = Schema::parse([
    "numberField" => [
        'type' => 'string',
        "enumClassName" => MyEnum::class,
    ],
]);

$value = $schema->validate(["numberField" => 'one']);
$value['numberField']; // MyEnum::One

use Garden\Schema\Entity;

class User extends Entity {
    public string $name;
    public string $email;
    public int $age;
    public ?string $bio = null;  // Optional, nullable with default
}

// Get the schema
$schema = User::getSchema();

// Validate and create an entity
$user = User::from([
    'name' => 'John',
    'email' => '[email protected]',
    'age' => 30,
]);

$user->name; // 'John'
$user->age;  // 30 (integer)

$user = User::from(['name' => 'John', 'email' => '[email protected]', 'age' => 30]);
$same = User::from($user); // Returns $user, no validation

use Garden\Schema\Entity;
use Garden\Schema\PropertySchema;

class Article extends Entity {
    public string $title;

    // Add constraints to auto-generated string type
    #[PropertySchema(['minLength' => 10, 'maxLength' => 5000])]
    public string $body;

    // Add items constraint to auto-generated array type
    #[PropertySchema(['items' => ['type' => 'string']])]
    public array $tags;

    // Multiple constraints
    #[PropertySchema(['items' => ['type' => 'string'], 'minItems' => 1])]
    public array $categories;
}

use Garden\Schema\Entity;
use Garden\Schema\Required;

class User extends Entity {
    public string $name;

    // Nullable but lic ?string $bio = null;
}

// This throws ValidationException - nickname is e' => 'Johnny']);

use Garden\Schema\Entity;
use Garden\Schema\PropertyAltNames;

class User extends Entity {
    // Single alt name - primaryAltName is inferred
    #[PropertyAltNames('user_name')]
    public string $name;

    // Multiple alt names - primaryAltName is ']);      // Alt name

// Main property name takes precedence
$user3 = User::from(['name' => 'Main', 'user_name' => 'Alt']);
$user3->name; // 'Main'

use Garden\Schema\Entity;
use Garden\Schema\PropertyAltNames;

class Config extends Entity {
    #[PropertyAltNames(
        ['settings.displayName', 'meta.name', 'name'],
        primaryAltName: 'settings.displayName'
    )]
    public string $displayName;
}

// All of these work:
$config1 = Config::from(['displayName' => 'Direct']);
$config2 = Config::from(['settings' => ['displayName' => 'Nested']]);
$config3 = Config::from(['meta' => ['name' => 'Deep Nested']]);
$config4 = Config::from(['name' => 'Fallback']);

// Disable dot notation if you need literal dots in property names
class LiteralDots extends Entity {
    #[PropertyAltNames(['some.literal.key'], useDotNotation: false)]
    public string $value;
}

use Garden\Schema\EntityFieldFormat;

$entity = User::from(['user_name' => 'John', 'e-mail' => '[email protected]']);

// Regular toArray() uses main property names (canonical)
$array = $entity->toArray();
// ['name' => 'John', 'email' => '[email protected]']

// toArray with PrimaryAltName format uses the primary alt names
$altArray = $entity->toArray(form: EntityFieldFormat::PrimaryAltName);
// ['user_name' => 'John', 'e-mail' => '[email protected]']

// With dot notation, creates nested structures
$config = Config::from(['displayName' => 'Test']);
$altArray = $config->toArray(form: EntityFieldFormat::PrimaryAltName);
// ['settings' => ['displayName' => 'Test']]

$article = Article::from([
    'postID' => 1,
    'title' => 'Hello',
    'authorID' => 123,
    'authorName' => 'John',
]);

// toArray() ltName format extracts back to original flat structure
$altArray = $article->toArray(form: EntityFieldFormat::PrimaryAltName);
// ['postID' => 1, 'title' => 'Hello', 'authorID' => 123, 'authorName' => 'John']

use Garden\Schema\EntityFieldFormat;

// Convert a single field name
User::convertFieldName('name', EntityFieldFormat::PrimaryAltName);  // 'user_name'
User::convertFieldName('user_name', EntityFieldFormat::Canonical);  // 'name'

// Fields without alt names are returned unchanged
User::convertFieldName('age', EntityFieldFormat::PrimaryAltName);   // 'age'

// Convert multiple field names at once
User::convertFieldNames(
    ['name', 'email', 'age'],
    EntityFieldFormat::PrimaryAltName
);
// ['user_name', 'e-mail', 'age']

// Round-trip conversion
$alt = User::convertFieldName('name', EntityFieldFormat::PrimaryAltName);  // 'user_name'
$canonical = User::convertFieldName($alt, EntityFieldFormat::Canonical);   // 'name'

Config::convertFieldName('displayName', EntityFieldFormat::PrimaryAltName);
// 'attributes.displayName'

Config::convertFieldName('attributes.displayName', EntityFieldFormat::Canonical);
// 'displayName'

use Garden\Schema\Entity;
use Garden\Schema\MapSubProperties;

class Author extends Entity {
    public int $authorID;
    public string $authorName;
    public ?string $email = null;
    public ?string $bio = null;
}

class Article extends Entity {
    public int $articleID;
    public string $title;

    #[MapSubProperties(
        keys: ['authorID', 'authorName'],
        mapping: ['metadata.authorEmail' => 'email', 'metadata.authorBio' => 'bio']
    )]
    public Author $author;
}

// Input with flat structure and nested metadata:
$article = Article::from([
    'articleID' => 1,
    'title' => 'My Article',
    'authorID' => 123,
    'authorName' => 'John Doe',
    'metadata' => [
        'authorEmail' => '[email protected]',
        'authorBio' => 'A prolific writer',
    ],
]);

// The author property is automatically populated:
$article->author->authorID;   // 123
$article->author->authorName; // 'John Doe'
$article->author->email;      // '[email protected]'
$article->author->bio;        // 'A prolific writer'

#[MapSubProperties(
    keys: ['user.id', 'user.name'],           // Copy nested source to same path in target
    mapping: [
        'profile.avatar' => 'image.url',       // Remap nested source to different target path
        'settings.theme' => 'preferences.theme'
    ]
)]
public \ArrayObject $userData;

use Garden\Schema\Entity;
use Garden\Schema\ExcludeFromSchema;

class Article extends Entity {
    public string $title;
    public string $body;

    #[ExcludeFromSchema]
    public string $slug = '';  // Computed from title

    #[ExcludeFromSchema]
    public ?array $cache = null;  // Internal cache
}

$article = Article::from([
    'title' => 'Hello World',
    'body' => 'Content here',
    'slug' => 'ignored',  // This is ignored during validation
]);

$article->title; // 'Hello World'
$article->slug;  // '' (default value, input was ignored)

// Excluded properties can still be set directly
$article->slug = 'hello-world';
$article->cache = ['rendered' => '<p>Content here</p>'];

// Excluded properties are not 

use Garden\Schema\SchemaVariant;

// Get different schema variants
$fullSchema     = Article::getSchema();                       // Default: Full
$fullSchema     = Article::getSchema(SchemaVariant::Full);    // Explicit Full
$fragmentSchema = Article::getSchema(SchemaVariant::Fragment);
$mutableSchema  = Article::getSchema(SchemaVariant::Mutable);
$createSchema   = Article::getSchema(SchemaVariant::Create);
$internalSchema = Article::getSchema(SchemaVariant::Internal);

use Garden\Schema\Entity;
use Garden\Schema\ExcludeFromVariant;
use Garden\Schema\SchemaVariant;

class Article extends Entity {
    public int $id;
    public string $title;

    // Exclude from Fragment (too large for list responses)
    #[ExcludeFromVariant(SchemaVariant::Fragment)]
    public string $body;

    // Exclude from Mutable (system-managed, not user-editable)
    #[ExcludeFromVariant(SchemaVariant::Mutable)]
    public \DateTimeImmutable $createdAt;

    #[ExcludeFromVariant(SchemaVariant::Mutable)]
    public \DateTimeImmutable $updatedAt;

    // Exclude from multiple variants
    #[ExcludeFromVariant(SchemaVariant::Fragment, SchemaVariant::Mutable)]
    public string $internalNotes;
}

// Fragment schema won't 

#[ExcludeFromVariant(SchemaVariant::Fragment)]
#[ExcludeFromVariant(SchemaVariant::Mutable)]
public string $internalNotes;

use Garden\Schema\Entity;
use Garden\Schema\IncludeOnlyInVariant;
use Garden\Schema\SchemaVariant;

class User extends Entity {
    public int $id;
    public string $username;
    public string $email;

    // Only lic ?string $inviteCode;
}

// Create schema iant::Fragment);

use Garden\Schema\Entity;
use Garden\Schema\SchemaVariant;

// Invalidate a specific variant for a class
Entity::invalidateSchemaCache(Article::class, SchemaVariant::Full);

// Invalidate all variants for a class
Entity::invalidateSchemaCache(Article::class);

// Invalidate all cached schemas globally
Entity::invalidateSchemaCache();

use Garden\Schema\EntitySchemaCache;
use Garden\Schema\Schema;
use Garden\Schema\SchemaVariant;

// Check if a schema is cached
if (EntitySchemaCache::has(Article::class, SchemaVariant::Full)) {
    $schema = EntitySchemaCache::get(Article::class, SchemaVariant::Full);
}

// Get or create a schema with a factory function
$schema = EntitySchemaCache::getOrCreate(
    MyEntity::class,
    SchemaVariant::Full,
    function () {
        // Build and return the schema
        return new Schema([
            'type' => 'object',
            'properties' => [...],
        ]);
    }
);

// Invalidate specific cache entries
EntitySchemaCache::invalidate(Article::class, SchemaVariant::Full);
EntitySchemaCache::invalidate(Article::class); // All variants for class
EntitySchemaCache::invalidateAll(); // Everything

// Debugging helpers
$count = EntitySchemaCache::count();
$all = EntitySchemaCache::getAll();

// Define a custom variant enum
enum AccessLevel: string {
    case Public = 'public';
    case Admin = 'admin';
    case Internal = 'internal';
}

use Garden\Schema\Entity;
use Garden\Schema\ExcludeFromVariant;
use Garden\Schema\IncludeOnlyInVariant;

class User extends Entity {
    public int $id;
    public string $name;

    // Only visible to admins and internal
    #[ExcludeFromVariant(AccessLevel::Public)]
    public string $adminNotes = '';

    // Only visible internally
    #[IncludeOnlyInVariant(AccessLevel::Internal)]
    public string $internalSecret = '';
}

// Use custom variants with getSchema()
$publicSchema   = User::getSchema(AccessLevel::Public);
$adminSchema    = User::getSchema(AccessLevel::Admin);
$internalSchema = User::getSchema(AccessLevel::Internal);

$user = User::from([...]);
$user->adminNotes = 'Secret note';

// Serialize with a specific variant - only alization variant for the entity
$user->setSerializationVariant(AccessLevel::Public);

// Now toArray() and json_encode() will use the set variant
$array = $user->toArray();        // Uses AccessLevel::Public
$json = json_encode($user);       // Uses AccessLevel::Public

// Get the current serialization variant
$variant = $user->getSerializationVariant();  // AccessLevel::Public

// Clear the serialization variant (reverts to full schema)
$user->setSerializationVariant(null);

// Explicit variant in toArray() overrides the set serialization variant
$adminArray = $user->toArray(AccessLevel::Admin);  // Uses Admin regardless of set variant

class Article extends Entity {
    public int $id;
    public User $author;

    #[ExcludeFromVariant(AccessLevel::Public)]
    public string $editorNotes = '';
}

$article = Article::from([...]);
$article->author->adminNotes = 'Author admin note';
$article->editorNotes = 'Editor note';

// Both Article and nested User will use Public variant
$publicArray = $article->toArray(AccessLevel::Public);
// $publicArray won't 

use Garden\Schema\Entity;
use Garden\Schema\ExcludeFromVariant;
use Garden\Schema\NestedVariant;
use Garden\Schema\SchemaVariant;

class Author extends Entity {
    public int $id;
    public string $name;
    public string $email;

    #[ExcludeFromVariant(SchemaVariant::Fragment)]
    public string $bio = '';  // Only in Full variant
}

class Article extends Entity {
    public int $id;
    public string $title;

    #[ExcludeFromVariant(SchemaVariant::Fragment)]
    public string $body = '';

    // Author is always serialized as Fragment, even when Article is Full
    #[NestedVariant(SchemaVariant::Fragment)]
    public ?Author $author = null;
}

$article = Article::from([
    'id' => 1,
    'title' => 'Hello World',
    'body' => 'Full content...',
    'author' => [
        'id' => 10,
        'name' => 'John Doe',
        'email' => '[email protected]',
        'bio' => 'Author biography...',
    ],
]);

// Serialize article as Full
$array = $article->toArray(SchemaVariant::Full);
// Article has body (Full), but author does NOT have bio (Fragment)
// $array = [
//     'id' => 1,
//     'title' => 'Hello World',
//     'body' => 'Full content...',
//     'author' => [
//         'id' => 10,
//         'name' => 'John Doe',
//         'email' => '[email protected]',
//         // bio is excluded - author uses Fragment variant
//     ],
// ]

$schema = Article::getSchema(SchemaVariant::Full);
// The 'author' property in the schema will use Fragment schema (no bio property)

class Article extends Entity {
    public int $id;

    #[NestedVariant(SchemaVariant::Fragment)]
    #[PropertySchema(['items' => ['entityClassName' => Author::class]])]
    public array $contributors = [];
}

// All contributors will be serialized as Fragment
$array = $article->toArray(SchemaVariant::Full);

class Address extends Entity {
    public string $street;
    public string $city;
}

class Person extends Entity {
    public string $name;
    public Address $address;
    public ?Address $workAddress = null;
}

$person = Person::from([
    'name' => 'Jane',
    'address' => ['street' => '123 Main St', 'city' => 'Springfield'],
]);

$person->address; // Address instance
$person->address->city; // 'Springfield'

use Garden\Schema\Entity;
use Garden\Schema\EntityDefaultInterface;

class Metadata extends Entity implements EntityDefaultInterface {
    public string $version;
    public bool $draft;

    public static function default(): static {
        $instance = new static();
        $instance->version = '1.0';
        $instance->draft = true;
        return $instance;
    }
}

class Article extends Entity {
    public string $title;
    public Metadata $metadata;        // Gets default when not provided
    public ?Metadata $extraMeta;      // Also gets default when not provided
}

// Create without providing metadata - gets default
$article = Article::from(['title' => 'Hello World']);

$article->metadata->version; // '1.0'
$article->metadata->draft;   // true

// Explicit values override the default
$article2 = Article::from([
    'title' => 'Hello World',
    'metadata' => ['version' => '2.0', 'draft' => false],
]);

$article2->metadata->version; // '2.0'

// Property absent → gets default
$article = Article::from(['title' => 'Hello']);
$article->extraMeta; // Metadata instance with defaults

// Property explicitly null → stays null
$article = Article::from(['title' => 'Hello', 'extraMeta' => null]);
$article->extraMeta; // null

use Garden\Schema\Entity;

class Config extends Entity {
    public string $name;
    public \ArrayObject $settings;
    public ?\ArrayObject $metadata = null;
}

$config = Config::from([
    'name' => 'app',
    'settings' => ['debug' => true, 'timeout' => 30],
]);

$config->settings; // ArrayObject instance
$config->settings['debug']; // true
$config->settings['timeout'] = 60; // Modify in place

$config = Config::from([
    'name' => 'app',
    'settings' => [],  // Empty
]);

json_encode($config); // {"name":"app","settings":{},"metadata":null}

use Garden\Schema\Entity;

class Event extends Entity {
    public string $title;
    public \DateTimeImmutable $startsAt;
    public ?\DateTimeImmutable $endsAt = null;
}

$event = Event::from([
    'title' => 'Meeting',
    'startsAt' => '2024-06-15T14:00:00+00:00',
]);

$event->startsAt; // DateTimeImmutable instance
$event->startsAt->format('Y-m-d'); // '2024-06-15'

// toArray() and JSON serialize to RFC3339 format
$array = $event->toArray();
$array['startsAt']; // '2024-06-15T14:00:00+00:00'

// With milliseconds, uses RFC3339_EXTENDED
$event->startsAt = new \DateTimeImmutable('2024-06-15T14:00:00.123+00:00');
$array = $event->toArray();
$array['startsAt']; // '2024-06-15T14:00:00.123+00:00'

use Garden\Schema\Entity;
use Ramsey\Uuid\UuidInterface;

class Resource extends Entity {
    public string $name;
    public UuidInterface $id;
    public ?UuidInterface $parentId = null;
}

// From UUID string
$resource = Resource::from([
    'name' => 'My Resource',
    'id' => '550e8400-e29b-41d4-a716-446655440000',
]);

$resource->id; // UuidInterface instance
$resource->id->toString(); // '550e8400-e29b-41d4-a716-446655440000'

// From binary bytes (16 bytes)
$uuid = \Ramsey\Uuid\Uuid::uuid4();
$resource2 = Resource::from([
    'name' => 'Binary Resource',
    'id' => $uuid->getBytes(),
]);
$resource2->id->toString(); // Same as $uuid->toString()

// toArray() and JSON serialize to string format
$array = $resource->toArray();
$array['id']; // '550e8400-e29b-41d4-a716-446655440000'

// Invalid UUIDs throw ValidationException
Resource::from(['name' => 'Bad', 'id' => 'not-a-uuid']); // Throws ValidationException

$schema = Schema::parse([
    'id:s' => ['format' => 'uuid'],
]);

$valid = $schema->validate(['id' => '550e8400-e29b-41d4-a716-446655440000']);
$valid['id']; // UuidInterface instance

// Shorthand
$schema = Schema::parse([
    'user' => User::class,
]);

// Long form
$schema = Schema::parse([
    'user' => ['entityClassName' => User::class],
]);

// Array of entities
$schema = Schema::parse([
    'users:a' => User::class,
]);

$result = $schema->validate(['user' => ['name' => 'John', 'email' => '[email protected]', 'age' => 25]]);
$result['user']; // User instance

$user = User::from(['name' => 'John', 'email' => '[email protected]', 'age' => 30]);

// ArrayAccess for reading
$user['name']; // 'John'

// ArrayAccess for writing (bypasses validation - use with care)
$user['age'] = 31;

// Convert to array (nested entities become arrays, enums become values)
$array = $user->toArray();

// JSON serialization uses toArray()
$json = json_encode($user);

// Round-trip: array -> entity -> array produces equivalent data
$user2 = User::from($user->toArray());

$user = User::from(['name' => 'John', 'email' => '[email protected]', 'age' => 30]);

// Modify directly (no validation)
$user->name = 'Jane';
$user['age'] = 25;

// Validate current state - returns new validated entity or throws ValidationException
$validatedUser = $user->validate();

// Invalid modification
$user->age = 'not a number';
$user->validate(); // Throws ValidationException

use Garden\Schema\SchemaVariant;

$article = Article::from([
    'id' => 1,
    'title' => 'Original Title',
    'slug' => 'original-slug',
    'body' => 'Original body.',
    'createdAt' => '2024-01-01T00:00:00+00:00',
    'updatedAt' => '2024-01-01T00:00:00+00:00',
    'authorId' => 100,
]);

// Update only mutable fields — non-mutable fields (id, createdAt, etc.) are ignored
$article->update(['title' => 'New Title', 'body' => 'New body.']);

$article->title; // 'New Title'
$article->body;  // 'New body.'
$article->id;    // 1 (unchanged)

// Using alt names
$user->update(['user_name' => 'Jane']); // maps to $user->name

// Using mapped sub-property keys
$post->update(['authorID' => 200, 'authorName' => 'Jane']); // maps into $post->author

$article->update(['title' => 'New Title', 'body' => 'New body.']);

// Canonical property names (default)
$article->getUpdatedArray();
// ['title' => 'New Title', 'body' => 'New body.']

// Primary alt names
$article->getUpdatedArray(EntityFieldFormat::PrimaryAltName);
// ['article_title' => 'New Title', 'article_body' => 'New body.']

$article->update(['title' => 'First']);
$article->update(['body' => 'Second']);

$article->getUpdatedArray();
// ['title' => 'First', 'body' => 'Second']

use Garden\Schema\EntityInterface;
use Garden\Schema\EntityTrait;
use Garden\Schema\Schema;
use Garden\Schema\SchemaVariant;

class MyModel extends SomeFrameworkModel implements EntityInterface {
    use EntityTrait;

    private static ?Schema $schema = null;

    public int $id;
    public string $name;

    public static function getSchema(?\BackedEnum $variant = null): Schema {
        // Implement your own schema generation or caching
        if (self::$schema === null) {
            self::$schema = new Schema([
                'type' => 'object',
                'properties' => [
                    'id' => ['type' => 'integer'],
                    'name' => ['type' => 'string'],
                ],
                '

// Type hints work with the interface
function processEntity(EntityInterface $entity): array {
    return $entity->toArray();
}

// Works with both Entity subclasses and EntityInterface implementations
$result = processEntity(new MyModel());
$result = processEntity(User::from([...]));

$schema = Schema::parse([
    ':a' => [
        'id:i',
        'name:s',
        'birthday:dt'
    ]
]);

[
    ['id' => 1, 'name' => 'George', 'birthday' => '1732-02-22'],
    ['id' => 16, 'name' => 'Abraham', 'birthday' => '1809-02-12'],
    ['id' => 32, 'name' => 'Franklin', 'birthday' => '1882-01-30']
]

[
    // You can specify nullable as a property attribute.
    'opt1:s?' => ['nullable' => true],

    // You can specify null as an optional type in the declaration.
    'opt2:s|n?' => 'Another nullable, optional property.'
]

$schema = Schema::parse(['id:i', 'name:s']);
try {
    // $u1 will be ['id' => 123, 'name' => 'John']
    $u1 = $schema->validate(['id' => '123', 'name' => 'John']);

    // This will thow an exception.
    $u2 = $schema->validate(['id' => 'foo']);
} catch (ValidationException $ex) {
    // $ex->getMessage() will be: 'id is not a valid integer. name is 

$schema = Schema::parse(['page:i', 'count:i?']);

if ($schema->isValid(['page' => 5]) {
    // This will be hit.
}

if ($schema->isValid(['page' => 2, 'count' => 'many']) {
    // This will not be hit because the data isn't valid.
}

$components = [
    'components' => [
        'schemas' => [
            'User' => [
                'type' => 'object',
                'properties' => [
                    'id' => [
                        'type' => 'integer'
                    ],
                    'username' => [
                        'type' => 'string'
                    ]
                ]
            ]
        ]
    ]
]

$userArray = [
    'type' => 'array',
    'items' => [
        '$ref' => '#/components/schemas/User'
    ]
]

function(string $ref): array|Schema|null {
   ...
}

$sch = new Schema($userArray);
$sch->setRefLookup(new ArrayRefLookup($components));

$valid = $sch->validate(...);

use Garden\Schema\Schema;
$schema = Schema::parse([]);

// Enable a flag.
$schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, true);

// Disable a flag.
$schema->setFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE, false);

// Set all flags together.
$schema->setFlags(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE & Schema::VALIDATE_EXTRA_PROPERTY_NOTICE);

// Check if a flag is set.
$schema->hasFlag(Schema::VALIDATE_STRING_LENGTH_AS_UNICODE); // true

function (mixed $value, ValidationField $field): bool {
}

function (mixed $value, ValidationField $field): mixed {
}

$schema = new Schema([...]);

// By default schema returns instances of DateTimeImmutable, instead return a string.
$schema->addFormatFilter('date-time', function ($v) {
    $dt = new \DateTime($v);
    return $dt->format(\DateTime::RFC3339);
}, true);

class LocalizedValidation extends Validation {
    public function translate($str) {
        if (substr($str, 0, 1) === '@') {
            // This is a literal string that bypasses translation.
            return substr($str, 1);
        } else {
            return gettext($str);
        }
    }
}

// Install your class like so:
$schema = Schema::parse([...]);
$schema->setValidationClass(LocalizedValidation::class);