stougeiro / schema
A lightweight structural gate for PHP payloads. Validates the shape before business logic validates the content.
Fund package maintenance!
Requires
- php: >=8.2
Requires (Dev)
- pestphp/pest: ^3.8
- phpstan/phpstan: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Schema
A lightweight structural gate for PHP payloads. Validates the shape before business logic validates the content.
✨ Features
-
Structural gate pattern:
Validates structure before business logic. A clean, intentional order. -
Simple, explicit schemas:
Define validation rules using native PHP arrays and nestedSchemaobjects. -
Full path error messages:
Errors show exactly where the validation failed:
Invalid required [user.address.zip]: expected [int], got [string] -
Nested schema support:
Validate complex payloads with unlimited depth. -
List of nested schemas:
Use$schema->many()to validate a list of objects against the same structure. -
Optional fields:
Usestring:oor$schema->optional()for optional nested schemas. -
Nullable types:
Prefix any type with?(e.g.,?string). -
Enum & Const validation
enum(a|b|c)andconst(value). -
Strict type matching:
Supports:string,int,float,number,numeric,
bool,boolean,array,list,object,null,
resource,callable,enum(a|b|c),const(value). -
Zero dependencies
Pure PHP. No magic. No framework coupling.
🚦 What Schema Is
Schema validates the structure of your data — not the business rules.
It answers one question: "Is this payload shaped correctly?"
- Does the field exist?
- Is it the right type?
- Is the nesting correct?
It does NOT answer: "Is this value meaningful?"
- Is this email deliverable?
- Is this ID registered?
- Is this amount within limits?
That's a different concern, handled elsewhere.
🏗️ The Gate Pattern
Input → Structural Gate → Business Logic → Output
Structural validation is cheap. Business validation is richer.
Schema runs first — not because it's faster, but because it's the right order. A malformed structure doesn't need business rules. A valid structure is ready for them.
$schema = new Schema([ 'email' => 'string', 'amount' => 'float', ]); // Structure first if ( ! $schema->validate($data, $error)) { return response(422, $error); } // Business logic second $email = EmailService::validate($data['email']);
Schema doesn't replace business validation — it precedes it.
📦 Installation
Install via Composer:
composer require stougeiro/schema
🚀 Usage
Schema supports multiple type validations. Before diving into examples, it's worth understanding the nuances between similar types:
bool vs boolean:
boolis permissive — acceptstrue,false,1,0,"true","false"booleanis strict — accepts onlytrueandfalse
int / float vs number vs numeric:
intandfloatare strict — native types onlynumberaccepts bothintandfloatnumericalso accepts numeric strings like"123"or"3.14"
array vs list:
arrayaccepts any arraylistaccepts only sequential arrays (no gaps in keys)
Simple validation
use STDW\Schema\Schema; $schema = new Schema([ 'name' => 'string', 'age' => 'int', ]); $payload = [ 'name' => 'Sidney', 'age' => 42, ]; $schema->validate($payload); // true
Complex validation
/** * Using the global schema() helper (optional) * * For convenience, you may use the global schema() helper * instead of instantiating Schema manually. * It behaves exactly the same, but makes nested definitions easier to read * */ $schema = schema([ 'id' => 'int', 'name' => 'string', 'email' => 'string', 'status' => 'enum(active|inactive|pending)', 'metadata' => schema([ 'ip' => 'string', 'userAgent' => 'string:o', ])->optional(), ]); $payload = [ 'id' => 1, 'name' => 'Sidney', 'email' => 'sidney@example.com', 'status' => 'active', 'metadata' => [ 'ip' => '127.0.0.1', ], ]; $schema->validate($payload); // true
List of nested schemas
Use many() when the field contains a list of objects that share the same structure.
Combine it with optional() when the list itself is optional.
$schema = schema([ 'users' => schema([ 'id' => 'int', 'name' => 'string', ])->many(), 'tags' => schema([ 'label' => 'string', ])->many()->optional(), ]); $payload = [ 'users' => [ ['id' => 1, 'name' => 'Sidney'], ['id' => 2, 'name' => 'Ana'], ], ]; $schema->validate($payload); // true
Errors include the list index in the path:
$payload['users'][1]['name'] = 123; $schema->validate($payload, $error); echo $error; // Invalid required [users.1.name]: expected [string], got [int]
In development mode, the same error also includes the received value:
got [123 (int)].
A field that expects a nested schema but receives another type fails without throwing an exception:
$schema = schema([ 'snippet' => schema(['log' => 'int:o'])->optional(), ]); $schema->validate(['snippet' => 10], $error); echo $error; // Invalid optional [snippet]: expected [array], got [int]
Optional nested schemas
For a nested schema, optional() means "no content": the key may be absent,
null, or an empty array. Any non-empty array is validated normally.
$schema = schema([ 'profile' => schema([ 'bio' => 'string', ])->optional(), ]); $schema->validate([]); // true $schema->validate(['profile' => null]); // true $schema->validate(['profile' => []]); // true $schema->validate(['profile' => ['bio' => 'x']]); // true
With associative JSON decoding,
{}and[]both become[]in PHP. For optional nested schemas, both are treated as "no content".
This rule applies only to nested schemas. Optional scalars remain strict:
string:o accepts a missing key but rejects null and []. Use ?string:o
when the value may also be null.
🔥 Error Handling
Schema::validate() accepts an optional second parameter by reference that will contain the first validation error message when the payload is invalid:
use STDW\Schema\Schema; $schema = new Schema([ 'name' => 'string', 'age' => 'int', ]); $payload = [ 'name' => 'Sidney', 'age' => '12', ]; if ( ! $schema->validate($payload, $error)) { echo $error; // Invalid required [age]: expected [int], got [string] }
By default, errors are safe for exposition and logging: they include the path, the expected type and the actual type, but never the payload value.
Error verbosity
Development and tests can opt into detailed messages:
use STDW\Schema\Schema; Schema::development(); $schema->validate(['age' => '12'], $error); echo $error; // Invalid required [age]: expected [int], got ['12' (string)]
Schema::development() is process-wide configuration; call it once at
bootstrap, not per request. Production needs no call: redacted is the default.
Simply omit Schema::development() outside development and tests.
When using nested schemas, the error message includes the full path to the failing field:
$schema = new Schema([ 'template' => 'enum(user|product|payment)', 'user' => new Schema([ 'id' => 'int', 'name' => 'string', 'email' => 'string', ]), ]); $payload = [ 'template' => 'user', 'user' => [ 'id' => '12', 'name' => 'Sidney', 'email' => 'sidney@example.com', ], ]; if ( ! $schema->validate($payload, $error)) { echo $error; // Invalid required [user.id]: expected [int], got [string] }
⚡ Performance
Schema is designed to be lightweight. Zero dependencies, minimal overhead, static caching for repeated patterns.
For typical API payloads, validation runs almost instantly — fast enough to be a transparent gate in any request lifecycle.
Schema doesn't aim to be the fastest validator. It aims to be the right first step — structural validation before business validation, at a negligible cost.
Long-running workers
Schema keeps no request state. Its compiled representation depends only on the schema definition, so an instance can be reused safely across requests and coroutines.
- FPM: build the schema where configuration lives (bootstrap or container). It compiles once per instance and is reused within the request.
- Swoole, RoadRunner, Octane and queue workers: build the schema at worker boot and reuse it. Compilation is amortized across every request or job handled by that worker.
Configure error verbosity at the same boot point. The default is
redacted; development and tests call Schema::development().
Do not switch modes per request or coroutine.
No request context is needed. If a schema is created per request, it still works — it simply compiles once per request instead of once per worker.
🧠 Why Schema?
Modern PHP applications pass arrays everywhere — API payloads, configuration blocks, decoded JSON, event messages. PHP offers no native way to validate these structures.
Schema fills that gap — not as a business validator, but as a structural gatekeeper.
Why a gate?
Because structure should be confirmed before business rules apply. A payload with the wrong shape doesn't need business evaluation. It deserves a fast, clear rejection.
What Schema guarantees:
- The payload has the expected fields
- Each field has the expected type
- Nested structures match the expected shape
- Errors point exactly to where the structure breaks
What Schema leaves to you:
- Whether the email is deliverable
- Whether the amount is within limits
- Whether the ID exists in your database
Schema is a gate — it opens for well-shaped data and closes for malformed payloads. What happens after the gate is your domain's responsibility.
🤝 Contributions
Contributions are welcome. Feel free to open issues or submit pull requests.