Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Schema Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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):

  1. The README on GitHub — it is the real documentation and covers ~90% of what you will use.
  2. src/Schema/Expect.php — every factory (string(), int(), arrayOf(), structure(), anyOf(), from(), …) lives in this one readable class.
  3. src/Schema/Processor.php — the entire entry point is process() / processMultiple(). It is stateless, so it is safe to bind as a singleton.
  4. src/Schema/ValidationException.php — how you turn failures into something a user (or your logs) can read.
  5. 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”.

Implementation Patterns

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.

Gotchas and Tips

  • 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).
  • Structures are closed by default. Any key not declared is an error. If you genuinely accept extras, declare it explicitly with ->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).
  • Two exception types, two meanings. 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.
  • Error codes beat string matching. Messages carry a stable 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(...).
  • Version drift in the exception API. Depending on the release you pin, per-message objects come from 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.
  • License is 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.
  • No translations, no Laravel integration, ~50 lines of glue. You own the provider, the exception mapping, and the error copy. Budget for that rather than expecting a config file to appear.
  • 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.
  • Run PHPStan on your schemas. The package itself is clean at high levels; type mismatches in schema definitions (a 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
takashato/laravel-vietqr
aitor24/laralang
kraenzle-ritter/entity-relations
dindigital/din-image
pomirleanu/image-colors
sawmainek/laravel-api-generator
delatbabel/jsonresponses
mouhamedfd/permissions-generator
eduplus/qr
laravel2580/idehelpers
delatbabel/viewpages
anavel/gettext
muhamadrezaar/highcharts
delatbabel/keylists
frankkessler/salesforce-laravel-oauth2-rest
anavel/foundation
wdmtech/enviricons
eii/laravel-captcha
muka/shape-reader
tdt/json