Search by

stougeiro / schema

stougeiro

A lightweight structural gate for PHP payloads. Validates the shape before business logic validates the content.

Package info

github.com/stougeiro/schema

pkg:composer/stougeiro/schema

Fund package maintenance!

Buymeacoffee

Statistics

Installs: 56

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v3.0.0 2026-10-26 23:08 UTC

This package is auto-updated.

Last update: 2026-10-26 23:14:54 UTC


README

PHP PHPStan-Level Pest-php License

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 nested Schema objects.

  • 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:
    Use string:o or $schema->optional() for optional nested schemas.

  • Nullable types:
    Prefix any type with ? (e.g., ?string).

  • Enum & Const validation
    enum(a|b|c) and const(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:

  • bool is permissive — accepts true, false, 1, 0, "true", "false"
  • boolean is strict — accepts only true and false

int / float vs number vs numeric:

  • int and float are strict — native types only
  • number accepts both int and float
  • numeric also accepts numeric strings like "123" or "3.14"

array vs list:

  • array accepts any array
  • list accepts 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.