• 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

90.57
/system/HTTP/FormRequest.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\HTTP;
15

16
use BackedEnum;
17
use CodeIgniter\Exceptions\InvalidArgumentException;
18
use CodeIgniter\Exceptions\RuntimeException;
19
use CodeIgniter\I18n\Time;
20
use DateTimeZone;
21
use Exception;
22
use ReflectionEnum;
23
use ReflectionNamedType;
24
use ReflectionParameter;
25
use UnitEnum;
26

27
/**
28
 * @see \CodeIgniter\HTTP\FormRequestTest
29
 */
30
abstract class FormRequest
31
{
32
    /**
33
     * The underlying HTTP request instance.
34
     */
35
    protected IncomingRequest $request;
36

37
    /**
38
     * Data that passed validation (only the fields covered by rules()).
39
     *
40
     * @var array<string, mixed>
41
     */
42
    private array $validatedData = [];
43

44
    /**
45
     * When called by the framework, the current IncomingRequest is injected
46
     * explicitly. When instantiated manually (e.g. in tests), the constructor
47
     * falls back to service('request').
48
     */
49
    final public function __construct(?Request $request = null)
50
    {
51
        $request ??= service('request');
52✔
52

53
        if (! $request instanceof IncomingRequest) {
52✔
54
            throw new RuntimeException(
1✔
55
                sprintf('%s requires an IncomingRequest instance, got %s.', static::class, $request::class),
1✔
56
            );
1✔
57
        }
58

59
        $this->request = $request;
51✔
60
    }
61

62
    /**
63
     * Validation rules for this request.
64
     *
65
     * Return an array of field => rules pairs, identical to what you would
66
     * pass to $this->validate() in a controller:
67
     *
68
     *  return [
69
     *      'title' => 'required|min_length[5]',
70
     *      'body'  => ['required', 'max_length[10000]'],
71
     *  ];
72
     *
73
     * @return array<string, list<string>|string>
74
     */
75
    abstract public function rules(): array;
76

77
    /**
78
     * Custom error messages keyed by field.rule.
79
     *
80
     *  return [
81
     *      'title' => ['required' => 'Post title cannot be empty.'],
82
     *  ];
83
     *
84
     * @return array<string, array<string, string>>
85
     */
86
    public function messages(): array
87
    {
88
        return [];
41✔
89
    }
90

91
    /**
92
     * Determine if the current user is authorized to make this request.
93
     *
94
     * Override in subclasses to add authorization logic:
95
     *
96
     *  public function isAuthorized(): bool
97
     *  {
98
     *      return auth()->user()->can('create-posts');
99
     *  }
100
     */
101
    public function isAuthorized(): bool
102
    {
103
        return true;
41✔
104
    }
105

106
    /**
107
     * Returns the class name when the given reflection parameter is typed as a
108
     * FormRequest subclass, or null otherwise. Used by the dispatcher and
109
     * auto-router to distinguish injectable parameters from URI-segment parameters.
110
     *
111
     * @internal
112
     *
113
     * @return class-string<self>|null
114
     */
115
    final public static function getFormRequestClass(ReflectionParameter $param): ?string
116
    {
117
        $type = $param->getType();
49✔
118

119
        if (
120
            $type instanceof ReflectionNamedType
49✔
121
            && ! $type->isBuiltin()
49✔
122
            && is_subclass_of($type->getName(), self::class)
49✔
123
        ) {
124
            return $type->getName();
13✔
125
        }
126

127
        return null;
42✔
128
    }
129

130
    /**
131
     * Modify the request data before validation rules are applied.
132
     * Override to normalize or cast input values:
133
     *
134
     *  protected function prepareForValidation(array $data): array
135
     *  {
136
     *      $data['slug'] = url_title($data['title'] ?? '', '-', true);
137
     *      return $data;
138
     *  }
139
     *
140
     * The $data array is the same payload that will be passed to the
141
     * validator. Return the (possibly modified) array.
142
     *
143
     * @param array<string, mixed> $data
144
     *
145
     * @return array<string, mixed>
146
     */
147
    protected function prepareForValidation(array $data): array
148
    {
149
        return $data;
39✔
150
    }
151

152
    /**
153
     * Called when validation fails. Override to customize the failure response.
154
     *
155
     * The default implementation redirects back with input and flashes validation
156
     * errors via the standard ``_ci_validation_errors`` channel (the same channel
157
     * used by controller-level validation and readable by ``validation_errors()``
158
     * helpers). For JSON/AJAX requests it returns a 422 JSON response instead.
159
     *
160
     * @param array<string, string> $errors
161
     */
162
    protected function failedValidation(array $errors): ResponseInterface
163
    {
164
        if ($this->request->is('json') || $this->request->isAJAX()) {
8✔
165
            return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]);
5✔
166
        }
167

168
        return redirect()->back()->withInput();
3✔
169
    }
170

171
    /**
172
     * Called when the isAuthorized() check returns false. Override to customize.
173
     */
174
    protected function failedAuthorization(): ResponseInterface
175
    {
176
        return service('response')->setStatusCode(403);
3✔
177
    }
178

179
    /**
180
     * Returns only the fields that passed validation (those covered by rules()).
181
     *
182
     * Prefer this over $this->request->getPost() in controllers to avoid
183
     * processing fields that were not declared in the rules.
184
     *
185
     * @return array<string, mixed>
186
     */
187
    public function validated(): array
188
    {
189
        return $this->validatedData;
12✔
190
    }
191

192
    /**
193
     * Returns a single validated field value by name, or the default value
194
     * if the field is not present in the validated data.
195
     *
196
     * Supports dot-array syntax for nested validated data.
197
     */
198
    public function getValidated(string $key, mixed $default = null): mixed
199
    {
200
        helper('array');
23✔
201

202
        if (! dot_array_has($key, $this->validatedData)) {
23✔
203
            return $default;
6✔
204
        }
205

206
        return dot_array_search($key, $this->validatedData);
17✔
207
    }
208

209
    /**
210
     * Returns a validated field as an integer.
211
     *
212
     * Supports dot-array syntax for nested validated data.
213
     */
214
    public function integer(string $key, ?int $default = null): ?int
215
    {
216
        $value = $this->getValidated($key, $default);
5✔
217

218
        if ($value === null || is_int($value)) {
5✔
219
            return $value;
2✔
220
        }
221

222
        if (is_string($value)) {
3✔
223
            $integer = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
3✔
224

225
            if ($integer !== null) {
3✔
226
                return $integer;
2✔
227
            }
228
        }
229

230
        throw $this->invalidValidatedType($key, 'integer');
1✔
231
    }
232

233
    /**
234
     * Returns a validated field as a boolean.
235
     *
236
     * Supports dot-array syntax for nested validated data.
237
     */
238
    public function boolean(string $key, ?bool $default = null): ?bool
239
    {
240
        $value = $this->getValidated($key, $default);
5✔
241

242
        if ($value === null || is_bool($value)) {
5✔
243
            return $value;
2✔
244
        }
245

246
        if (is_int($value) || is_string($value)) {
3✔
247
            $boolean = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
3✔
248

249
            if ($boolean !== null) {
3✔
250
                return $boolean;
2✔
251
            }
252
        }
253

254
        throw $this->invalidValidatedType($key, 'boolean');
1✔
255
    }
256

257
    /**
258
     * Returns a validated field as a Time instance.
259
     *
260
     * Supports dot-array syntax for nested validated data.
261
     */
262
    public function date(
263
        string $key,
264
        ?string $format = null,
265
        DateTimeZone|string|null $timezone = null,
266
    ): ?Time {
267
        $value = $this->getValidated($key);
4✔
268

269
        if ($value === null) {
4✔
270
            return null;
1✔
271
        }
272

273
        if (! is_string($value) || $value === '') {
3✔
274
            throw $this->invalidValidatedType($key, 'date');
1✔
275
        }
276

277
        try {
278
            if ($format === null) {
2✔
279
                return Time::parse($value, $timezone);
1✔
280
            }
281

282
            return Time::createFromFormat($format, $value, $timezone);
1✔
NEW
UNCOV
283
        } catch (Exception) {
×
NEW
UNCOV
284
            throw $this->invalidValidatedType($key, 'date');
×
285
        }
286
    }
287

288
    /**
289
     * Returns a validated field as an enum instance.
290
     *
291
     * Supports dot-array syntax for nested validated data.
292
     *
293
     * @template TEnum of UnitEnum
294
     *
295
     * @param class-string<TEnum> $enumClass
296
     * @param TEnum|null          $default
297
     *
298
     * @return TEnum|null
299
     */
300
    public function enum(string $key, string $enumClass, ?UnitEnum $default = null): ?UnitEnum
301
    {
302
        if (! enum_exists($enumClass)) {
6✔
NEW
UNCOV
303
            throw new InvalidArgumentException('The "' . $enumClass . '" class is not a valid enum.');
×
304
        }
305

306
        $value = $this->getValidated($key, $default);
6✔
307

308
        if ($value === null) {
6✔
309
            return null;
1✔
310
        }
311

312
        if ($value instanceof UnitEnum) {
5✔
313
            if ($value instanceof $enumClass) {
1✔
314
                return $value;
1✔
315
            }
316

NEW
UNCOV
317
            throw $this->invalidValidatedType($key, $enumClass);
×
318
        }
319

320
        $reflection = new ReflectionEnum($enumClass);
4✔
321

322
        if ($reflection->isBacked()) {
4✔
323
            return $this->backedEnum($key, $enumClass, $reflection, $value);
3✔
324
        }
325

326
        if (is_string($value)) {
1✔
327
            foreach ($enumClass::cases() as $case) {
1✔
328
                if ($case->name === $value) {
1✔
329
                    return $case;
1✔
330
                }
331
            }
332
        }
333

NEW
UNCOV
334
        throw $this->invalidValidatedType($key, $enumClass);
×
335
    }
336

337
    /**
338
     * Returns true when the named field exists in the validated data, even if
339
     * its value is null.
340
     *
341
     * Supports dot-array syntax for nested validated data.
342
     */
343
    public function hasValidated(string $key): bool
344
    {
345
        helper('array');
4✔
346

347
        return dot_array_has($key, $this->validatedData);
4✔
348
    }
349

350
    private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum
351
    {
352
        $backingType = $reflection->getBackingType()?->getName();
3✔
353

354
        if ($backingType === 'int') {
3✔
355
            if (is_string($value)) {
1✔
356
                $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE);
1✔
357
            }
358

359
            if (! is_int($value)) {
1✔
NEW
UNCOV
360
                throw $this->invalidValidatedType($key, $enumClass);
×
361
            }
362
        } elseif (! is_int($value) && ! is_string($value)) {
2✔
NEW
UNCOV
363
            throw $this->invalidValidatedType($key, $enumClass);
×
364
        }
365

366
        if (! is_subclass_of($enumClass, BackedEnum::class)) {
3✔
NEW
UNCOV
367
            throw $this->invalidValidatedType($key, $enumClass);
×
368
        }
369

370
        if ($backingType === 'string') {
3✔
371
            $value = (string) $value;
2✔
372
        }
373

374
        $enum = $enumClass::tryFrom($value);
3✔
375

376
        if ($enum === null) {
3✔
377
            throw $this->invalidValidatedType($key, $enumClass);
1✔
378
        }
379

380
        return $enum;
2✔
381
    }
382

383
    private function invalidValidatedType(string $key, string $type): InvalidArgumentException
384
    {
385
        return new InvalidArgumentException(
4✔
386
            sprintf('The validated "%s" value cannot be read as %s.', $key, $type),
4✔
387
        );
4✔
388
    }
389

390
    /**
391
     * Returns the data to be validated.
392
     *
393
     * Override this method to provide custom data or to merge data from
394
     * multiple sources. By default, data is sourced from the appropriate
395
     * part of the request based on HTTP method and Content-Type:
396
     *
397
     * - JSON (any method)      - decoded JSON body
398
     * - PUT / PATCH / DELETE   - raw body (unless multipart/form-data)
399
     * - GET / HEAD             - query-string parameters
400
     * - Everything else (POST) - POST body
401
     *
402
     * @return array<string, mixed>
403
     */
404
    protected function validationData(): array
405
    {
406
        $contentType = $this->request->getHeaderLine('Content-Type');
39✔
407

408
        if (str_contains($contentType, 'application/json')) {
39✔
409
            return $this->request->getJSON(true) ?? [];
5✔
410
        }
411

412
        if (
413
            in_array($this->request->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true)
34✔
414
            && ! str_contains($contentType, 'multipart/form-data')
34✔
415
        ) {
UNCOV
416
            return $this->request->getRawInput() ?? [];
×
417
        }
418

419
        if (in_array($this->request->getMethod(), [Method::GET, Method::HEAD], true)) {
34✔
UNCOV
420
            return $this->request->getGet() ?? [];
×
421
        }
422

423
        return $this->request->getPost() ?? [];
34✔
424
    }
425

426
    /**
427
     * Runs authorization and validation. Called by the framework before
428
     * injecting the FormRequest into the controller method.
429
     *
430
     * Returns null on success, or a ResponseInterface to short-circuit the
431
     * request when authorization or validation fails.
432
     *
433
     * Do not call this method directly unless you are inside a ``_remap()``
434
     * method, where automatic injection is not available.
435
     */
436
    final public function resolveRequest(): ?ResponseInterface
437
    {
438
        if (! $this->isAuthorized()) {
44✔
439
            return $this->failedAuthorization();
4✔
440
        }
441

442
        $data = $this->prepareForValidation($this->validationData());
40✔
443

444
        $validation = service('validation')
40✔
445
            ->setRules($this->rules(), $this->messages());
40✔
446

447
        if (! $validation->run($data)) {
40✔
448
            return $this->failedValidation($validation->getErrors());
9✔
449
        }
450

451
        $this->validatedData = $validation->getValidated();
31✔
452

453
        return null;
31✔
454
    }
455
}
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