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

codeigniter4 / CodeIgniter4 / 25399569005

05 May 2026 08:08PM UTC coverage: 88.276% (+0.002%) from 88.274%
25399569005

Pull #10158

github

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

65 of 73 new or added lines in 3 files covered. (89.04%)

28 existing lines in 4 files now uncovered.

23552 of 26680 relevant lines covered (88.28%)

218.03 hits per line

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

95.39
/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\HTTP\ValidatedInput;
25
use CodeIgniter\Validation\Exceptions\ValidationException;
26
use CodeIgniter\View\RendererInterface;
27

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

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

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

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

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

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

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

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

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

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

119
        $this->config = $config;
1,824✔
120

121
        $this->view = $view;
1,824✔
122

123
        $this->loadRuleSets();
1,824✔
124
    }
125

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

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

146
        $this->loadRuleGroup($group);
1,688✔
147

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

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

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

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

167
            $rules = $setup['rules'];
1,677✔
168

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

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

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

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

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

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

201
                continue;
14✔
202
            }
203

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

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

222
            return true;
933✔
223
        }
224

225
        return false;
744✔
226
    }
227

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

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

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

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

275
    /**
276
     * Returns the actual validated data as a typed input object.
277
     */
278
    public function getValidatedInput(): ValidatedInput
279
    {
280
        return new ValidatedInput($this->validated);
2✔
281
    }
282

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

306
        $rules = $this->processIfExist($field, $rules, $data);
1,677✔
307
        if ($rules === true) {
1,677✔
308
            return true;
6✔
309
        }
310

311
        $rules = $this->processPermitEmpty($value, $rules, $data);
1,673✔
312
        if ($rules === true) {
1,673✔
313
            return true;
60✔
314
        }
315

316
        foreach ($rules as $i => $rule) {
1,639✔
317
            $isCallable     = is_callable($rule);
1,636✔
318
            $stringCallable = $isCallable && is_string($rule);
1,636✔
319
            $arrayCallable  = $isCallable && is_array($rule);
1,636✔
320

321
            $passed = false;
1,636✔
322
            /** @var string|null $param */
323
            $param = null;
1,636✔
324

325
            if (! $isCallable && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
1,636✔
326
                $rule  = $match[1];
1,086✔
327
                $param = $match[2];
1,086✔
328
            }
329

330
            // Placeholder for custom errors from the rules.
331
            $error = null;
1,636✔
332

333
            // If it's a callable, call and get out of here.
334
            if ($this->isClosure($rule)) {
1,636✔
335
                $passed = $rule($value, $data, $error, $field);
20✔
336
            } elseif ($isCallable) {
1,628✔
337
                $passed = $stringCallable ? $rule($value) : $rule($value, $data, $error, $field);
44✔
338
            } else {
339
                $found = false;
1,586✔
340

341
                // Check in our rulesets
342
                foreach ($this->ruleSetInstances as $set) {
1,586✔
343
                    if (! method_exists($set, $rule)) {
1,586✔
344
                        continue;
1,011✔
345
                    }
346

347
                    $found = true;
1,584✔
348

349
                    if ($rule === 'field_exists') {
1,584✔
350
                        $passed = $set->{$rule}($value, $param, $data, $error, $originalField);
24✔
351
                    } else {
352
                        $passed = ($param === null)
1,560✔
353
                            ? $set->{$rule}($value, $error)
567✔
354
                            : $set->{$rule}($value, $param, $data, $error, $field);
1,086✔
355
                    }
356

357
                    break;
1,575✔
358
                }
359

360
                // If the rule wasn't found anywhere, we
361
                // should throw an exception so the developer can find it.
362
                if (! $found) {
1,577✔
363
                    throw ValidationException::forRuleNotFound($rule);
2✔
364
                }
365
            }
366

367
            // Set the error message if we didn't survive.
368
            if ($passed === false) {
1,625✔
369
                // if the $value is an array, convert it to as string representation
370
                if (is_array($value)) {
744✔
371
                    $value = $this->isStringList($value)
17✔
372
                        ? '[' . implode(', ', $value) . ']'
10✔
373
                        : json_encode($value);
7✔
374
                } elseif (is_object($value)) {
727✔
375
                    $value = json_encode($value);
2✔
376
                }
377

378
                $fieldForErrors = ($rule === 'field_exists') ? $originalField : $field;
744✔
379

380
                // @phpstan-ignore-next-line $error may be set by rule methods.
381
                $this->errors[$fieldForErrors] = $error !== null
744✔
382
                    ? $this->parseErrorMessage($error, $field, $label, $param, (string) $value)
14✔
383
                    : $this->getErrorMessage(
730✔
384
                        ($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule,
730✔
385
                        $field,
730✔
386
                        $label,
730✔
387
                        $param,
730✔
388
                        (string) $value,
730✔
389
                        $originalField,
730✔
390
                    );
730✔
391

392
                return false;
744✔
393
            }
394
        }
395

396
        return true;
944✔
397
    }
398

399
    /**
400
     * @param array $data The array of data to validate, with `DBGroup`.
401
     *
402
     * @return array|true The modified rules or true if we return early
403
     */
404
    private function processIfExist(string $field, array $rules, array $data)
405
    {
406
        if (in_array('if_exist', $rules, true)) {
1,677✔
407
            $flattenedData = array_flatten_with_dots($data);
26✔
408
            $ifExistField  = $field;
26✔
409

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

416
                foreach (array_keys($flattenedData) as $item) {
×
417
                    if (preg_match($pattern, $item) === 1) {
×
418
                        $dataIsExisting = true;
×
UNCOV
419
                        break;
×
420
                    }
421
                }
422
            } elseif (str_contains($field, '.')) {
26✔
423
                $dataIsExisting = array_key_exists($ifExistField, $flattenedData);
18✔
424
            } else {
425
                $dataIsExisting = array_key_exists($ifExistField, $data);
8✔
426
            }
427

428
            if (! $dataIsExisting) {
26✔
429
                // we return early if `if_exist` is not satisfied. we have nothing to do here.
430
                return true;
6✔
431
            }
432

433
            // Otherwise remove the if_exist rule and continue the process
434
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'if_exist');
22✔
435
        }
436

437
        return $rules;
1,673✔
438
    }
439

440
    /**
441
     * @param array|string $value
442
     * @param array        $data  The array of data to validate, with `DBGroup`.
443
     *
444
     * @return array|true The modified rules or true if we return early
445
     */
446
    private function processPermitEmpty($value, array $rules, array $data)
447
    {
448
        if (in_array('permit_empty', $rules, true)) {
1,673✔
449
            if (
450
                ! in_array('required', $rules, true)
146✔
451
                && (is_array($value) ? $value === [] : trim((string) $value) === '')
146✔
452
            ) {
453
                $passed = true;
74✔
454

455
                foreach ($rules as $rule) {
74✔
456
                    if (! $this->isClosure($rule) && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
74✔
457
                        $rule  = $match[1];
50✔
458
                        $param = $match[2];
50✔
459

460
                        if (! in_array($rule, ['required_with', 'required_without'], true)) {
50✔
461
                            continue;
20✔
462
                        }
463

464
                        // Check in our rulesets
465
                        foreach ($this->ruleSetInstances as $set) {
30✔
466
                            if (! method_exists($set, $rule)) {
30✔
UNCOV
467
                                continue;
×
468
                            }
469

470
                            $passed = $passed && $set->{$rule}($value, $param, $data);
30✔
471
                            break;
30✔
472
                        }
473
                    }
474
                }
475

476
                if ($passed) {
74✔
477
                    return true;
60✔
478
                }
479
            }
480

481
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'permit_empty');
90✔
482
        }
483

484
        return $rules;
1,639✔
485
    }
486

487
    /**
488
     * @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
489
     */
490
    private function isClosure($rule): bool
491
    {
492
        return $rule instanceof Closure;
1,670✔
493
    }
494

495
    /**
496
     * Is the array a string list `list<string>`?
497
     */
498
    private function isStringList(array $array): bool
499
    {
500
        $expectedKey = 0;
17✔
501

502
        foreach ($array as $key => $val) {
17✔
503
            // Note: also covers PHP array key conversion, e.g. '5' and 5.1 both become 5
504
            if (! is_int($key)) {
7✔
505
                return false;
1✔
506
            }
507

508
            if ($key !== $expectedKey) {
6✔
UNCOV
509
                return false;
×
510
            }
511
            $expectedKey++;
6✔
512

513
            if (! is_string($val)) {
6✔
514
                return false;
6✔
515
            }
516
        }
517

518
        return true;
10✔
519
    }
520

521
    /**
522
     * Takes a Request object and grabs the input data to use from its
523
     * array values.
524
     */
525
    public function withRequest(RequestInterface $request): ValidationInterface
526
    {
527
        /** @var IncomingRequest $request */
528
        if (str_contains($request->getHeaderLine('Content-Type'), 'application/json')) {
25✔
529
            $this->data = $request->getJSON(true);
8✔
530

531
            if (! is_array($this->data)) {
6✔
532
                throw HTTPException::forUnsupportedJSONFormat();
2✔
533
            }
534

535
            return $this;
4✔
536
        }
537

538
        if (in_array($request->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true)
17✔
539
            && ! str_contains($request->getHeaderLine('Content-Type'), 'multipart/form-data')
17✔
540
        ) {
541
            $this->data = $request->getRawInput();
2✔
542
        } else {
543
            $this->data = $request->getVar() ?? [];
15✔
544
        }
545

546
        return $this;
17✔
547
    }
548

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

573
        $ruleSet = [
62✔
574
            $field => [
62✔
575
                'label' => $label,
62✔
576
                'rules' => $rules,
62✔
577
            ],
62✔
578
        ];
62✔
579

580
        if ($errors !== []) {
62✔
581
            $ruleSet[$field]['errors'] = $errors;
4✔
582
        }
583

584
        $this->setRules(array_merge($this->getRules(), $ruleSet), $this->customErrors);
62✔
585

586
        return $this;
62✔
587
    }
588

589
    /**
590
     * Stores the rules that should be used to validate the items.
591
     *
592
     * Rules should be an array formatted like:
593
     *    [
594
     *        'field' => 'rule1|rule2'
595
     *    ]
596
     *
597
     * The $errors array should be formatted like:
598
     *    [
599
     *        'field' => [
600
     *            'rule1' => 'message1',
601
     *            'rule2' => 'message2',
602
     *        ],
603
     *    ]
604
     *
605
     * @param array $errors An array of custom error messages
606
     */
607
    public function setRules(array $rules, array $errors = []): ValidationInterface
608
    {
609
        $this->customErrors = $errors;
1,717✔
610

611
        foreach ($rules as $field => &$rule) {
1,717✔
612
            if (is_array($rule)) {
1,712✔
613
                if (array_key_exists('errors', $rule)) {
161✔
614
                    $this->customErrors[$field] = $rule['errors'];
27✔
615
                    unset($rule['errors']);
27✔
616
                }
617

618
                // if $rule is already a rule collection, just move it to "rules"
619
                // transforming [foo => [required, foobar]] to [foo => [rules => [required, foobar]]]
620
                if (! array_key_exists('rules', $rule)) {
161✔
621
                    $rule = ['rules' => $rule];
51✔
622
                }
623
            }
624

625
            if (isset($rule['rules']) && is_string($rule['rules'])) {
1,712✔
626
                $rule['rules'] = $this->splitRules($rule['rules']);
66✔
627
            }
628

629
            if (is_string($rule)) {
1,712✔
630
                $rule = ['rules' => $this->splitRules($rule)];
1,590✔
631
            }
632
        }
633

634
        $this->rules = $rules;
1,717✔
635

636
        return $this;
1,717✔
637
    }
638

639
    /**
640
     * Returns all of the rules currently defined.
641
     */
642
    public function getRules(): array
643
    {
644
        return $this->rules;
66✔
645
    }
646

647
    /**
648
     * Checks to see if the rule for key $field has been set or not.
649
     */
650
    public function hasRule(string $field): bool
651
    {
652
        return array_key_exists($field, $this->rules);
2✔
653
    }
654

655
    /**
656
     * Get rule group.
657
     *
658
     * @param string $group Group.
659
     *
660
     * @return list<string> Rule group.
661
     *
662
     * @throws ValidationException If group not found.
663
     */
664
    public function getRuleGroup(string $group): array
665
    {
666
        if (! isset($this->config->{$group})) {
14✔
667
            throw ValidationException::forGroupNotFound($group);
4✔
668
        }
669

670
        if (! is_array($this->config->{$group})) {
10✔
671
            throw ValidationException::forGroupNotArray($group);
2✔
672
        }
673

674
        return $this->config->{$group};
8✔
675
    }
676

677
    /**
678
     * Set rule group.
679
     *
680
     * @param string $group Group.
681
     *
682
     * @return void
683
     *
684
     * @throws ValidationException If group not found.
685
     */
686
    public function setRuleGroup(string $group)
687
    {
688
        $rules = $this->getRuleGroup($group);
10✔
689
        $this->setRules($rules);
6✔
690

691
        $errorName = $group . '_errors';
6✔
692
        if (isset($this->config->{$errorName})) {
6✔
693
            $this->customErrors = $this->config->{$errorName};
4✔
694
        }
695
    }
696

697
    /**
698
     * Returns the rendered HTML of the errors as defined in $template.
699
     *
700
     * You can also use validation_list_errors() in Form helper.
701
     */
702
    public function listErrors(string $template = 'list'): string
703
    {
704
        if (! array_key_exists($template, $this->config->templates)) {
3✔
705
            throw ValidationException::forInvalidTemplate($template);
2✔
706
        }
707

708
        return $this->view
1✔
709
            ->setVar('errors', $this->getErrors())
1✔
710
            ->render($this->config->templates[$template]);
1✔
711
    }
712

713
    /**
714
     * Displays a single error in formatted HTML as defined in the $template view.
715
     *
716
     * You can also use validation_show_error() in Form helper.
717
     */
718
    public function showError(string $field, string $template = 'single'): string
719
    {
720
        if (! array_key_exists($field, $this->getErrors())) {
5✔
721
            return '';
2✔
722
        }
723

724
        if (! array_key_exists($template, $this->config->templates)) {
3✔
725
            throw ValidationException::forInvalidTemplate($template);
2✔
726
        }
727

728
        return $this->view
1✔
729
            ->setVar('error', $this->getError($field))
1✔
730
            ->render($this->config->templates[$template]);
1✔
731
    }
732

733
    /**
734
     * Loads all of the rulesets classes that have been defined in the
735
     * Config\Validation and stores them locally so we can use them.
736
     *
737
     * @return void
738
     */
739
    protected function loadRuleSets()
740
    {
741
        if ($this->ruleSetFiles === [] || $this->ruleSetFiles === null) {
1,824✔
742
            throw ValidationException::forNoRuleSets();
2✔
743
        }
744

745
        foreach ($this->ruleSetFiles as $file) {
1,824✔
746
            $this->ruleSetInstances[] = new $file();
1,824✔
747
        }
748
    }
749

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

770
        if (! isset($this->config->{$group})) {
24✔
771
            throw ValidationException::forGroupNotFound($group);
2✔
772
        }
773

774
        if (! is_array($this->config->{$group})) {
22✔
775
            throw ValidationException::forGroupNotArray($group);
2✔
776
        }
777

778
        $this->setRules($this->config->{$group});
20✔
779

780
        // If {group}_errors exists in the config file,
781
        // then override our custom errors with them.
782
        $errorName = $group . '_errors';
20✔
783

784
        if (isset($this->config->{$errorName})) {
20✔
785
            $this->customErrors = $this->config->{$errorName};
17✔
786
        }
787

788
        return [$this->rules, $this->customErrors];
20✔
789
    }
790

791
    /**
792
     * Replace any placeholders within the rules with the values that
793
     * match the 'key' of any properties being set. For example, if
794
     * we had the following $data array:
795
     *
796
     * [ 'id' => 13 ]
797
     *
798
     * and the following rule:
799
     *
800
     *  'is_unique[users,email,id,{id}]'
801
     *
802
     * The value of {id} would be replaced with the actual id in the form data:
803
     *
804
     *  'is_unique[users,email,id,13]'
805
     */
806
    protected function fillPlaceholders(array $rules, array $data): array
807
    {
808
        foreach ($rules as &$rule) {
1,681✔
809
            $ruleSet = $rule['rules'];
1,681✔
810

811
            foreach ($ruleSet as &$row) {
1,681✔
812
                if (is_string($row)) {
1,681✔
813
                    $placeholderFields = $this->retrievePlaceholders($row, $data);
1,675✔
814

815
                    foreach ($placeholderFields as $field) {
1,675✔
816
                        $validator ??= service('validation', null, false);
31✔
817
                        assert($validator instanceof Validation);
818

819
                        $placeholderRules = $rules[$field]['rules'] ?? null;
31✔
820

821
                        // Check if the validation rule for the placeholder exists
822
                        if ($placeholderRules === null) {
31✔
823
                            throw new LogicException(
2✔
824
                                'No validation rules for the placeholder: "' . $field
2✔
825
                                . '". You must set the validation rules for the field.'
2✔
826
                                . ' See <https://codeigniter4.github.io/userguide/libraries/validation.html#validation-placeholders>.',
2✔
827
                            );
2✔
828
                        }
829

830
                        // Check if the rule does not have placeholders
831
                        foreach ($placeholderRules as $placeholderRule) {
29✔
832
                            if ($this->retrievePlaceholders($placeholderRule, $data) !== []) {
29✔
833
                                throw new LogicException(
×
834
                                    'The placeholder field cannot use placeholder: ' . $field,
×
UNCOV
835
                                );
×
836
                            }
837
                        }
838

839
                        // Validate the placeholder field
840
                        $dbGroup = $data['DBGroup'] ?? null;
29✔
841
                        if (! $validator->check($data[$field], $placeholderRules, [], $dbGroup)) {
29✔
842
                            // if fails, do nothing
UNCOV
843
                            continue;
×
844
                        }
845

846
                        // Replace the placeholder in the current rule string
847
                        if (str_starts_with($row, 'regex_match[')) {
29✔
848
                            $row = str_replace('{{' . $field . '}}', (string) $data[$field], $row);
2✔
849
                        } else {
850
                            $row = str_replace('{' . $field . '}', (string) $data[$field], $row);
27✔
851
                        }
852
                    }
853
                }
854
            }
855

856
            $rule['rules'] = $ruleSet;
1,679✔
857
        }
858

859
        return $rules;
1,679✔
860
    }
861

862
    /**
863
     * Retrieves valid placeholder fields.
864
     */
865
    private function retrievePlaceholders(string $rule, array $data): array
866
    {
867
        if (str_starts_with($rule, 'regex_match[')) {
1,675✔
868
            // For regex_match rules, only look for double-bracket placeholders
869
            preg_match_all('/\{\{((?:(?![{}]).)+?)\}\}/', $rule, $matches);
12✔
870
        } else {
871
            // For all other rules, use single-bracket placeholders
872
            preg_match_all('/{(.+?)}/', $rule, $matches);
1,669✔
873
        }
874

875
        return array_intersect($matches[1], array_keys($data));
1,675✔
876
    }
877

878
    /**
879
     * Checks to see if an error exists for the given field.
880
     */
881
    public function hasError(string $field): bool
882
    {
883
        return (bool) preg_grep(self::getRegex($field), array_keys($this->getErrors()));
10✔
884
    }
885

886
    /**
887
     * Returns the error(s) for a specified $field (or empty string if not
888
     * set).
889
     */
890
    public function getError(?string $field = null): string
891
    {
892
        if ($field === null && count($this->rules) === 1) {
56✔
893
            $field = array_key_first($this->rules);
4✔
894
        }
895

896
        $errors = array_filter(
56✔
897
            $this->getErrors(),
56✔
898
            static fn ($key): bool => preg_match(self::getRegex($field), $key) === 1,
56✔
899
            ARRAY_FILTER_USE_KEY,
56✔
900
        );
56✔
901

902
        return implode("\n", $errors);
56✔
903
    }
904

905
    /**
906
     * Returns the array of errors that were encountered during
907
     * a run() call. The array should be in the following format:
908
     *
909
     *    [
910
     *        'field1' => 'error message',
911
     *        'field2' => 'error message',
912
     *    ]
913
     *
914
     * @return array<string, string>
915
     *
916
     * @codeCoverageIgnore
917
     */
918
    public function getErrors(): array
919
    {
920
        return $this->errors;
921
    }
922

923
    /**
924
     * Sets the error for a specific field. Used by custom validation methods.
925
     */
926
    public function setError(string $field, string $error): ValidationInterface
927
    {
928
        $this->errors[$field] = $error;
8✔
929

930
        return $this;
8✔
931
    }
932

933
    /**
934
     * Attempts to find the appropriate error message
935
     *
936
     * @param non-empty-string|null $label
937
     * @param string|null           $value The value that caused the validation to fail.
938
     */
939
    protected function getErrorMessage(
940
        string $rule,
941
        string $field,
942
        ?string $label = null,
943
        ?string $param = null,
944
        ?string $value = null,
945
        ?string $originalField = null,
946
    ): string {
947
        $args = $this->buildErrorArgs($field, $label, $param, $value);
730✔
948

949
        // Check if custom message has been defined by user
950
        if (isset($this->customErrors[$field][$rule])) {
730✔
951
            return lang($this->customErrors[$field][$rule], $args);
61✔
952
        }
953
        if (null !== $originalField && isset($this->customErrors[$originalField][$rule])) {
671✔
954
            return lang($this->customErrors[$originalField][$rule], $args);
2✔
955
        }
956

957
        // Try to grab a localized version of the message...
958
        // lang() will return the rule name back if not found,
959
        // so there will always be a string being returned.
960
        return lang('Validation.' . $rule, $args);
669✔
961
    }
962

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

979
        return str_replace(
14✔
980
            ['{field}', '{param}', '{value}'],
14✔
981
            [$args['field'], $args['param'], $args['value']],
14✔
982
            $message,
14✔
983
        );
14✔
984
    }
985

986
    /**
987
     * Builds the placeholder arguments array used for error message substitution.
988
     *
989
     * @return array{field: string, param: string, value: string}
990
     */
991
    private function buildErrorArgs(
992
        string $field,
993
        ?string $label = null,
994
        ?string $param = null,
995
        ?string $value = null,
996
    ): array {
997
        $param ??= '';
744✔
998

999
        return [
744✔
1000
            'field' => ($label === null || $label === '') ? $field : lang($label),
744✔
1001
            'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param,
744✔
1002
            'value' => $value ?? '',
744✔
1003
        ];
744✔
1004
    }
1005

1006
    /**
1007
     * Split rules string by pipe operator.
1008
     */
1009
    protected function splitRules(string $rules): array
1010
    {
1011
        if (! str_contains($rules, '|')) {
1,676✔
1012
            return [$rules];
1,456✔
1013
        }
1014

1015
        $string = $rules;
260✔
1016
        $rules  = [];
260✔
1017
        $length = strlen($string);
260✔
1018
        $cursor = 0;
260✔
1019

1020
        while ($cursor < $length) {
260✔
1021
            $pos = strpos($string, '|', $cursor);
260✔
1022

1023
            if ($pos === false) {
260✔
1024
                // we're in the last rule
1025
                $pos = $length;
252✔
1026
            }
1027

1028
            $rule = substr($string, $cursor, $pos - $cursor);
260✔
1029

1030
            while (
1031
                (substr_count($rule, '[') - substr_count($rule, '\['))
260✔
1032
                !== (substr_count($rule, ']') - substr_count($rule, '\]'))
260✔
1033
            ) {
1034
                // the pipe is inside the brackets causing the closing bracket to
1035
                // not be included. so, we adjust the rule to include that portion.
1036
                $pos  = strpos($string, '|', $cursor + strlen($rule) + 1) ?: $length;
16✔
1037
                $rule = substr($string, $cursor, $pos - $cursor);
16✔
1038
            }
1039

1040
            $rules[] = $rule;
260✔
1041
            $cursor += strlen($rule) + 1; // +1 to exclude the pipe
260✔
1042
        }
1043

1044
        return array_unique($rules);
260✔
1045
    }
1046

1047
    /**
1048
     * Entry point: allocates a single accumulator and delegates to the
1049
     * recursive collector, so no intermediate arrays are built or unpacked.
1050
     *
1051
     * @param list<string>                  $segments
1052
     * @param array<array-key, mixed>|mixed $current
1053
     *
1054
     * @return list<string>
1055
     */
1056
    private function walkForAllPossiblePaths(array $segments, mixed $current, string $prefix): array
1057
    {
1058
        $result = [];
79✔
1059
        $this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result);
79✔
1060

1061
        return $result;
79✔
1062
    }
1063

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

1088
        $segment   = $segments[$index];
79✔
1089
        $nextIndex = $index + 1;
79✔
1090

1091
        if ($segment === '*') {
79✔
1092
            if (! is_array($current)) {
79✔
UNCOV
1093
                return;
×
1094
            }
1095

1096
            foreach ($current as $key => $value) {
79✔
1097
                $keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
79✔
1098

1099
                // Non-array elements with remaining segments are a structural
1100
                // mismatch (e.g. the DBGroup sentinel, scalar siblings) - skip.
1101
                if (! is_array($value) && $nextIndex < $segmentCount) {
79✔
1102
                    continue;
8✔
1103
                }
1104

1105
                $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result);
79✔
1106
            }
1107

1108
            return;
79✔
1109
        }
1110

1111
        $newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment;
79✔
1112

1113
        if (! is_array($current) || ! array_key_exists($segment, $current)) {
79✔
1114
            // Only record a missing path for the leaf key. When an intermediate
1115
            // segment is absent there is nothing to validate in that branch,
1116
            // so skip it to avoid false-positive errors.
1117
            if ($nextIndex === $segmentCount) {
25✔
1118
                $result[] = $newPrefix;
25✔
1119
            }
1120

1121
            return;
25✔
1122
        }
1123

1124
        $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result);
79✔
1125
    }
1126

1127
    /**
1128
     * Resets the class to a blank slate. Should be called whenever
1129
     * you need to process more than one array.
1130
     */
1131
    public function reset(): ValidationInterface
1132
    {
1133
        $this->data         = [];
1,722✔
1134
        $this->validated    = [];
1,722✔
1135
        $this->rules        = [];
1,722✔
1136
        $this->errors       = [];
1,722✔
1137
        $this->customErrors = [];
1,722✔
1138

1139
        return $this;
1,722✔
1140
    }
1141
}
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