XF2 Request Validator

grisha2217

Active member
Licensed customer
One area where I think XenForo 2 could really level up its developer experience is request validation.

Right now, validation in XF2 is very controller/service-centric and scattered across different layers. In contrast, Laravel’s Request Validators provide a clean, declarative, and reusable way to define validation rules, authorization logic, and custom messages in a single place: https://laravel.com/docs/12.x/validation

I’d love to see something similar in XF2 — a dedicated “Request Validator” layer where you define:

  • Rules for incoming input
  • Authorization logic for who can perform the action
  • Sanitization and transformation of data
  • Consistent error formatting
This would make code more maintainable, reduce duplication across controllers, and create a clearer separation between validation and business logic.

It could also open the door for better testing, cleaner APIs, and a more modern developer workflow within the XF ecosystem.
 
Upvote 6
PHP:
<?php

namespace XF;

use XF\Mvc\Controller;
use XF\Util\Json;

use function array_filter;
use function array_map;
use function array_values;
use function count;
use function enum_exists;
use function explode;
use function function_exists;
use function in_array;
use function is_array;
use function is_numeric;
use function is_object;
use function is_scalar;
use function is_string;
use function mb_strlen;
use function preg_match;
use function str_contains;
use function str_starts_with;
use function strlen;
use function strtolower;
use function trim;

final readonly class RequestValidator
{
    public function __construct(private Controller $controller)
    {
    }

    /**
     * @param array<string, array<int, mixed>|string> $rules
     *
     * @return array<string, mixed>
     * @throws \XF\Mvc\Reply\Exception
     */
    public function validate(array $rules): array
    {
        $out = [];

        foreach ($rules as $field => $ruleSet) {
            $parsed = $this->parseRuleSet($ruleSet);

            $nullable = $parsed['nullable'];
            $required = $parsed['required'];
            $type = $parsed['type'] ?? 'str';
            $rulesList = $parsed['rules'];

            $value = $this->filterValue($field, $type);

            if ($this->isEmpty($value)) {
                if ($required) {
                    $this->fail($field, "Field '$field' is required.", 400);
                }
                if ($nullable) {
                    $out[$field] = null;
                    continue;
                }
                $out[$field] = $value;
                continue;
            }

            $this->applyRules($field, $value, $type, $rulesList);

            $out[$field] = $value;
        }

        return $out;
    }

    /**
     * @param array<int, mixed>|string $ruleSet
     *
     * @return array{nullable: bool, required: bool, type: null|string, rules: array<int, mixed>}
     */
    private function parseRuleSet(string|array $ruleSet): array
    {
        $parts = is_array($ruleSet) ? $ruleSet : explode('|', $ruleSet);

        $nullable = false;
        $required = false;
        $type = null;
        $rules = [];

        foreach ($parts as $p) {
            if (is_string($p)) {
                $p = trim($p);
                if ($p === '') {
                    continue;
                }

                $pl = strtolower($p);

                if ($pl === 'nullable') {
                    $nullable = true;
                    continue;
                }
                if ($pl === 'required') {
                    $required = true;
                    continue;
                }

                if ($type === null && $this->looksLikeType($p)) {
                    $type = $p;
                    continue;
                }

                $rules[] = $p;
                continue;
            }

            $rules[] = $p;
        }

        return [
            'nullable' => $nullable,
            'required' => $required,
            'type'     => $type,
            'rules'    => $rules,
        ];
    }

    private function looksLikeType(string $p): bool
    {
        $pl = strtolower($p);

        return $pl === 'str'
            || $pl === 'string'
            || $pl === 'int'
            || $pl === 'integer'
            || $pl === 'uint'
            || $pl === 'posint'
            || $pl === 'num'
            || $pl === 'unum'
            || $pl === 'float'
            || $pl === 'bool'
            || $pl === 'boolean'
            || $pl === 'array'
            || $pl === 'json-array'
            || $pl === 'datetime'
            || str_starts_with($pl, 'array-');
    }

    private function filterValue(string $field, string $type): mixed
    {
        $t = strtolower($type);

        if ($t === 'json-array') {
            $raw = $this->controller->filter($field, 'str');
            if ($raw === null || $raw === '') {
                return $raw;
            }
            if (!is_string($raw)) {
                return $raw;
            }

            try {
                $decoded = Json::decode($raw, true);
            } catch (\Throwable) {
                $this->fail($field, "Field '$field' must be a valid JSON array.", 400);
            }

            if (!is_array($decoded)) {
                $this->fail($field, "Field '$field' must be a JSON array.", 400);
            }

            return $decoded;
        }

        if (str_starts_with($t, 'array-')) {
            $sub = trim(substr($t, 6));
            $arr = $this->controller->filter($field, 'array');
            if ($this->isEmpty($arr)) {
                return $arr;
            }
            if (!is_array($arr)) {
                $this->fail($field, "Field '$field' must be an array.", 400);
            }

            $out = [];
            foreach ($arr as $k => $v) {
                $out[$k] = $this->filterScalarLike($field, $v, $sub);
            }

            return $out;
        }

        return $this->controller->filter($field, $type);
    }

    private function filterScalarLike(string $field, mixed $value, string $type): mixed
    {
        $t = strtolower($type);

        if ($value === null || $value === '') {
            return $value;
        }

        if ($t === 'str' || $t === 'string') {
            return (string) $value;
        }

        if ($t === 'bool' || $t === 'boolean') {
            return (bool) $value;
        }

        if ($t === 'float') {
            if (!is_numeric($value)) {
                $this->fail($field, "Field '$field' has invalid value.", 400);
            }
            return (float) $value;
        }

        if ($t === 'int' || $t === 'integer' || $t === 'uint' || $t === 'posint' || $t === 'num' || $t === 'unum') {
            if (!is_numeric($value)) {
                $this->fail($field, "Field '$field' has invalid value.", 400);
            }
            return (int) $value;
        }

        return $value;
    }

    /**
     * @param array<int, mixed> $rules
     */
    private function applyRules(string $field, mixed &$value, string $type, array $rules): void
    {
        foreach ($rules as $r) {
            if (is_object($r)) {
                if ($r instanceof EnumRule) {
                    $this->applyEnumRule($field, $value, $r);
                    continue;
                }

                \XF::logError([
                    'validator' => self::class,
                    'error'     => 'Unknown rule object',
                    'field'     => $field,
                    'ruleClass' => $r::class,
                ]);

                $this->fail($field, "Unknown rule object for '$field'.", 500);
            }

            $name = (string) $r;
            $arg = null;

            if (str_contains($name, ':')) {
                [$name, $arg] = explode(':', $name, 2);
                $name = trim($name);
                $arg = trim($arg);
            }

            $nl = strtolower($name);

            if ($nl === 'in') {
                $allowed = $arg === null
                    ? []
                    : array_values(array_filter(
                        array_map(trim(...), explode(',', $arg)),
                        static fn ($v) => $v !== ''
                    ));

                if (is_array($value)) {
                    foreach ($value as $v) {
                        if (!in_array((string) $v, $allowed, true)) {
                            $this->fail($field, "Field '$field' has invalid value.", 400);
                        }
                    }
                } elseif (!in_array((string) $value, $allowed, true)) {
                    $this->fail($field, "Field '$field' has invalid value.", 400);
                }

                continue;
            }

            if ($nl === 'min' || $nl === 'max') {
                if ($arg === null || !is_numeric($arg)) {
                    \XF::logError([
                        'validator' => self::class,
                        'error'     => 'Invalid rule argument',
                        'field'     => $field,
                        'rule'      => $nl,
                        'arg'       => $arg,
                    ]);

                    $this->fail($field, "Rule '$nl' for '$field' is invalid.", 500);
                }

                $limit = (float) $arg;

                if ($this->isNumericType($type)) {
                    $actual = (float) $value;
                    if ($nl === 'min') {
                        if ($actual < $limit) {
                            $this->fail($field, "Field '$field' is too small.", 400);
                        }
                    } elseif ($actual > $limit) {
                        $this->fail($field, "Field '$field' is too large.", 400);
                    }
                } else {
                    $actual = $this->sizeOf($value);
                    if ($nl === 'min') {
                        if ($actual < (int) $limit) {
                            $this->fail($field, "Field '$field' is too short.", 400);
                        }
                    } elseif ($actual > (int) $limit) {
                        $this->fail($field, "Field '$field' is too long.", 400);
                    }
                }

                continue;
            }

            if ($nl === 'regex') {
                if ($arg === null || $arg === '') {
                    \XF::logError([
                        'validator' => self::class,
                        'error'     => 'Invalid regex rule argument',
                        'field'     => $field,
                    ]);

                    $this->fail($field, "Rule 'regex' for '$field' is invalid.", 500);
                }

                if (!preg_match($arg, (string) $value)) {
                    $this->fail($field, "Field '$field' has invalid format.", 400);
                }

                continue;
            }

            if ($nl === 'distinct') {
                if (is_array($value)) {
                    $seen = [];
                    foreach ($value as $v) {
                        $k = $this->getDistinctKey($v);

                        if (isset($seen[$k])) {
                            $this->fail($field, "Field '$field' must not contain duplicates.", 400);
                        }
                        $seen[$k] = true;
                    }
                }

                continue;
            }

            \XF::logError([
                'validator' => self::class,
                'error'     => 'Unknown rule',
                'field'     => $field,
                'rule'      => $name,
            ]);

            $this->fail($field, "Unknown rule '$name' for '$field'.", 500);
        }
    }

    private function applyEnumRule(string $field, mixed &$value, EnumRule $rule): void
    {
        $enumClass = $rule->enumClass;

        if (!enum_exists($enumClass)) {
            \XF::logError([
                'validator' => self::class,
                'error'     => 'Invalid enum class',
                'field'     => $field,
                'enumClass' => $enumClass,
            ]);

            $this->fail($field, "Enum for '$field' is invalid.", 500);
        }

        if ($value instanceof $enumClass) {
            return;
        }

        if (!is_scalar($value) || $enumClass::tryFrom((string) $value) === null) {
            $this->fail($field, "Field '$field' has invalid enum value.", 400);
        }
    }

    private function isEmpty(mixed $value): bool
    {
        if ($value === null) {
            return true;
        }
        if ($value === '') {
            return true;
        }
        return (bool) (is_array($value) && count($value) === 0);
    }

    private function isNumericType(string $type): bool
    {
        $t = strtolower($type);

        return $t === 'int'
            || $t === 'integer'
            || $t === 'uint'
            || $t === 'posint'
            || $t === 'num'
            || $t === 'unum'
            || $t === 'float';
    }

    private function sizeOf(mixed $value): int
    {
        if (is_array($value)) {
            return count($value);
        }

        return $this->stringLen((string) $value);
    }

    private function stringLen(string $s): int
    {
        if (function_exists('mb_strlen')) {
            return (int) mb_strlen($s, 'UTF-8');
        }
        return strlen($s);
    }

    private function fail(string $field, string $message, int $responseCode): never
    {
        throw $this->controller->exception(
            $this->controller->error($message, $responseCode)
        );
    }

    private function getDistinctKey(mixed $value): string
    {
        if ($value === null) {
            return 'null';
        }

        if (is_string($value)) {
            return 's:' . $value;
        }

        if (is_scalar($value)) {
            return gettype($value) . ':' . $value;
        }

        if (is_array($value)) {
            return 'a:' . json_encode(
                $value,
                JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
            );
        }

        if (is_object($value)) {
            if ($value instanceof \Stringable) {
                return 'o:' . $value::class . ':' . $value;
            }

            return 'o:' . $value::class . '#' . spl_object_id($value);
        }

        return gettype($value);
    }
}


PHP:
<?php

declare(strict_types=1);

use XF\Mvc\Controller;
use XF\RequestValidator;

final class ExampleController extends Controller
{
    public function actionExamples(): \XF\Mvc\Reply\AbstractReply
    {
        $validator = new RequestValidator($this);

        $data = $validator->validate([
            // 1) Required string with length limits
            'title' => 'required|string|min:3|max:120',

            // 2) Nullable string
            'description' => 'nullable|string|max:500',

            // 3) Unsigned integer with range
            'price' => 'required|uint|min:1|max:1000000',

            // 4) Value must be in allowed list
            'status' => 'required|string|in:active,disabled,archived',

            // 5) Boolean flag
            'is_sticky' => 'nullable|bool',

            // 6) Array of integers, at least 1 item, no duplicates
            'ids' => 'required|array-int|min:1|distinct',

            // 7) Array of strings, max 10 items, no duplicates
            'tags' => 'nullable|array-string|max:10|distinct',

            // 8) Slug validated by regex
            'slug' => 'required|string|regex:/^[a-z0-9\-]+$/i|min:3|max:50',

            // 9) JSON array (string input decoded to array)
            'filters' => 'nullable|json-array|min:1|max:50',

            // 10) Combined example (typical payload)
            'category_id' => 'required|uint|min:1',
            'features'    => 'nullable|json-array|max:100',
        ]);

        return $this->message('Validation passed');
    }
}
 
Back
Top Bottom