@@ -0,0 +1,218 @@
|
|||||||
|
# tez-utils-validate
|
||||||
|
|
||||||
|
PHP validation toolkit with guard assertions, composable rules, schema validation, strict type casting, and array-item validation. PHP 8.3+, no framework dependencies.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require tez/utils-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `Guard` | Assertion helpers that throw on failure and return the value on success |
|
||||||
|
| `Rules` | Stateless boolean predicates — no exceptions, no side-effects |
|
||||||
|
| `Composite` | Combines predicates with AND / OR / NONE logic |
|
||||||
|
| `Schema` | Validates an array against a shape definition |
|
||||||
|
| `TypeCast` | Strict type coercion that throws instead of silently truncating |
|
||||||
|
| `Each` | Applies a validation rule to every item in an array |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guard
|
||||||
|
|
||||||
|
Assertions that return the value on success, making them suitable for inline use. Throws `RuntimeException` by default; the exception can be overridden with a class name, an instance, or a closure.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tez\Utils\Validate\Guard;
|
||||||
|
|
||||||
|
// Basic assertions
|
||||||
|
$name = Guard::notNull($request->get('name'));
|
||||||
|
$email = Guard::notEmpty($request->get('email'));
|
||||||
|
$score = Guard::inRange($input, min: 0, max: 100);
|
||||||
|
$user = Guard::instanceOf($entity, User::class);
|
||||||
|
|
||||||
|
// Boolean assertions
|
||||||
|
Guard::isTrue($user->isActive());
|
||||||
|
Guard::isFalse($user->isBanned());
|
||||||
|
|
||||||
|
// Numeric string
|
||||||
|
Guard::isNumeric($request->get('amount'));
|
||||||
|
|
||||||
|
// Custom predicate
|
||||||
|
Guard::that($price > 0, fn() => new DomainException('Price must be positive'));
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom exceptions:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Guard::notNull($value, \InvalidArgumentException::class);
|
||||||
|
Guard::notNull($value, new \DomainException('Value is required'));
|
||||||
|
Guard::notNull($value, fn() => new \DomainException('Value is required'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Stateless boolean predicates. Return `true`/`false` — never throw.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tez\Utils\Validate\Rules;
|
||||||
|
|
||||||
|
Rules::isEmail('user@example.com'); // true
|
||||||
|
Rules::isUrl('https://example.com'); // true
|
||||||
|
Rules::isUrl('http://...', requireHttps: true); // false
|
||||||
|
Rules::isUuid('550e8400-e29b-41d4-...'); // true
|
||||||
|
Rules::isIp('192.168.1.1'); // true
|
||||||
|
Rules::isIpv4('192.168.1.1'); // true
|
||||||
|
Rules::isIpv6('::1'); // true
|
||||||
|
|
||||||
|
Rules::minLength('hello', 3); // true
|
||||||
|
Rules::maxLength('hello', 10); // true
|
||||||
|
Rules::matches('abc123', '/^[a-z0-9]+$/'); // true
|
||||||
|
|
||||||
|
Rules::between(5, 1, 10); // true
|
||||||
|
Rules::isPositive(1); // true
|
||||||
|
Rules::isNegative(-1); // true
|
||||||
|
Rules::notEmpty('hello'); // true
|
||||||
|
|
||||||
|
Rules::isDate('2026-04-23'); // true
|
||||||
|
Rules::isDate('23.04.2026', 'd.m.Y'); // true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composite
|
||||||
|
|
||||||
|
Combines multiple predicates into one using AND, OR, or NONE logic. Each method returns a `Closure` that can be passed directly to `Guard::that()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tez\Utils\Validate\Composite;
|
||||||
|
use Tez\Utils\Validate\Rules;
|
||||||
|
|
||||||
|
$isStrongPassword = Composite::all(
|
||||||
|
fn($v) => Rules::minLength($v, 8),
|
||||||
|
fn($v) => Rules::matches($v, '/[A-Z]/'),
|
||||||
|
fn($v) => Rules::matches($v, '/[0-9]/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$isStrongPassword('Secret42!'); // true
|
||||||
|
$isStrongPassword('weak'); // false
|
||||||
|
|
||||||
|
// OR — passes when at least one rule matches
|
||||||
|
$isEmailOrUuid = Composite::any(
|
||||||
|
fn($v) => Rules::isEmail($v),
|
||||||
|
fn($v) => Rules::isUuid($v),
|
||||||
|
);
|
||||||
|
|
||||||
|
// NONE — passes when no rule matches
|
||||||
|
$isNotReserved = Composite::none(
|
||||||
|
fn($v) => $v === 'admin',
|
||||||
|
fn($v) => $v === 'root',
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
Validates an array against a shape definition. Fails fast on the first violation and reports the dot-notation path.
|
||||||
|
|
||||||
|
**Supported type strings:** `string`, `int`, `float`, `bool`, `array`, `mixed`
|
||||||
|
Prefix with `?` to mark a field as optional (absent or `null` is accepted).
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tez\Utils\Validate\Schema;
|
||||||
|
use Tez\Utils\Validate\Guard;
|
||||||
|
|
||||||
|
Schema::validate($data, [
|
||||||
|
'name' => 'string',
|
||||||
|
'age' => 'int',
|
||||||
|
'active' => 'bool',
|
||||||
|
'email' => '?string', // optional
|
||||||
|
'score' => fn($v) => Guard::inRange($v, 0, 100), // custom rule
|
||||||
|
'address' => [ // nested schema
|
||||||
|
'city' => 'string',
|
||||||
|
'country' => '?string',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
On failure, an `UnexpectedValueException` is thrown with the dot-notation path:
|
||||||
|
|
||||||
|
```
|
||||||
|
address.zip: expected string, got int
|
||||||
|
user.profile.displayName: required field missing
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeCast
|
||||||
|
|
||||||
|
Strict type coercion — throws `UnexpectedValueException` when a value cannot be unambiguously converted, unlike PHP built-ins that silently truncate or guess.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tez\Utils\Validate\TypeCast;
|
||||||
|
|
||||||
|
TypeCast::toInt('42'); // 42
|
||||||
|
TypeCast::toInt(3.0); // 3
|
||||||
|
TypeCast::toInt(3.7); // throws — fractional float
|
||||||
|
|
||||||
|
TypeCast::toFloat('3.14'); // 3.14
|
||||||
|
TypeCast::toString(42); // '42'
|
||||||
|
|
||||||
|
TypeCast::toBool('true'); // true
|
||||||
|
TypeCast::toBool('yes'); // true
|
||||||
|
TypeCast::toBool('off'); // false
|
||||||
|
TypeCast::toBool('maybe'); // throws — ambiguous string
|
||||||
|
|
||||||
|
// Nullable variants — pass through null, convert everything else
|
||||||
|
TypeCast::toNullableInt(null); // null
|
||||||
|
TypeCast::toNullableInt('42'); // 42
|
||||||
|
TypeCast::toNullableFloat(null); // null
|
||||||
|
TypeCast::toNullableString(null); // null
|
||||||
|
TypeCast::toNullableBool(null); // null
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Each
|
||||||
|
|
||||||
|
Applies a validation rule to every item in an array. Fails fast on the first violation and prepends the index path to the exception message.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Tez\Utils\Validate\Each;
|
||||||
|
use Tez\Utils\Validate\Guard;
|
||||||
|
|
||||||
|
$orders = [
|
||||||
|
['amount' => 50],
|
||||||
|
['amount' => -10],
|
||||||
|
];
|
||||||
|
|
||||||
|
Each::validate($orders, function (mixed $order): void {
|
||||||
|
if ($order['amount'] <= 0) {
|
||||||
|
throw new \DomainException('Amount must be positive.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// throws DomainException: "items[1]: Amount must be positive."
|
||||||
|
|
||||||
|
// Custom path label
|
||||||
|
Each::validate($lines, fn($line) => Guard::notEmpty($line), path: 'lines');
|
||||||
|
// throws: "lines[2]: ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
The original exception type is preserved, and the original exception is available via `getPrevious()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.3+
|
||||||
|
- No runtime dependencies
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
Reference in New Issue
Block a user