Skip to content

Latest commit

 

History

History
605 lines (488 loc) · 18.2 KB

File metadata and controls

605 lines (488 loc) · 18.2 KB

Object & Schema Validation

This guide covers validation of structured data using AssociativeValidator (for associative arrays) and ObjectValidator (for stdClass objects), both supporting schema-based validation.

Table of Contents

Associative Array Validation

Basic Associative Array Validation

use Lemmon\Validator\Validator;

// Simple associative array without schema
$validator = Validator::isAssociative();
$result = $validator->validate(['key' => 'value']);
// Result: ['key' => 'value']

$result = $validator->validate([]);
// Result: []

// Null is allowed for optional validators
$result = $validator->validate(null);
// Result: null

Schema-Based Validation

// Define schema for user data
$userSchema = Validator::isAssociative([
    'name' => Validator::isString()->required(),
    'age' => Validator::isInt()->min(0)->max(150),
    'email' => Validator::isString()->email()
]);

$userData = [
    'name' => 'John Doe',
    'age' => 30,
    'email' => 'john@example.com'
];

$result = $userSchema->validate($userData);
// Result: ['name' => 'John Doe', 'age' => 30, 'email' => 'john@example.com']

Object Validation

Basic Object Validation

// Validate stdClass objects with schema
$objectSchema = Validator::isObject([
    'name' => Validator::isString(),
    'age' => Validator::isInt()->coerce()
]);

$input = (object)[
    'name' => 'John Doe',
    'age' => '42' // String will be coerced to int
];

$result = $objectSchema->validate($input);
// Result: stdClass object with name='John Doe', age=42

Object Creation from Arrays

// Coerce associative array to stdClass object
$objectSchema = Validator::isObject([
    'name' => Validator::isString(),
    'age' => Validator::isInt()
])->coerce();

$input = [
    'name' => 'Jane Doe',
    'age' => 30
];

$result = $objectSchema->validate($input);
// Result: stdClass object with name='Jane Doe', age=30

Schema Definition

Required Fields

$schema = Validator::isAssociative([
    'required_field' => Validator::isString()->required(),
    'optional_field' => Validator::isString() // Optional by default
]);

// Valid - optional field can be missing
$result = $schema->validate(['required_field' => 'value']);

// Invalid - required field missing
$schema->validate(['optional_field' => 'value']); // Throws ValidationException

Default Values

$schema = Validator::isAssociative([
    'name' => Validator::isString()->required(),
    'status' => Validator::isString()->default('active'),
    'priority' => Validator::isInt()->default(1)
]);

$input = ['name' => 'John'];
$result = $schema->validate($input);
// Result: ['name' => 'John', 'status' => 'active', 'priority' => 1]

Output Key Remapping

Use outputKey() on schema fields to emit validated values under a different key than the input field. This is useful when the input uses snake_case or IDs while the output should use different property names (e.g. for API responses or after transforming IDs to entities).

$schema = Validator::isAssociative([
    'service_id' => Validator::isString()->uuid()->outputKey('service'),
    'user_id' => Validator::isString()->uuid()->outputKey('user'),
]);

$input = [
    'service_id' => '550e8400-e29b-41d4-a716-446655440000',
    'user_id' => '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
];

$result = $schema->validate($input);
// Result: ['service' => '550e8400-...', 'user' => '6ba7b810-...']
// Note: 'service_id' and 'user_id' are not in the result

Works with transform() for ID-to-entity resolution:

$schema = Validator::isAssociative([
    'service_id' => Validator::isString()->uuid()
        ->transform(fn(string $id) => $serviceRepo->find($id))
        ->outputKey('service'),
]);

Works with both Validator::isAssociative() and Validator::isObject().

Field Inclusion Behavior

By default, schema validators only include fields in the result that were either:

  1. Provided in the input data, or
  2. Have default values applied

Fields that are not provided and don't have defaults are not included in the result:

$schema = Validator::isAssociative([
    'name' => Validator::isString()->required(),
    'email' => Validator::isString()->email(),           // Optional, no default
    'status' => Validator::isString()->default('active'), // Has default
    'age' => Validator::isInt(),                         // Optional, no default
    'role' => Validator::isString()->default('user')     // Has default
]);

// Only provide name and email
$input = [
    'name' => 'John Doe',
    'email' => 'john@example.com'
];

$result = $schema->validate($input);
// Result: [
//     'name' => 'John Doe',      // Provided in input
//     'email' => 'john@example.com', // Provided in input
//     'status' => 'active',      // Default value applied
//     'role' => 'user'           // Default value applied
// ]
// Note: 'age' is NOT included (not provided, no default)

This behavior ensures that:

  • Results accurately reflect the validated data without unexpected properties (unless you opt into passthrough() below)
  • Default values are consistently applied when fields are missing
  • Required field validation still works (missing required fields cause validation to fail)

Passthrough (undeclared keys)

Call passthrough() on a schema-backed Validator::isAssociative() or Validator::isObject() to copy input keys that are not declared in the schema onto the validated output without validating them. Declared fields are still run through their validators. Values already written from the schema (including keys produced by outputKey()) are not overwritten by passthrough.

Passthrough runs only after all schema fields validate successfully. Copied values are shallow: nested arrays or objects are not recursively validated.

$schema = Validator::isAssociative([
    'name' => Validator::isString()->required(),
])->passthrough();

$input = [
    'name' => 'Ann',
    'metadata' => ['any' => 'structure'],
    'version' => 1,
];

$result = $schema->validate($input);
// Result includes name (validated) plus metadata and version (copied as-is)

With an empty schema, passthrough() keeps the entire map (still subject to array/object type rules and coercion on the container itself). That pattern is useful for optional opaque payloads such as API metadata:

$result = Validator::isAssociative([])->passthrough()
    ->validate(['foo' => 1, 'nested' => ['x' => true]]);
// $result is the same associative shape, unvalidated

For ObjectValidator, only public properties on the input object are considered for passthrough (same visibility as get_object_vars()).

Nested Schemas

$userSchema = Validator::isAssociative([
    'name' => Validator::isString()->required(),
    'profile' => Validator::isAssociative([
        'bio' => Validator::isString(),
        'website' => Validator::isString()->url()
    ]),
    'preferences' => Validator::isObject([
        'theme' => Validator::isString()->in(['light', 'dark'])->default('light'),
        'notifications' => Validator::isBool()->default(true)
    ])->coerce()
]);

$userData = [
    'name' => 'John Doe',
    'profile' => [
        'bio' => 'Software developer',
        'website' => 'https://johndoe.dev'
    ],
    'preferences' => [
        'theme' => 'dark',
        'notifications' => false
    ]
];

$result = $userSchema->validate($userData);

Type Coercion

Individual Field Coercion

$schema = Validator::isAssociative([
    'id' => Validator::isInt()->coerce(), // String to int
    'active' => Validator::isBool()->coerce(), // String to bool
    'price' => Validator::isFloat()->coerce() // String to float
]);

$input = [
    'id' => '123',
    'active' => 'true',
    'price' => '19.99'
];

$result = $schema->validate($input);
// Result: ['id' => 123, 'active' => true, 'price' => 19.99]

Global Coercion with coerceAll()

$schema = Validator::isAssociative([
    'level' => Validator::isInt()->in([3, 5, 8]),
    'override' => Validator::isBool()
])->coerceAll(); // Recursively enables coercion for all fields, nested schemas, and array items

$input = [
    'level' => '5', // String coerced to int
    'override' => 'false' // String coerced to bool
];

$result = $schema->validate($input);
// Result: ['level' => 5, 'override' => false]

Scope: coerceAll() propagates through schema fields, nested schemas, and array item validators. It does not reach into assertion operands -- validators passed to satisfies(), satisfiesAll(), satisfiesAny(), satisfiesNone(), or contains(). Call coerce() on those validators individually when needed.

Object-Array Coercion

// AssociativeValidator can coerce objects to arrays
$arraySchema = Validator::isAssociative([
    'name' => Validator::isString()
])->coerce();

$object = new stdClass();
$object->name = 'John Doe';

$result = $arraySchema->validate($object);
// Result: ['name' => 'John Doe']

// ObjectValidator can coerce arrays to objects
$objectSchema = Validator::isObject([
    'name' => Validator::isString()
])->coerce();

$array = ['name' => 'Jane Doe'];
$result = $objectSchema->validate($array);
// Result: stdClass object with name='Jane Doe'

Advanced Features

Conditional Validation

$schema = Validator::isAssociative([
    'type' => Validator::isString()->in(['user', 'admin'])->required(),
    'permissions' => Validator::isArray()->satisfies(
        function ($value, $key, $input) {
            // Only require permissions for admin users
            if ($input['type'] === 'admin') {
                return !empty($value);
            }
            return true;
        },
        'Admin users must have permissions'
    )
]);

Complex Validation Rules

$productSchema = Validator::isAssociative([
    'name' => Validator::isString()->required()->minLength(3),
    'price' => Validator::isFloat()->positive()->multipleOf(0.01),
    'category' => Validator::isString()->in(['electronics', 'clothing', 'books']),
    'tags' => Validator::isArray()->items(Validator::isString()),
    'metadata' => Validator::isAssociative([
        'weight' => Validator::isFloat()->positive(),
        'dimensions' => Validator::isAssociative([
            'length' => Validator::isFloat()->positive(),
            'width' => Validator::isFloat()->positive(),
            'height' => Validator::isFloat()->positive()
        ])
    ])
]);

Common Patterns

API Request Validation

// Validate API request payload
$createUserRequest = Validator::isAssociative([
    'username' => Validator::isString()
        ->required()
        ->minLength(3)
        ->maxLength(20)
        ->pattern('/^[a-zA-Z0-9_]+$/', 'Username can only contain letters, numbers, and underscores'),
    'email' => Validator::isString()->email()->required(),
    'password' => Validator::isString()->minLength(8)->required(),
    'profile' => Validator::isAssociative([
        'first_name' => Validator::isString()->required(),
        'last_name' => Validator::isString()->required(),
        'bio' => Validator::isString()->maxLength(500)
    ])
]);

Configuration Validation

// Application configuration schema
$configSchema = Validator::isAssociative([
    'app' => Validator::isAssociative([
        'name' => Validator::isString()->required(),
        'debug' => Validator::isBool()->default(false),
        'timezone' => Validator::isString()->default('UTC')
    ]),
    'database' => Validator::isAssociative([
        'host' => Validator::isString()->required(),
        'port' => Validator::isInt()->min(1)->max(65535)->default(3306),
        'username' => Validator::isString()->required(),
        'password' => Validator::isString()->required(),
        'database' => Validator::isString()->required()
    ]),
    'cache' => Validator::isAssociative([
        'driver' => Validator::isString()->in(['redis', 'memcached', 'file'])->default('file'),
        'ttl' => Validator::isInt()->positive()->default(3600)
    ])
]);

Form Data Validation

// Contact form validation
$contactFormSchema = Validator::isAssociative([
    'name' => Validator::isString()->required()->pipe('trim'),
    'email' => Validator::isString()->email()->required(),
    'subject' => Validator::isString()->required()->maxLength(100),
    'message' => Validator::isString()->required()->minLength(10)->maxLength(1000),
    'newsletter' => Validator::isBool()->default(false),
    'terms' => Validator::isBool()->satisfies(
        fn($value) => $value === true,
        'You must accept the terms and conditions'
    )
]);

Error Handling

Schema Validation Errors

use Lemmon\Validator\ValidationException;

$schema = Validator::isAssociative([
    'name' => Validator::isString()->required(),
    'age' => Validator::isInt()->min(18)
]);

$input = [
    'age' => 16 // Invalid: too young
    // Missing required 'name' field
];

try {
    $schema->validate($input);
} catch (ValidationException $e) {
    print_r($e->getErrors());
    // Output:
    // [
    //     'name' => ['Value is required'],
    //     'age' => ['Value must be at least 18']
    // ]
}

Nested Error Handling

$nestedSchema = Validator::isAssociative([
    'user' => Validator::isAssociative([
        'profile' => Validator::isAssociative([
            'email' => Validator::isString()->email()->required()
        ])
    ])
]);

$input = [
    'user' => [
        'profile' => [
            'email' => 'invalid-email'
        ]
    ]
];

try {
    $nestedSchema->validate($input);
} catch (ValidationException $e) {
    print_r($e->getErrors());
    // Nested error structure reflects the schema structure
}

Using tryValidate for Graceful Error Handling

$schema = Validator::isAssociative([
    'items' => Validator::isArray()->items(Validator::isInt())
]);

[$valid, $result, $errors] = $schema->tryValidate([
    'items' => ['1', 'invalid', '3']
]);

if (!$valid) {
    echo "Validation failed:\n";
    foreach ($errors as $field => $fieldErrors) {
        echo "$field: " . implode(', ', $fieldErrors) . "\n";
    }
} else {
    echo "Validation successful:\n";
    print_r($result);
}

Flattened Errors for API Responses

For API consumption, you can flatten the nested error structure:

use Lemmon\Validator\ValidationException;

// With exceptions
try {
    $schema->validate($input);
} catch (ValidationException $e) {
    $flattened = $e->getFlattenedErrors();
    // Returns: [
    //     ['path' => 'name', 'message' => 'Value is required'],
    //     ['path' => 'user.profile.email', 'message' => 'Value must be a valid email address']
    // ]
}

// With tryValidate
[$valid, $data, $errors] = $schema->tryValidate($input);
if (!$valid) {
    $flattened = ValidationException::flattenErrors($errors);
    // Same format as above
}

See the Error Handling Guide for complete documentation on flattened errors.

Advanced Examples

Multi-Step Form Validation

// Registration form with multiple steps
$step1Schema = Validator::isAssociative([
    'email' => Validator::isString()->email()->required(),
    'password' => Validator::isString()->minLength(8)->required(),
    'password_confirm' => Validator::isString()->satisfies(
        function ($value, $key, $input) {
            return isset($input['password']) && $value === $input['password'];
        },
        'Password confirmation must match password'
    )
]);

$step2Schema = Validator::isAssociative([
    'first_name' => Validator::isString()->required(),
    'last_name' => Validator::isString()->required(),
    'birth_date' => Validator::isString()->date('Y-m-d')->required()
]);

$step3Schema = Validator::isAssociative([
    'company' => Validator::isString(),
    'job_title' => Validator::isString(),
    'industry' => Validator::isString()->in([
        'technology', 'finance', 'healthcare', 'education', 'other'
    ])
]);

E-commerce Product Schema

$productSchema = Validator::isAssociative([
    'basic_info' => Validator::isAssociative([
        'name' => Validator::isString()->required()->minLength(3)->maxLength(100),
        'description' => Validator::isString()->required()->maxLength(2000),
        'sku' => Validator::isString()->required()->pattern('/^[A-Z0-9-]+$/'),
        'brand' => Validator::isString()->required()
    ]),
    'pricing' => Validator::isAssociative([
        'price' => Validator::isFloat()->positive()->multipleOf(0.01)->required(),
        'sale_price' => Validator::isFloat()->positive()->multipleOf(0.01),
        'currency' => Validator::isString()->in(['USD', 'EUR', 'GBP'])->default('USD')
    ]),
    'inventory' => Validator::isAssociative([
        'stock_quantity' => Validator::isInt()->min(0)->required(),
        'track_inventory' => Validator::isBool()->default(true),
        'allow_backorder' => Validator::isBool()->default(false)
    ]),
    'shipping' => Validator::isAssociative([
        'weight' => Validator::isFloat()->positive(),
        'dimensions' => Validator::isAssociative([
            'length' => Validator::isFloat()->positive(),
            'width' => Validator::isFloat()->positive(),
            'height' => Validator::isFloat()->positive()
        ]),
        'shipping_class' => Validator::isString()->in(['standard', 'heavy', 'fragile'])
    ]),
    'seo' => Validator::isAssociative([
        'meta_title' => Validator::isString()->maxLength(60),
        'meta_description' => Validator::isString()->maxLength(160),
        'slug' => Validator::isString()->pattern('/^[a-z0-9-]+$/')
    ])
]);

Next Steps