$rules) { $rules = preg_split('/\|/', $rules); foreach ($rules as $rule) { if (static::checkRule($haystack->{$target}, $rule)) { continue; } static::$lastFailed = $target . '.' . $rule; return false; } } return true; } /** * Splits a single rule string into its name and its raw argument string. * * The rule name is everything before the first ':'. The remainder is * returned verbatim (it is NOT split further here) so that rules whose * arguments legitimately contain ':' or ',' - such as regex or mimes - * are preserved intact. The caller decides how to split the arguments. * * @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "enum:a,b". * * @return array A two element array: [name, rawArguments]. When the rule * has no parameters, rawArguments is an empty string. */ public static function parseRule(string $rule): array { $separator = strpos($rule, ':'); if ($separator === false) { return [$rule, '']; } return [substr($rule, 0, $separator), substr($rule, $separator + 1)]; } /** * Checks if a rule is met. * * @param mixed $subject The value to verify. * @param string $rule The rule to test. * * @return bool * @throws Exception If the rule is not callable. */ public static function checkRule(mixed $subject, string $rule): bool { [$name, $rawArguments] = static::parseRule($rule); $method = [static::class, $name]; $arguments = array_merge([$subject], static::splitArguments($name, $rawArguments)); if (is_callable($method)) { return call_user_func_array($method, $arguments); } throw new Exception('Bad rule: "' . $name . '"'); } /** * Splits a rule's raw argument string into the arguments to pass to the * rule's method (the subject is not included). * * Most rules take a comma-separated list of values (e.g. "enum:a,b"). * A few rules receive an argument that must be kept intact because it can * legitimately contain commas or colons: "regex" (a pattern) and "not" * (a sub-rule that is itself parsed recursively). An empty argument string * yields no extra arguments. * * @param string $name The rule name. * @param string $raw The raw argument string as returned by parseRule(). * * @return array The list of arguments to pass after the subject. */ private static function splitArguments(string $name, string $raw): array { if ($raw === '') { return []; } $intactRules = ['regex', 'not']; if (in_array($name, $intactRules, true)) { return [$raw]; } return explode(',', $raw); } /** * Verifies the rule in a negative way. * * @param mixed $subject The value to verify. * @param mixed $rule The rule to test. * * @return bool */ public static function not(mixed $subject, ...$rule): bool { return !static::checkRule($subject, join(':', $rule)); } /** * Checks if the value is defined/exists. * * @param mixed $subject The value to check. * * @return bool */ public static function exists(mixed $subject): bool { return isset($subject); } /** * Checks if the value is defined and not empty. * * @param mixed $subject The value to check. * * @return bool */ public static function required(mixed $subject): bool { return isset($subject) && !empty($subject); } /** * Checks if the value is numeric. * * @param mixed $subject The value to check. * * @return bool */ public static function number(mixed $subject): bool { return is_numeric($subject); } /** * Checks if the value is an integer. * * @param mixed $subject The value to check. * * @return bool */ public static function int(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_INT) !== false; } /** * Checks if the value is a float. * * @param mixed $subject The value to check. * * @return bool */ public static function float(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_FLOAT) !== false; } /** * Checks if the value is a boolean. * * @param mixed $subject The value to check. * * @return bool */ public static function bool(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null; } /** * Checks if the value is a string. * * Note this is the strict type check: a PHP integer (e.g. 42) or an array * fails, while any string - including a numeric string like "42" - passes. * * @param mixed $subject The value to check. * * @return bool */ public static function string(mixed $subject): bool { return is_string($subject); } /** * Checks if the value is an array. * * @param mixed $subject The value to check. * * @return bool */ public static function array(mixed $subject): bool { return is_array($subject); } /** * Measures a value for the size-based rules (min/max/between/size). * * A numeric value is measured by its number, a plain string by its * character length, and an array by its element count. This single helper * keeps the size semantics consistent and is where the file case (kilobytes) * is added later. * * @param mixed $subject The value to measure. * * @return float */ private static function measure(mixed $subject): float { if (is_numeric($subject)) { return (float) $subject; } if (is_string($subject)) { return (float) (function_exists('mb_strlen') ? mb_strlen($subject) : strlen($subject)); } if (is_array($subject)) { return (float) count($subject); } return 0.0; } /** * Checks if the measured value is greater than or equal to a minimum. * * @param mixed $subject The value to check. * @param mixed $min The minimum length, number or count. * * @return bool */ public static function min(mixed $subject, mixed $min): bool { return static::measure($subject) >= (float) $min; } /** * Checks if the measured value is less than or equal to a maximum. * * @param mixed $subject The value to check. * @param mixed $max The maximum length, number or count. * * @return bool */ public static function max(mixed $subject, mixed $max): bool { return static::measure($subject) <= (float) $max; } /** * Checks if the measured value is within an inclusive range. * * @param mixed $subject The value to check. * @param mixed $min The lower bound. * @param mixed $max The upper bound. * * @return bool */ public static function between(mixed $subject, mixed $min, mixed $max): bool { $size = static::measure($subject); return $size >= (float) $min && $size <= (float) $max; } /** * Checks if the measured value is exactly a given size. * * @param mixed $subject The value to check. * @param mixed $size The expected length, number or count. * * @return bool */ public static function size(mixed $subject, mixed $size): bool { return static::measure($subject) === (float) $size; } /** * Checks if the value matches a regular expression. * * The pattern is received verbatim (delimiters included), thanks to * splitArguments() treating "regex" as an intact argument, so patterns * that contain ':' or ',' - such as "/^a,b$/" - work as expected. * * @param mixed $subject The value to check. * @param string $pattern A PCRE pattern including its delimiters. * * @return bool */ public static function regex(mixed $subject, mixed $pattern): bool { return preg_match((string) $pattern, (string) $subject) === 1; } /** * Checks if the value is a parseable date. * * Any format accepted by DateTime is valid (e.g. "2026-09-03", an ISO * datetime, or a timestamp string). Empty and non-scalar values fail. * * @param mixed $subject The value to check. * * @return bool */ public static function date(mixed $subject): bool { if (!is_scalar($subject) || trim((string) $subject) === '') { return false; } try { new \DateTime((string) $subject); return true; } catch (Exception $e) { return false; } } /** * Checks if the value is a valid email address. * * @param mixed $subject The value to check. * * @return bool */ public static function email(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_EMAIL) !== false; } /** * Checks if the value is a valid URL. * * @param mixed $subject The value to check. * * @return bool */ public static function url(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_URL) !== false; } /** * Checks if the value is present in a list of allowed values. * * @param mixed $subject The value to check. * @param mixed ...$values A variable number of allowed values. * * @return bool */ public static function enum(mixed $subject, ...$values): bool { return in_array($subject, $values); } }