nette/schema
Nette Schema is a lightweight PHP library for validating and normalizing structured data. Define schemas to check arrays and objects, coerce values, and generate clear error messages for configs, APIs, and form input.
Install with Composer (nothing else to wire — there is no Laravel service provider, facade, or config file):
composer require nette/schema
Requirements: PHP 8.1+ and a maintained Laravel (10/11/12). It is a plain library — one Processor object in, one validated value out.
Where to look first (in this order):
src/Schema/Expect.php — every factory (string(), int(), arrayOf(), structure(), anyOf(), from(), …) lives in this one readable class.src/Schema/Processor.php — the entire entry point is process() / processMultiple(). It is stateless, so it is safe to bind as a singleton.src/Schema/ValidationException.php — how you turn failures into something a user (or your logs) can read.tests/ — the test suite doubles as the spec for edge cases the README glosses over. When behaviour is unclear, read the test, not the docs.First use case — validate application config at boot. This is the highest-value, lowest-risk place to start, because Laravel config comes from .env (everything is a string) and from merged files (nested arrays you cannot trust):
use Nette\Schema\Expect;
use Nette\Schema\Processor;
use Nette\Schema\ValidationException;
$schema = Expect::structure([
'name' => Expect::string()->required(),
'debug' => Expect::bool()->default(false),
'retries' => Expect::int()->min(0)->max(10)->default(3),
'log' => Expect::structure([
'level' => Expect::anyOf('debug', 'info', 'warning', 'error')->default('info'),
'path' => Expect::string()->required(),
]),
]);
try {
$config = (new Processor)->process($schema, config('app'));
} catch (ValidationException $e) {
// fail fast, with a path-precise message
throw new RuntimeException($e->getMessage(), previous: $e);
}
The win over config() + scattered ?? checks: defaults are applied centrally, unknown keys are rejected, and errors read log.level instead of “something is wrong somewhere”.
Bind the processor once. It holds no state, so a singleton is correct and avoids re-instantiating per call:
// AppServiceProvider::register()
$this->app->singleton(\Nette\Schema\Processor::class);
Coerce environment strings with transform(). env('APP_DEBUG') can be "true", "1", "(true)". transform() runs during normalization, before the type check, so this is the correct hook for repair:
$bool = fn($v) => is_string($v)
? (filter_var($v, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $v)
: $v;
$schema = Expect::structure([
'debug' => Expect::bool()->default(false)->transform($bool),
'timeout' => Expect::int()->default(30)->transform(fn($v) => is_numeric($v) ? (int) $v : $v),
]);
Do not put logging, DB lookups, or cache writes in transform() — it must stay a pure value-to-value function.
Validate config early, in a dedicated provider. Don't validate inside config/*.php (exceptions there are brutal to debug). Use a ConfigIntegrityServiceProvider::boot() that walks a map of config key => schema and throws on failure — in every environment, or only locally/staging if production config is owned by ops.
Layer configs with merge() / extend(). merge() merges defaults deeply (your value overwrites, missing keys fall back); extend() is the shallow, key-level variant added in 1.3.2. Use skipDefaults() on the output side when you cache or snapshot config and don't want the defaults bloating the diff.
Replace request validation for deeply nested payloads. Laravel's Validator handles flat forms well; for multi-level JSON it becomes a wall of dot-rules. Pattern: a small trait or base FormRequest that runs the processor over $this->all() in validated() and rethrows as Laravel's ValidationException so your existing exception handler renders the same 422 shape:
try {
return app(Processor::class)->process($this->schema(), $this->all());
} catch (\Nette\Schema\ValidationException $e) {
throw \Illuminate\Validation\ValidationException::withMessages(
collect($e->getMessages())
->mapWithKeys(fn($m) => [$m->path => $m->message])
->all()
);
}
Because ->path is dot-notation, it drops straight into Laravel's error bag and even maps onto old form input for redirects.
Use castTo() for DTOs instead of hand-written hydrators. Expect::structure([...])->castTo(CreateOrder::class) produces a typed, readonly object after validation — perfect for constructor-injecting into actions and queue jobs, and it kills a whole class of “array shape drift” bugs. Cast the leaf structures, not everything, or you will fight array access later.
Keep Laravel and nette/schema in their lanes. Laravel Validator: HTTP forms, exists/unique/confirmed, anything needing DB or session context. nette/schema: config files, webhook payloads, third-party API responses, job payloads — places where the shape is the contract and there is no request. Mixing them per-endpoint is normal, not a smell.
Treat the schema as documentation. Structure::getShape() and Expect::from(SomeClass::class) let you generate a config reference page or an admin form from the same definition you validate with. One source of truth, no drift.
Test against fixtures. A tests/Fixtures/config/*.php matrix run through the processor in a data provider catches config regressions that Laravel's own config cache silently swallows.
Expect::listOf() reindexes. Keys are dropped and values become a sequential list — silent data loss if you fed it an associative array. Use Expect::arrayOf($valueType, $keyType) when keys matter (the key type argument was added in 1.2.0).->otherItems(Expect::mixed()) — do not disable validation to “make it pass”.default() only fires for missing keys. A key present with null will fail the type check unless you add ->nullable(). required() plus default() is a SchemaException (broken schema, not bad data).Nette\Schema\ValidationException = the data is wrong → show the user. Nette\Schema\SchemaException = you wrote the schema wrong → log loudly, never surface. Catch them separately; a schema bug that renders as “Invalid input” is a nightmare to find.code alongside path and message. Map codes to your own copy rather than regexing English text. Dump the codes once from a failing fixture and build an explicit map.AnyOf() must not be empty (enforced since 1.2.0). Guard dynamically built branches with if ($conditions) before calling Expect::anyOf(...).getMessages() or getMessageObjects(), and getMessage() is a flattened string. Pin a version and check both before relying on one in a shared helper.->dynamic() is for nette/di. Dynamic parameters become placeholder objects that a container resolves later. Outside that world it just hands you an unresolved object — don't reach for it.NOASSERTION on Packagist. Nette is normally dual-licensed (BSD-3-Clause / GPL-2.0). Confirm your legal position before shipping it in a closed-source product; it takes five minutes and saves an awkward conversation.Processor is safe to reuse, schemas are cheap to build — but if you build a large schema per request, hoist it into a method or constant so PHPStan and opcache can help you.string() where a Structure was needed, a typo'd key) are caught statically before they become runtime config explosions.Expect::array($shape) returns a Structure since 1.3.2 — the return type changed, so code that type-hinted on ArrayType after that upgrade needs a look.How can I help you explore Laravel packages today?