• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 25333579552

04 May 2026 05:35PM UTC coverage: 88.271% (-0.003%) from 88.274%
25333579552

Pull #10158

github

web-flow
Merge ed9d8b6f2 into 3cdb4c5e1
Pull Request #10158: feat: add typed FormRequest accessors

54 of 62 new or added lines in 1 file covered. (87.1%)

41 existing lines in 4 files now uncovered.

23541 of 26669 relevant lines covered (88.27%)

218.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.38
/system/Validation/Validation.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Validation;
15

16
use Closure;
17
use CodeIgniter\Database\BaseConnection;
18
use CodeIgniter\Exceptions\InvalidArgumentException;
19
use CodeIgniter\Exceptions\LogicException;
20
use CodeIgniter\HTTP\Exceptions\HTTPException;
21
use CodeIgniter\HTTP\IncomingRequest;
22
use CodeIgniter\HTTP\Method;
23
use CodeIgniter\HTTP\RequestInterface;
24
use CodeIgniter\Validation\Exceptions\ValidationException;
25
use CodeIgniter\View\RendererInterface;
26

27
/**
28
 * Validator
29
 *
30
 * @see \CodeIgniter\Validation\ValidationTest
31
 */
32
class Validation implements ValidationInterface
33
{
34
    /**
35
     * Files to load with validation functions.
36
     *
37
     * @var array
38
     */
39
    protected $ruleSetFiles;
40

41
    /**
42
     * The loaded instances of our validation files.
43
     *
44
     * @var array
45
     */
46
    protected $ruleSetInstances = [];
47

48
    /**
49
     * Stores the actual rules that should be run against $data.
50
     *
51
     * @var array<array-key, array{label?: string, rules: list<string>}>
52
     *
53
     * [
54
     *     field1 => [
55
     *         'label' => label,
56
     *         'rules' => [
57
     *              rule1, rule2, ...
58
     *          ],
59
     *     ],
60
     * ]
61
     */
62
    protected $rules = [];
63

64
    /**
65
     * The data that should be validated,
66
     * where 'key' is the alias, with value.
67
     *
68
     * @var array
69
     */
70
    protected $data = [];
71

72
    /**
73
     * The data that was actually validated.
74
     *
75
     * @var array
76
     */
77
    protected $validated = [];
78

79
    /**
80
     * Any generated errors during validation.
81
     * 'key' is the alias, 'value' is the message.
82
     *
83
     * @var array
84
     */
85
    protected $errors = [];
86

87
    /**
88
     * Stores custom error message to use
89
     * during validation. Where 'key' is the alias.
90
     *
91
     * @var array
92
     */
93
    protected $customErrors = [];
94

95
    /**
96
     * Our configuration.
97
     *
98
     * @var object{ruleSets: list<class-string>}
99
     */
100
    protected $config;
101

102
    /**
103
     * The view renderer used to render validation messages.
104
     *
105
     * @var RendererInterface
106
     */
107
    protected $view;
108

109
    /**
110
     * Validation constructor.
111
     *
112
     * @param object{ruleSets: list<class-string>} $config
113
     */
114
    public function __construct($config, RendererInterface $view)
115
    {
116
        $this->ruleSetFiles = $config->ruleSets;
1,835✔
117

118
        $this->config = $config;
1,835✔
119

120
        $this->view = $view;
1,835✔
121

122
        $this->loadRuleSets();
1,835✔
123
    }
124

125
    /**
126
     * Runs the validation process, returning true/false determining whether
127
     * validation was successful or not.
128
     *
129
     * @param array|null                                 $data    The array of data to validate.
130
     * @param string|null                                $group   The predefined group of rules to apply.
131
     * @param array|BaseConnection|non-empty-string|null $dbGroup The database group to use.
132
     */
133
    public function run(?array $data = null, ?string $group = null, $dbGroup = null): bool
134
    {
135
        if ($data === null) {
1,699✔
136
            $data = $this->data;
15✔
137
        } else {
138
            // Store data to validate.
139
            $this->data = $data;
1,684✔
140
        }
141

142
        // `DBGroup` is a reserved name. For is_unique and is_not_unique
143
        $data['DBGroup'] = $dbGroup;
1,699✔
144

145
        $this->loadRuleGroup($group);
1,699✔
146

147
        // If no rules exist, we return false to ensure
148
        // the developer didn't forget to set the rules.
149
        if ($this->rules === []) {
1,695✔
150
            return false;
5✔
151
        }
152

153
        // Replace any placeholders (e.g. {id}) in the rules with
154
        // the value found in $data, if any.
155
        $this->rules = $this->fillPlaceholders($this->rules, $data);
1,690✔
156

157
        // Need this for searching arrays in validation.
158
        helper('array');
1,688✔
159

160
        // Run through each rule. If we have any field set for
161
        // this rule, then we need to run them through!
162
        foreach ($this->rules as $field => $setup) {
1,688✔
163
            //  An array key might be int.
164
            $field = (string) $field;
1,688✔
165

166
            $rules = $setup['rules'];
1,688✔
167

168
            if (is_string($rules)) {
1,688✔
169
                $rules = $this->splitRules($rules);
×
170
            }
171

172
            if (str_contains($field, '*')) {
1,688✔
173
                $flattenedArray = array_flatten_with_dots($data);
79✔
174

175
                $values = array_filter(
79✔
176
                    $flattenedArray,
79✔
177
                    static fn ($key): bool => preg_match(self::getRegex($field), $key) === 1,
79✔
178
                    ARRAY_FILTER_USE_KEY,
79✔
179
                );
79✔
180

181
                // Emit null for every leaf path that is structurally reachable
182
                // but whose key is absent from the data. This mirrors the
183
                // non-wildcard behaviour where a missing key is treated as null,
184
                // so that all rules behave consistently regardless of whether
185
                // the field uses a wildcard or not.
186
                foreach ($this->walkForAllPossiblePaths(explode('.', $field), $data, '') as $path) {
79✔
187
                    $values[$path] = null;
25✔
188
                }
189

190
                // if keys not found
191
                $values = $values !== [] ? $values : [$field => null];
79✔
192
            } else {
193
                $values = dot_array_search($field, $data);
1,621✔
194
            }
195

196
            if ($values === []) {
1,688✔
197
                // We'll process the values right away if an empty array
198
                $this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data, $field);
14✔
199

200
                continue;
14✔
201
            }
202

203
            if (str_contains($field, '*')) {
1,674✔
204
                // Process multiple fields
205
                foreach ($values as $dotField => $value) {
79✔
206
                    $this->processRules($dotField, $setup['label'] ?? $field, $value, $rules, $data, $field);
79✔
207
                }
208
            } else {
209
                // Process single field
210
                $this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data, $field);
1,607✔
211
            }
212
        }
213

214
        if ($this->getErrors() === []) {
1,677✔
215
            // Store data that was actually validated.
216
            $this->validated = DotArrayFilter::run(
944✔
217
                array_keys($this->rules),
944✔
218
                $this->data,
944✔
219
            );
944✔
220

221
            return true;
944✔
222
        }
223

224
        return false;
744✔
225
    }
226

227
    /**
228
     * Returns regex pattern for key with dot array syntax.
229
     */
230
    private static function getRegex(string $field): string
231
    {
232
        return '/\A'
135✔
233
            . str_replace(
135✔
234
                ['\.\*', '\*\.'],
135✔
235
                ['\.[^.]+', '[^.]+\.'],
135✔
236
                preg_quote($field, '/'),
135✔
237
            )
135✔
238
            . '\z/';
135✔
239
    }
240

241
    /**
242
     * Runs the validation process, returning true or false determining whether
243
     * validation was successful or not.
244
     *
245
     * @param array|bool|float|int|object|string|null $value   The data to validate.
246
     * @param array|string                            $rules   The validation rules.
247
     * @param list<string>                            $errors  The custom error message.
248
     * @param string|null                             $dbGroup The database group to use.
249
     */
250
    public function check($value, $rules, array $errors = [], $dbGroup = null): bool
251
    {
252
        $this->reset();
35✔
253

254
        return $this->setRule(
35✔
255
            'check',
35✔
256
            null,
35✔
257
            $rules,
35✔
258
            $errors,
35✔
259
        )->run(
35✔
260
            ['check' => $value],
35✔
261
            null,
35✔
262
            $dbGroup,
35✔
263
        );
35✔
264
    }
265

266
    /**
267
     * Returns the actual validated data.
268
     */
269
    public function getValidated(): array
270
    {
271
        return $this->validated;
57✔
272
    }
273

274
    /**
275
     * Runs all of $rules against $field, until one fails, or
276
     * all of them have been processed. If one fails, it adds
277
     * the error to $this->errors and moves on to the next,
278
     * so that we can collect all of the first errors.
279
     *
280
     * @param array|string $value
281
     * @param array        $rules
282
     * @param array        $data          The array of data to validate, with `DBGroup`.
283
     * @param string|null  $originalField The original asterisk field name like "foo.*.bar".
284
     */
285
    protected function processRules(
286
        string $field,
287
        ?string $label,
288
        $value,
289
        $rules = null,       // @TODO remove `= null`
290
        ?array $data = null, // @TODO remove `= null`
291
        ?string $originalField = null,
292
    ): bool {
293
        if ($data === null) {
1,688✔
UNCOV
294
            throw new InvalidArgumentException('You must supply the parameter: data.');
×
295
        }
296

297
        $rules = $this->processIfExist($field, $rules, $data);
1,688✔
298
        if ($rules === true) {
1,688✔
299
            return true;
6✔
300
        }
301

302
        $rules = $this->processPermitEmpty($value, $rules, $data);
1,684✔
303
        if ($rules === true) {
1,684✔
304
            return true;
62✔
305
        }
306

307
        foreach ($rules as $i => $rule) {
1,648✔
308
            $isCallable     = is_callable($rule);
1,645✔
309
            $stringCallable = $isCallable && is_string($rule);
1,645✔
310
            $arrayCallable  = $isCallable && is_array($rule);
1,645✔
311

312
            $passed = false;
1,645✔
313
            /** @var string|null $param */
314
            $param = null;
1,645✔
315

316
            if (! $isCallable && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
1,645✔
317
                $rule  = $match[1];
1,085✔
318
                $param = $match[2];
1,085✔
319
            }
320

321
            // Placeholder for custom errors from the rules.
322
            $error = null;
1,645✔
323

324
            // If it's a callable, call and get out of here.
325
            if ($this->isClosure($rule)) {
1,645✔
326
                $passed = $rule($value, $data, $error, $field);
20✔
327
            } elseif ($isCallable) {
1,637✔
328
                $passed = $stringCallable ? $rule($value) : $rule($value, $data, $error, $field);
44✔
329
            } else {
330
                $found = false;
1,595✔
331

332
                // Check in our rulesets
333
                foreach ($this->ruleSetInstances as $set) {
1,595✔
334
                    if (! method_exists($set, $rule)) {
1,595✔
335
                        continue;
1,011✔
336
                    }
337

338
                    $found = true;
1,593✔
339

340
                    if ($rule === 'field_exists') {
1,593✔
341
                        $passed = $set->{$rule}($value, $param, $data, $error, $originalField);
24✔
342
                    } else {
343
                        $passed = ($param === null)
1,569✔
344
                            ? $set->{$rule}($value, $error)
576✔
345
                            : $set->{$rule}($value, $param, $data, $error, $field);
1,085✔
346
                    }
347

348
                    break;
1,584✔
349
                }
350

351
                // If the rule wasn't found anywhere, we
352
                // should throw an exception so the developer can find it.
353
                if (! $found) {
1,586✔
354
                    throw ValidationException::forRuleNotFound($rule);
2✔
355
                }
356
            }
357

358
            // Set the error message if we didn't survive.
359
            if ($passed === false) {
1,634✔
360
                // if the $value is an array, convert it to as string representation
361
                if (is_array($value)) {
744✔
362
                    $value = $this->isStringList($value)
17✔
363
                        ? '[' . implode(', ', $value) . ']'
10✔
364
                        : json_encode($value);
7✔
365
                } elseif (is_object($value)) {
727✔
366
                    $value = json_encode($value);
2✔
367
                }
368

369
                $fieldForErrors = ($rule === 'field_exists') ? $originalField : $field;
744✔
370

371
                // @phpstan-ignore-next-line $error may be set by rule methods.
372
                $this->errors[$fieldForErrors] = $error !== null
744✔
373
                    ? $this->parseErrorMessage($error, $field, $label, $param, (string) $value)
14✔
374
                    : $this->getErrorMessage(
730✔
375
                        ($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule,
730✔
376
                        $field,
730✔
377
                        $label,
730✔
378
                        $param,
730✔
379
                        (string) $value,
730✔
380
                        $originalField,
730✔
381
                    );
730✔
382

383
                return false;
744✔
384
            }
385
        }
386

387
        return true;
953✔
388
    }
389

390
    /**
391
     * @param array $data The array of data to validate, with `DBGroup`.
392
     *
393
     * @return array|true The modified rules or true if we return early
394
     */
395
    private function processIfExist(string $field, array $rules, array $data)
396
    {
397
        if (in_array('if_exist', $rules, true)) {
1,688✔
398
            $flattenedData = array_flatten_with_dots($data);
26✔
399
            $ifExistField  = $field;
26✔
400

401
            if (str_contains($field, '.*')) {
26✔
402
                // We'll change the dot notation into a PCRE pattern that can be used later
UNCOV
403
                $ifExistField   = str_replace('\.\*', '\.(?:[^\.]+)', preg_quote($field, '/'));
×
UNCOV
404
                $dataIsExisting = false;
×
UNCOV
405
                $pattern        = sprintf('/%s/u', $ifExistField);
×
406

UNCOV
407
                foreach (array_keys($flattenedData) as $item) {
×
UNCOV
408
                    if (preg_match($pattern, $item) === 1) {
×
UNCOV
409
                        $dataIsExisting = true;
×
UNCOV
410
                        break;
×
411
                    }
412
                }
413
            } elseif (str_contains($field, '.')) {
26✔
414
                $dataIsExisting = array_key_exists($ifExistField, $flattenedData);
18✔
415
            } else {
416
                $dataIsExisting = array_key_exists($ifExistField, $data);
8✔
417
            }
418

419
            if (! $dataIsExisting) {
26✔
420
                // we return early if `if_exist` is not satisfied. we have nothing to do here.
421
                return true;
6✔
422
            }
423

424
            // Otherwise remove the if_exist rule and continue the process
425
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'if_exist');
22✔
426
        }
427

428
        return $rules;
1,684✔
429
    }
430

431
    /**
432
     * @param array|string $value
433
     * @param array        $data  The array of data to validate, with `DBGroup`.
434
     *
435
     * @return array|true The modified rules or true if we return early
436
     */
437
    private function processPermitEmpty($value, array $rules, array $data)
438
    {
439
        if (in_array('permit_empty', $rules, true)) {
1,684✔
440
            if (
441
                ! in_array('required', $rules, true)
148✔
442
                && (is_array($value) ? $value === [] : trim((string) $value) === '')
148✔
443
            ) {
444
                $passed = true;
76✔
445

446
                foreach ($rules as $rule) {
76✔
447
                    if (! $this->isClosure($rule) && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
76✔
448
                        $rule  = $match[1];
50✔
449
                        $param = $match[2];
50✔
450

451
                        if (! in_array($rule, ['required_with', 'required_without'], true)) {
50✔
452
                            continue;
20✔
453
                        }
454

455
                        // Check in our rulesets
456
                        foreach ($this->ruleSetInstances as $set) {
30✔
457
                            if (! method_exists($set, $rule)) {
30✔
UNCOV
458
                                continue;
×
459
                            }
460

461
                            $passed = $passed && $set->{$rule}($value, $param, $data);
30✔
462
                            break;
30✔
463
                        }
464
                    }
465
                }
466

467
                if ($passed) {
76✔
468
                    return true;
62✔
469
                }
470
            }
471

472
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'permit_empty');
90✔
473
        }
474

475
        return $rules;
1,648✔
476
    }
477

478
    /**
479
     * @param Closure(bool|float|int|list<mixed>|object|string|null, bool|float|int|list<mixed>|object|string|null, string|null, string|null): (bool|string) $rule
480
     */
481
    private function isClosure($rule): bool
482
    {
483
        return $rule instanceof Closure;
1,681✔
484
    }
485

486
    /**
487
     * Is the array a string list `list<string>`?
488
     */
489
    private function isStringList(array $array): bool
490
    {
491
        $expectedKey = 0;
17✔
492

493
        foreach ($array as $key => $val) {
17✔
494
            // Note: also covers PHP array key conversion, e.g. '5' and 5.1 both become 5
495
            if (! is_int($key)) {
7✔
496
                return false;
1✔
497
            }
498

499
            if ($key !== $expectedKey) {
6✔
UNCOV
500
                return false;
×
501
            }
502
            $expectedKey++;
6✔
503

504
            if (! is_string($val)) {
6✔
505
                return false;
6✔
506
            }
507
        }
508

509
        return true;
10✔
510
    }
511

512
    /**
513
     * Takes a Request object and grabs the input data to use from its
514
     * array values.
515
     */
516
    public function withRequest(RequestInterface $request): ValidationInterface
517
    {
518
        /** @var IncomingRequest $request */
519
        if (str_contains($request->getHeaderLine('Content-Type'), 'application/json')) {
25✔
520
            $this->data = $request->getJSON(true);
8✔
521

522
            if (! is_array($this->data)) {
6✔
523
                throw HTTPException::forUnsupportedJSONFormat();
2✔
524
            }
525

526
            return $this;
4✔
527
        }
528

529
        if (in_array($request->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true)
17✔
530
            && ! str_contains($request->getHeaderLine('Content-Type'), 'multipart/form-data')
17✔
531
        ) {
532
            $this->data = $request->getRawInput();
2✔
533
        } else {
534
            $this->data = $request->getVar() ?? [];
15✔
535
        }
536

537
        return $this;
17✔
538
    }
539

540
    /**
541
     * Sets (or adds) an individual rule and custom error messages for a single
542
     * field.
543
     *
544
     * The custom error message should be just the messages that apply to
545
     * this field, like so:
546
     *    [
547
     *        'rule1' => 'message1',
548
     *        'rule2' => 'message2',
549
     *    ]
550
     *
551
     * @param array|string $rules  The validation rules.
552
     * @param array        $errors The custom error message.
553
     *
554
     * @return $this
555
     *
556
     * @throws InvalidArgumentException
557
     */
558
    public function setRule(string $field, ?string $label, $rules, array $errors = [])
559
    {
560
        if (! is_array($rules) && ! is_string($rules)) {
66✔
561
            throw new InvalidArgumentException('$rules must be of type string|array');
4✔
562
        }
563

564
        $ruleSet = [
62✔
565
            $field => [
62✔
566
                'label' => $label,
62✔
567
                'rules' => $rules,
62✔
568
            ],
62✔
569
        ];
62✔
570

571
        if ($errors !== []) {
62✔
572
            $ruleSet[$field]['errors'] = $errors;
4✔
573
        }
574

575
        $this->setRules(array_merge($this->getRules(), $ruleSet), $this->customErrors);
62✔
576

577
        return $this;
62✔
578
    }
579

580
    /**
581
     * Stores the rules that should be used to validate the items.
582
     *
583
     * Rules should be an array formatted like:
584
     *    [
585
     *        'field' => 'rule1|rule2'
586
     *    ]
587
     *
588
     * The $errors array should be formatted like:
589
     *    [
590
     *        'field' => [
591
     *            'rule1' => 'message1',
592
     *            'rule2' => 'message2',
593
     *        ],
594
     *    ]
595
     *
596
     * @param array $errors An array of custom error messages
597
     */
598
    public function setRules(array $rules, array $errors = []): ValidationInterface
599
    {
600
        $this->customErrors = $errors;
1,728✔
601

602
        foreach ($rules as $field => &$rule) {
1,728✔
603
            if (is_array($rule)) {
1,723✔
604
                if (array_key_exists('errors', $rule)) {
161✔
605
                    $this->customErrors[$field] = $rule['errors'];
27✔
606
                    unset($rule['errors']);
27✔
607
                }
608

609
                // if $rule is already a rule collection, just move it to "rules"
610
                // transforming [foo => [required, foobar]] to [foo => [rules => [required, foobar]]]
611
                if (! array_key_exists('rules', $rule)) {
161✔
612
                    $rule = ['rules' => $rule];
51✔
613
                }
614
            }
615

616
            if (isset($rule['rules']) && is_string($rule['rules'])) {
1,723✔
617
                $rule['rules'] = $this->splitRules($rule['rules']);
66✔
618
            }
619

620
            if (is_string($rule)) {
1,723✔
621
                $rule = ['rules' => $this->splitRules($rule)];
1,601✔
622
            }
623
        }
624

625
        $this->rules = $rules;
1,728✔
626

627
        return $this;
1,728✔
628
    }
629

630
    /**
631
     * Returns all of the rules currently defined.
632
     */
633
    public function getRules(): array
634
    {
635
        return $this->rules;
66✔
636
    }
637

638
    /**
639
     * Checks to see if the rule for key $field has been set or not.
640
     */
641
    public function hasRule(string $field): bool
642
    {
643
        return array_key_exists($field, $this->rules);
2✔
644
    }
645

646
    /**
647
     * Get rule group.
648
     *
649
     * @param string $group Group.
650
     *
651
     * @return list<string> Rule group.
652
     *
653
     * @throws ValidationException If group not found.
654
     */
655
    public function getRuleGroup(string $group): array
656
    {
657
        if (! isset($this->config->{$group})) {
14✔
658
            throw ValidationException::forGroupNotFound($group);
4✔
659
        }
660

661
        if (! is_array($this->config->{$group})) {
10✔
662
            throw ValidationException::forGroupNotArray($group);
2✔
663
        }
664

665
        return $this->config->{$group};
8✔
666
    }
667

668
    /**
669
     * Set rule group.
670
     *
671
     * @param string $group Group.
672
     *
673
     * @return void
674
     *
675
     * @throws ValidationException If group not found.
676
     */
677
    public function setRuleGroup(string $group)
678
    {
679
        $rules = $this->getRuleGroup($group);
10✔
680
        $this->setRules($rules);
6✔
681

682
        $errorName = $group . '_errors';
6✔
683
        if (isset($this->config->{$errorName})) {
6✔
684
            $this->customErrors = $this->config->{$errorName};
4✔
685
        }
686
    }
687

688
    /**
689
     * Returns the rendered HTML of the errors as defined in $template.
690
     *
691
     * You can also use validation_list_errors() in Form helper.
692
     */
693
    public function listErrors(string $template = 'list'): string
694
    {
695
        if (! array_key_exists($template, $this->config->templates)) {
3✔
696
            throw ValidationException::forInvalidTemplate($template);
2✔
697
        }
698

699
        return $this->view
1✔
700
            ->setVar('errors', $this->getErrors())
1✔
701
            ->render($this->config->templates[$template]);
1✔
702
    }
703

704
    /**
705
     * Displays a single error in formatted HTML as defined in the $template view.
706
     *
707
     * You can also use validation_show_error() in Form helper.
708
     */
709
    public function showError(string $field, string $template = 'single'): string
710
    {
711
        if (! array_key_exists($field, $this->getErrors())) {
5✔
712
            return '';
2✔
713
        }
714

715
        if (! array_key_exists($template, $this->config->templates)) {
3✔
716
            throw ValidationException::forInvalidTemplate($template);
2✔
717
        }
718

719
        return $this->view
1✔
720
            ->setVar('error', $this->getError($field))
1✔
721
            ->render($this->config->templates[$template]);
1✔
722
    }
723

724
    /**
725
     * Loads all of the rulesets classes that have been defined in the
726
     * Config\Validation and stores them locally so we can use them.
727
     *
728
     * @return void
729
     */
730
    protected function loadRuleSets()
731
    {
732
        if ($this->ruleSetFiles === [] || $this->ruleSetFiles === null) {
1,835✔
733
            throw ValidationException::forNoRuleSets();
2✔
734
        }
735

736
        foreach ($this->ruleSetFiles as $file) {
1,835✔
737
            $this->ruleSetInstances[] = new $file();
1,835✔
738
        }
739
    }
740

741
    /**
742
     * Loads custom rule groups (if set) into the current rules.
743
     *
744
     * Rules can be pre-defined in Config\Validation and can
745
     * be any name, but must all still be an array of the
746
     * same format used with setRules(). Additionally, check
747
     * for {group}_errors for an array of custom error messages.
748
     *
749
     * @param non-empty-string|null $group
750
     *
751
     * @return array<int, array> [rules, customErrors]
752
     *
753
     * @throws ValidationException
754
     */
755
    public function loadRuleGroup(?string $group = null)
756
    {
757
        if ($group === null || $group === '') {
1,700✔
758
            return [];
1,689✔
759
        }
760

761
        if (! isset($this->config->{$group})) {
24✔
762
            throw ValidationException::forGroupNotFound($group);
2✔
763
        }
764

765
        if (! is_array($this->config->{$group})) {
22✔
766
            throw ValidationException::forGroupNotArray($group);
2✔
767
        }
768

769
        $this->setRules($this->config->{$group});
20✔
770

771
        // If {group}_errors exists in the config file,
772
        // then override our custom errors with them.
773
        $errorName = $group . '_errors';
20✔
774

775
        if (isset($this->config->{$errorName})) {
20✔
776
            $this->customErrors = $this->config->{$errorName};
17✔
777
        }
778

779
        return [$this->rules, $this->customErrors];
20✔
780
    }
781

782
    /**
783
     * Replace any placeholders within the rules with the values that
784
     * match the 'key' of any properties being set. For example, if
785
     * we had the following $data array:
786
     *
787
     * [ 'id' => 13 ]
788
     *
789
     * and the following rule:
790
     *
791
     *  'is_unique[users,email,id,{id}]'
792
     *
793
     * The value of {id} would be replaced with the actual id in the form data:
794
     *
795
     *  'is_unique[users,email,id,13]'
796
     */
797
    protected function fillPlaceholders(array $rules, array $data): array
798
    {
799
        foreach ($rules as &$rule) {
1,692✔
800
            $ruleSet = $rule['rules'];
1,692✔
801

802
            foreach ($ruleSet as &$row) {
1,692✔
803
                if (is_string($row)) {
1,692✔
804
                    $placeholderFields = $this->retrievePlaceholders($row, $data);
1,686✔
805

806
                    foreach ($placeholderFields as $field) {
1,686✔
807
                        $validator ??= service('validation', null, false);
31✔
808
                        assert($validator instanceof Validation);
809

810
                        $placeholderRules = $rules[$field]['rules'] ?? null;
31✔
811

812
                        // Check if the validation rule for the placeholder exists
813
                        if ($placeholderRules === null) {
31✔
814
                            throw new LogicException(
2✔
815
                                'No validation rules for the placeholder: "' . $field
2✔
816
                                . '". You must set the validation rules for the field.'
2✔
817
                                . ' See <https://codeigniter4.github.io/userguide/libraries/validation.html#validation-placeholders>.',
2✔
818
                            );
2✔
819
                        }
820

821
                        // Check if the rule does not have placeholders
822
                        foreach ($placeholderRules as $placeholderRule) {
29✔
823
                            if ($this->retrievePlaceholders($placeholderRule, $data) !== []) {
29✔
UNCOV
824
                                throw new LogicException(
×
UNCOV
825
                                    'The placeholder field cannot use placeholder: ' . $field,
×
UNCOV
826
                                );
×
827
                            }
828
                        }
829

830
                        // Validate the placeholder field
831
                        $dbGroup = $data['DBGroup'] ?? null;
29✔
832
                        if (! $validator->check($data[$field], $placeholderRules, [], $dbGroup)) {
29✔
833
                            // if fails, do nothing
834
                            continue;
×
835
                        }
836

837
                        // Replace the placeholder in the current rule string
838
                        if (str_starts_with($row, 'regex_match[')) {
29✔
839
                            $row = str_replace('{{' . $field . '}}', (string) $data[$field], $row);
2✔
840
                        } else {
841
                            $row = str_replace('{' . $field . '}', (string) $data[$field], $row);
27✔
842
                        }
843
                    }
844
                }
845
            }
846

847
            $rule['rules'] = $ruleSet;
1,690✔
848
        }
849

850
        return $rules;
1,690✔
851
    }
852

853
    /**
854
     * Retrieves valid placeholder fields.
855
     */
856
    private function retrievePlaceholders(string $rule, array $data): array
857
    {
858
        if (str_starts_with($rule, 'regex_match[')) {
1,686✔
859
            // For regex_match rules, only look for double-bracket placeholders
860
            preg_match_all('/\{\{((?:(?![{}]).)+?)\}\}/', $rule, $matches);
12✔
861
        } else {
862
            // For all other rules, use single-bracket placeholders
863
            preg_match_all('/{(.+?)}/', $rule, $matches);
1,680✔
864
        }
865

866
        return array_intersect($matches[1], array_keys($data));
1,686✔
867
    }
868

869
    /**
870
     * Checks to see if an error exists for the given field.
871
     */
872
    public function hasError(string $field): bool
873
    {
874
        return (bool) preg_grep(self::getRegex($field), array_keys($this->getErrors()));
10✔
875
    }
876

877
    /**
878
     * Returns the error(s) for a specified $field (or empty string if not
879
     * set).
880
     */
881
    public function getError(?string $field = null): string
882
    {
883
        if ($field === null && count($this->rules) === 1) {
56✔
884
            $field = array_key_first($this->rules);
4✔
885
        }
886

887
        $errors = array_filter(
56✔
888
            $this->getErrors(),
56✔
889
            static fn ($key): bool => preg_match(self::getRegex($field), $key) === 1,
56✔
890
            ARRAY_FILTER_USE_KEY,
56✔
891
        );
56✔
892

893
        return implode("\n", $errors);
56✔
894
    }
895

896
    /**
897
     * Returns the array of errors that were encountered during
898
     * a run() call. The array should be in the following format:
899
     *
900
     *    [
901
     *        'field1' => 'error message',
902
     *        'field2' => 'error message',
903
     *    ]
904
     *
905
     * @return array<string, string>
906
     *
907
     * @codeCoverageIgnore
908
     */
909
    public function getErrors(): array
910
    {
911
        return $this->errors;
912
    }
913

914
    /**
915
     * Sets the error for a specific field. Used by custom validation methods.
916
     */
917
    public function setError(string $field, string $error): ValidationInterface
918
    {
919
        $this->errors[$field] = $error;
8✔
920

921
        return $this;
8✔
922
    }
923

924
    /**
925
     * Attempts to find the appropriate error message
926
     *
927
     * @param non-empty-string|null $label
928
     * @param string|null           $value The value that caused the validation to fail.
929
     */
930
    protected function getErrorMessage(
931
        string $rule,
932
        string $field,
933
        ?string $label = null,
934
        ?string $param = null,
935
        ?string $value = null,
936
        ?string $originalField = null,
937
    ): string {
938
        $args = $this->buildErrorArgs($field, $label, $param, $value);
730✔
939

940
        // Check if custom message has been defined by user
941
        if (isset($this->customErrors[$field][$rule])) {
730✔
942
            return lang($this->customErrors[$field][$rule], $args);
61✔
943
        }
944
        if (null !== $originalField && isset($this->customErrors[$originalField][$rule])) {
671✔
945
            return lang($this->customErrors[$originalField][$rule], $args);
2✔
946
        }
947

948
        // Try to grab a localized version of the message...
949
        // lang() will return the rule name back if not found,
950
        // so there will always be a string being returned.
951
        return lang('Validation.' . $rule, $args);
669✔
952
    }
953

954
    /**
955
     * Substitutes {field}, {param}, and {value} placeholders in an error message
956
     * set directly by a rule method via the $error reference parameter.
957
     *
958
     * Uses simple string replacement rather than lang() to avoid ICU MessageFormatter
959
     * warnings on unrecognised patterns and to leave any other {xyz} content untouched.
960
     */
961
    private function parseErrorMessage(
962
        string $message,
963
        string $field,
964
        ?string $label = null,
965
        ?string $param = null,
966
        ?string $value = null,
967
    ): string {
968
        $args = $this->buildErrorArgs($field, $label, $param, $value);
14✔
969

970
        return str_replace(
14✔
971
            ['{field}', '{param}', '{value}'],
14✔
972
            [$args['field'], $args['param'], $args['value']],
14✔
973
            $message,
14✔
974
        );
14✔
975
    }
976

977
    /**
978
     * Builds the placeholder arguments array used for error message substitution.
979
     *
980
     * @return array{field: string, param: string, value: string}
981
     */
982
    private function buildErrorArgs(
983
        string $field,
984
        ?string $label = null,
985
        ?string $param = null,
986
        ?string $value = null,
987
    ): array {
988
        $param ??= '';
744✔
989

990
        return [
744✔
991
            'field' => ($label === null || $label === '') ? $field : lang($label),
744✔
992
            'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param,
744✔
993
            'value' => $value ?? '',
744✔
994
        ];
744✔
995
    }
996

997
    /**
998
     * Split rules string by pipe operator.
999
     */
1000
    protected function splitRules(string $rules): array
1001
    {
1002
        if (! str_contains($rules, '|')) {
1,687✔
1003
            return [$rules];
1,467✔
1004
        }
1005

1006
        $string = $rules;
259✔
1007
        $rules  = [];
259✔
1008
        $length = strlen($string);
259✔
1009
        $cursor = 0;
259✔
1010

1011
        while ($cursor < $length) {
259✔
1012
            $pos = strpos($string, '|', $cursor);
259✔
1013

1014
            if ($pos === false) {
259✔
1015
                // we're in the last rule
1016
                $pos = $length;
251✔
1017
            }
1018

1019
            $rule = substr($string, $cursor, $pos - $cursor);
259✔
1020

1021
            while (
1022
                (substr_count($rule, '[') - substr_count($rule, '\['))
259✔
1023
                !== (substr_count($rule, ']') - substr_count($rule, '\]'))
259✔
1024
            ) {
1025
                // the pipe is inside the brackets causing the closing bracket to
1026
                // not be included. so, we adjust the rule to include that portion.
1027
                $pos  = strpos($string, '|', $cursor + strlen($rule) + 1) ?: $length;
16✔
1028
                $rule = substr($string, $cursor, $pos - $cursor);
16✔
1029
            }
1030

1031
            $rules[] = $rule;
259✔
1032
            $cursor += strlen($rule) + 1; // +1 to exclude the pipe
259✔
1033
        }
1034

1035
        return array_unique($rules);
259✔
1036
    }
1037

1038
    /**
1039
     * Entry point: allocates a single accumulator and delegates to the
1040
     * recursive collector, so no intermediate arrays are built or unpacked.
1041
     *
1042
     * @param list<string>                  $segments
1043
     * @param array<array-key, mixed>|mixed $current
1044
     *
1045
     * @return list<string>
1046
     */
1047
    private function walkForAllPossiblePaths(array $segments, mixed $current, string $prefix): array
1048
    {
1049
        $result = [];
79✔
1050
        $this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result);
79✔
1051

1052
        return $result;
79✔
1053
    }
1054

1055
    /**
1056
     * Recursively walks the data structure, expanding wildcard segments over
1057
     * all array keys, and appends to $result by reference. Only concrete leaf
1058
     * paths where the key is genuinely absent are recorded - intermediate
1059
     * missing segments are silently skipped so `*` never appears in a result.
1060
     *
1061
     * @param list<string>                  $segments
1062
     * @param int<0, max>                   $segmentCount
1063
     * @param array<array-key, mixed>|mixed $current
1064
     * @param list<string>                  $result
1065
     */
1066
    private function collectMissingPaths(
1067
        array $segments,
1068
        int $index,
1069
        int $segmentCount,
1070
        mixed $current,
1071
        string $prefix,
1072
        array &$result,
1073
    ): void {
1074
        if ($index >= $segmentCount) {
79✔
1075
            // Successfully navigated every segment - the path exists in the data.
1076
            return;
78✔
1077
        }
1078

1079
        $segment   = $segments[$index];
79✔
1080
        $nextIndex = $index + 1;
79✔
1081

1082
        if ($segment === '*') {
79✔
1083
            if (! is_array($current)) {
79✔
UNCOV
1084
                return;
×
1085
            }
1086

1087
            foreach ($current as $key => $value) {
79✔
1088
                $keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
79✔
1089

1090
                // Non-array elements with remaining segments are a structural
1091
                // mismatch (e.g. the DBGroup sentinel, scalar siblings) - skip.
1092
                if (! is_array($value) && $nextIndex < $segmentCount) {
79✔
1093
                    continue;
8✔
1094
                }
1095

1096
                $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result);
79✔
1097
            }
1098

1099
            return;
79✔
1100
        }
1101

1102
        $newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment;
79✔
1103

1104
        if (! is_array($current) || ! array_key_exists($segment, $current)) {
79✔
1105
            // Only record a missing path for the leaf key. When an intermediate
1106
            // segment is absent there is nothing to validate in that branch,
1107
            // so skip it to avoid false-positive errors.
1108
            if ($nextIndex === $segmentCount) {
25✔
1109
                $result[] = $newPrefix;
25✔
1110
            }
1111

1112
            return;
25✔
1113
        }
1114

1115
        $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result);
79✔
1116
    }
1117

1118
    /**
1119
     * Resets the class to a blank slate. Should be called whenever
1120
     * you need to process more than one array.
1121
     */
1122
    public function reset(): ValidationInterface
1123
    {
1124
        $this->data         = [];
1,720✔
1125
        $this->validated    = [];
1,720✔
1126
        $this->rules        = [];
1,720✔
1127
        $this->errors       = [];
1,720✔
1128
        $this->customErrors = [];
1,720✔
1129

1130
        return $this;
1,720✔
1131
    }
1132
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc