Understanding these core concepts will help you make the most of the Lemmon Validator library.
The library follows a clean, hierarchical architecture:
Validator (Factory)
↓
FieldValidator (Base Class)
↓
Specific Validators (StringValidator, IntValidator, etc.)
↓
Validation Rules (email(), min(), etc.)
The Validator class is a factory that creates specific validator instances:
use Lemmon\Validator\Validator;
// Factory methods create specific validators
$string = Validator::isString(); // → StringValidator
$int = Validator::isInt(); // → IntValidator
$float = Validator::isFloat(); // → FloatValidator
$array = Validator::isArray(); // → ArrayValidator
$assoc = Validator::isAssociative(); // → AssociativeValidator
$object = Validator::isObject(); // → ObjectValidator
$bool = Validator::isBool(); // → BoolValidatorAll validators extend FieldValidator, which provides:
validate(mixed $value): mixed- Throws exception on failuretryValidate(mixed $value): array- Returns[bool, mixed, array]
required(): static- Makes the field mandatorydefault(mixed $value): static- Sets default value for null inputsdefaultUsing(callable $factory): static- Builds a fresh default for each null inputcoerce(): static- Enables automatic type conversionin(array $values): static- Restricts to specific values (primitive validators only)
satisfies(callable|FieldValidator $rule, ?string $message = null): static- Enhanced custom validation accepting validators or callables
satisfiesAll(array $validations): static- Must pass all validators/callables (replacesallOf())satisfiesAny(array $validations): static- Must pass at least one validator/callable (replacesanyOf())satisfiesNone(array $validations): static- Must NOT pass any validator/callable (replacesnot())
Note: The old allOf(), anyOf(), and not() instance methods are deprecated but maintained for backward compatibility.
Validator::allOf(array $validators)- Creates validator that must pass all validatorsValidator::anyOf(array $validators)- Creates validator that must pass at least one validatorValidator::not(FieldValidator $validator)- Creates validator that must NOT pass the validator
Each validator handles a specific PHP type and provides relevant methods:
Validator::isString()
->notEmpty() // Non-empty string
->minLength(3) // Minimum length
->maxLength(100) // Maximum length
->email() // Email format
->url() // URL format
->pattern('/regex/') // Custom regex
// ... and moreBoth share numeric constraints via NumericConstraintsTrait:
Validator::isInt()
->min(0) // Minimum value
->max(100) // Maximum value
->positive() // Must be > 0
->multipleOf(5) // Must be divisible by 5
Validator::isFloat()
->min(0.0) // Same methods for floats
->max(100.0)
->positive()
->multipleOf(0.01) // Precision controlValidator::isArray() // Plain indexed array
Validator::isArray()->notEmpty() // At least one item
Validator::isArray()->items($itemValidator) // With item validationValidator::isAssociative($schema) // Associative array with schema
Validator::isObject($schema) // stdClass object with schema
// Optional on both: ->coerceAll(), ->passthrough() (keep undeclared keys unvalidated)Understanding the validation flow helps debug and optimize your validators:
- Type Coercion - If enabled, attempt type conversion (empty strings become
nullfor primitives) - Type Validation - Check value type (skipped for null -- lets the pipeline and default/required handle it)
- Pipeline Execution - Validations and transformations run in the order written (fail-fast per field)
- Default - Last-resort fallback: if the result is null and a default exists, apply it
- Required - Single check, always last: if the value is still null after default, fail
default() and required() are both flags -- their position in the fluent chain does not change when they are evaluated. default() always applies after the pipeline as the last-resort fallback for null. required() always enforces presence at the very end, after default has had its chance.
One of the library's most powerful features is the type-aware transformation system that allows you to process and transform data after validation.
The transform() method allows you to change the data type and updates the type context for subsequent operations:
$result = Validator::isString()
->transform(fn($v) => explode(',', $v)) // String → Array (type changes)
->validate('a,b,c'); // Returns: ['a', 'b', 'c']Key Characteristics:
- Changes type context - Updates internal type tracking
- No type coercion - Returns exactly what the transformer produces
- Enables type switching - Subsequent
pipe()operations work with the new type - Null handling -
transform()skips null by default (most type conversions don't need null). Usetransform($fn, skipNull: false)to process null values.
Null Handling Examples:
// Default behavior: skips null (most common case)
$result = Validator::isString()
->transform(fn($v) => DateTime::createFromFormat('Y-m-d', $v))
->validate(null); // Returns: null (transform skipped, no error)
// Explicit null processing: when you need to handle null
$result = Validator::isString()
->transform(fn($v) => $v ?? 'N/A', skipNull: false)
->validate(null); // Returns: 'N/A' (transform executed on null)
// Converting null to empty array
$result = Validator::isArray()
->transform(fn($v) => $v === null ? [] : $v, skipNull: false)
->validate(null); // Returns: [] (null converted to empty array)The pipe() method applies multiple transformations while maintaining the current type context:
$result = Validator::isString()
->pipe('trim', 'strtoupper', fn($v) => str_replace(' ', '-', $v))
->validate(' hello world '); // Returns: "HELLO-WORLD"Key Characteristics:
- Preserves type context - Maintains current type for consistency
- Type-specific coercion - Applies intelligent coercion based on current type
- Multiple operations - Accepts variadic arguments for clean chaining
- Null handling -
pipe()skips null values to avoid type errors for optional fields
The revolutionary aspect is how these methods work together to create intelligent transformation chains:
$result = Validator::isArray()
->pipe('array_unique', 'array_reverse') // Array operations (maintains array type)
->transform(fn($v) => implode(',', $v)) // Array → String (type switches)
->pipe('trim', 'strtoupper') // String operations (works with string)
->transform('strlen') // String → Int (type switches again)
->validate(['a', 'b', 'a']); // Returns: 3What happens internally:
- Initial type:
indexed_array(fromValidator::isArray()) - After
pipe(): Stillindexed_array, but array is processed and reindexed - After first
transform(): Type context switches tostring - After second
pipe(): Stillstring, string operations applied - After final
transform(): Type context switches toint
The pipe() method applies intelligent coercion based on the current type context:
// Array pipe operations automatically reindex when needed
$result = Validator::isArray()
->pipe('array_filter', 'array_unique') // These might break indexing
->validate([1, '', 2, 1, 3]); // Returns: [1, 2, 3] (properly reindexed)
// Associative arrays preserve keys
$result = Validator::isAssociative()
->pipe(fn($v) => array_map('strtoupper', $v)) // Keys preserved
->validate(['name' => 'john', 'city' => 'paris']);
// Returns: ['name' => 'JOHN', 'city' => 'PARIS']Philosophy: The library prioritizes core validation principles over implementing every possible validator. For advanced or specialized validation needs, leveraging external libraries via satisfies() is strongly encouraged. This keeps the library focused on what it does best while enabling you to leverage the entire PHP ecosystem.
The transformation system seamlessly integrates with PHP's ecosystem:
use Illuminate\Support\Str;
$slug = Validator::isString()
->pipe('trim', fn($v) => Str::lower($v)) // Laravel Str integration
->transform(fn($v) => Str::slug($v)) // Create URL slug
->validate(' Hello World '); // Returns: "hello-world"
// Or with Laravel Collections
use Illuminate\Support\Collection;
$processed = Validator::isArray()
->transform(fn($v) => collect($v)) // Array → Collection
->transform(fn($c) => $c->unique()->values()) // Collection operations
->transform(fn($c) => $c->toArray()) // Collection → Array
->validate([1, 2, 2, 3]); // Returns: [1, 2, 3]Critical Concept: The fluent API executes methods in the exact order they are written. This is fundamental to understanding how validation and transformation work together.
Pipeline methods (pipe(), transform(), nullifyEmpty(), satisfies(), etc.) execute in their written order. required() and default() are flags -- their position does not change execution order:
// Execution order: trim → nullifyEmpty
$validator = Validator::isString()
->pipe('trim') // 1. Trim whitespace
->nullifyEmpty(); // 2. Convert empty strings to null
// Input: ' ' (spaces only)
$validator->validate(' '); // Returns: null
// Flow: ' ' → '' → nullThe same methods in different orders produce different results:
// Case 1: trim() BEFORE nullifyEmpty()
$validator1 = Validator::isString()
->pipe('trim') // 1. Trim whitespace
->nullifyEmpty(); // 2. Convert empty strings to null
$result1 = $validator1->validate(' '); // Returns: null
// Case 2: nullifyEmpty() BEFORE trim()
$validator2 = Validator::isString()
->nullifyEmpty() // 1. Convert empty strings to null
->pipe('trim'); // 2. Trim whitespace
$validator2->validate(' '); // Returns: '' (empty string)This execution order is crucial for form validation:
// Common form scenario: trim whitespace, handle empty fields, require non-empty
$nameValidator = Validator::isString()
->pipe('trim') // Remove leading/trailing spaces
->nullifyEmpty() // Empty strings become null
->required('Name is required'); // Reject null values
// Test cases:
$nameValidator->validate('John'); // Returns: "John"
$nameValidator->validate(' John '); // Returns: "John" (trimmed)
$nameValidator->validate(''); // ❌ "Name is required"
$nameValidator->validate(' '); // ❌ "Name is required" (trimmed to empty)Principle of Least Surprise: The fluent API should work exactly as written. When you write:
->pipe('trim')->nullifyEmpty()->required()You expect: trim → nullify → required check, and that's exactly what happens. Both required() and default() are flags evaluated after the pipeline, regardless of where they appear in the chain. default() fills in null as a last resort, then required() enforces presence.
Flexibility: Different orders enable different behaviors for different use cases:
// Scenario A: Trim first, then nullify (whitespace-only becomes null)
->pipe('trim')->nullifyEmpty()
// Scenario B: Nullify first, then trim (whitespace-only becomes empty string)
->nullifyEmpty()->pipe('trim')The library fails fast per validator chain. Schema validation still aggregates errors across fields:
$validator = Validator::isString()
->required()
->minLength(5)
->email();
// Stops at the first failing rule in this chain:
[$valid, $data, $errors] = $validator->tryValidate('ab');
// $errors = [
// 'Value must be at least 5 characters long'
// ]Custom validation functions receive context about the validation:
$validator->satisfies(
function ($value, $key, $input) {
// $value - the current field value
// $key - the field name (in schema validation)
// $input - the full input data (in schema validation)
return $value !== $input['forbidden_value'] ?? null;
},
'Value cannot match the forbidden value'
);Coercion attempts intelligent type conversion:
// String to Int
$intValidator = Validator::isInt()->coerce();
$result = $intValidator->validate('123'); // Returns: 123 (int)
// Array to Object
$objectValidator = Validator::isObject()->coerce();
$result = $objectValidator->validate(['key' => 'value']); // Returns: stdClass
// Object to Array
$arrayValidator = Validator::isAssociative()->coerce();
$obj = new stdClass(); $obj->key = 'value';
$result = $arrayValidator->validate($obj); // Returns: ['key' => 'value']BREAKING CHANGE (v0.6.0): The library now prioritizes real-world form safety over PHP's default type casting behavior.
In traditional PHP type casting, empty strings convert to "falsy" defaults:
(int) ''→0(float) ''→0.0(bool) ''→false
This creates dangerous scenarios in real-world applications:
// ❌ DANGEROUS: Traditional PHP behavior
$bankBalance = (int) $_POST['balance']; // Empty field becomes 0!
$itemQuantity = (int) $_POST['quantity']; // Empty field becomes 0!
$isActive = (bool) $_POST['active']; // Empty checkbox becomes false!The Lemmon Validator treats empty strings as "no value provided" (null) rather than converting to potentially dangerous defaults:
// SAFE: Lemmon Validator behavior
$validator = Validator::isInt()->coerce();
$bankBalance = $validator->validate(''); // Returns: null (not dangerous 0)
$validator = Validator::isFloat()->coerce();
$price = $validator->validate(''); // Returns: null (not dangerous 0.0)
$validator = Validator::isBool()->coerce();
$isActive = $validator->validate(''); // Returns: null (not dangerous false)// Form validation with safe empty string handling
$formValidator = Validator::isAssociative([
'name' => Validator::isString()->required(), // Must be provided
'age' => Validator::isInt()->coerce(), // Empty → null (optional)
'salary' => Validator::isFloat()->coerce(), // Empty → null (safe!)
'active' => Validator::isBool()->coerce(), // Empty → null (optional)
]);
// Safe handling of empty form fields
$formData = [
'name' => 'John Doe',
'age' => '', // Empty form field
'salary' => '', // Empty form field
'active' => '', // Empty checkbox
];
[$valid, $result, $errors] = $formValidator->tryValidate($formData);
// Result: ['name' => 'John Doe', 'age' => null, 'salary' => null, 'active' => null]If you need explicit zero defaults for empty fields, use default():
// If you need zero defaults (rare cases)
$quantity = Validator::isInt()
->coerce()
->default(0) // Explicit zero default
->validate(''); // Returns: 0
// Better: Use nullifyEmpty() for explicit null conversion
$optional = Validator::isInt()
->nullifyEmpty() // Explicit empty → null
->validate(''); // Returns: nullSchema validation works recursively:
$schema = Validator::isAssociative([
'user' => Validator::isObject([
'name' => Validator::isString()->required(),
'contacts' => Validator::isArray()->items(
Validator::isAssociative([
'type' => Validator::isString()->in(['email', 'phone']),
'value' => Validator::isString()->required()
])
)
])
]);Each level validates independently, and errors are collected hierarchically.
- Lazy Evaluation: Validators are only executed when
validate()ortryValidate()is called - Reusable Instances: Validator instances are stateless and can be reused
- Efficient Chaining: Method chaining doesn't create new instances unnecessarily
// Create once, use many times
$emailValidator = Validator::isString()->email();
$email1 = $emailValidator->validate('user1@example.com');
$email2 = $emailValidator->validate('user2@example.com');
// Same validator instance, no recreation overheadNow that you understand the core concepts:
- String Validation Guide -- Master string validation
- Numeric Validation Guide -- Work with numbers
- Object & Schema Validation -- Handle complex structures
- Custom Validation Guide -- Create custom business rules