From ed9d8b6f2ef176b6a21fb30fd79e2a356996fb45 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 4 May 2026 18:41:08 +0200 Subject: [PATCH 1/3] feat(http): add typed FormRequest accessors - Add typed helpers for reading validated integer, boolean, date, and enum values - Keep accessors scoped to validated FormRequest data only - Document expected validation/accessor responsibilities - Cover defaults, null values, invalid values, dot syntax, and enum variants Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/HTTP/FormRequest.php | 175 +++++++++++ tests/system/HTTP/FormRequestTest.php | 271 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 2 +- .../source/incoming/form_requests.rst | 34 +++ .../source/incoming/form_requests/015.php | 8 + 5 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 user_guide_src/source/incoming/form_requests/015.php diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 5e831e69be5d..667dac5e9f00 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -13,9 +13,16 @@ namespace CodeIgniter\HTTP; +use BackedEnum; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\I18n\Time; +use DateTimeZone; +use Exception; +use ReflectionEnum; use ReflectionNamedType; use ReflectionParameter; +use UnitEnum; /** * @see \CodeIgniter\HTTP\FormRequestTest @@ -199,6 +206,134 @@ public function getValidated(string $key, mixed $default = null): mixed return dot_array_search($key, $this->validatedData); } + /** + * Returns a validated field as an integer. + * + * Supports dot-array syntax for nested validated data. + */ + public function integer(string $key, ?int $default = null): ?int + { + $value = $this->getValidated($key, $default); + + if ($value === null || is_int($value)) { + return $value; + } + + if (is_string($value)) { + $integer = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + + if ($integer !== null) { + return $integer; + } + } + + throw $this->invalidValidatedType($key, 'integer'); + } + + /** + * Returns a validated field as a boolean. + * + * Supports dot-array syntax for nested validated data. + */ + public function boolean(string $key, ?bool $default = null): ?bool + { + $value = $this->getValidated($key, $default); + + if ($value === null || is_bool($value)) { + return $value; + } + + if (is_int($value) || is_string($value)) { + $boolean = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($boolean !== null) { + return $boolean; + } + } + + throw $this->invalidValidatedType($key, 'boolean'); + } + + /** + * Returns a validated field as a Time instance. + * + * Supports dot-array syntax for nested validated data. + */ + public function date( + string $key, + ?string $format = null, + DateTimeZone|string|null $timezone = null, + ): ?Time { + $value = $this->getValidated($key); + + if ($value === null) { + return null; + } + + if (! is_string($value) || $value === '') { + throw $this->invalidValidatedType($key, 'date'); + } + + try { + if ($format === null) { + return Time::parse($value, $timezone); + } + + return Time::createFromFormat($format, $value, $timezone); + } catch (Exception) { + throw $this->invalidValidatedType($key, 'date'); + } + } + + /** + * Returns a validated field as an enum instance. + * + * Supports dot-array syntax for nested validated data. + * + * @template TEnum of UnitEnum + * + * @param class-string $enumClass + * @param TEnum|null $default + * + * @return TEnum|null + */ + public function enum(string $key, string $enumClass, ?UnitEnum $default = null): ?UnitEnum + { + if (! enum_exists($enumClass)) { + throw new InvalidArgumentException('The "' . $enumClass . '" class is not a valid enum.'); + } + + $value = $this->getValidated($key, $default); + + if ($value === null) { + return null; + } + + if ($value instanceof UnitEnum) { + if ($value instanceof $enumClass) { + return $value; + } + + throw $this->invalidValidatedType($key, $enumClass); + } + + $reflection = new ReflectionEnum($enumClass); + + if ($reflection->isBacked()) { + return $this->backedEnum($key, $enumClass, $reflection, $value); + } + + if (is_string($value)) { + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + } + + throw $this->invalidValidatedType($key, $enumClass); + } + /** * Returns true when the named field exists in the validated data, even if * its value is null. @@ -212,6 +347,46 @@ public function hasValidated(string $key): bool return dot_array_has($key, $this->validatedData); } + private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum + { + $backingType = $reflection->getBackingType()?->getName(); + + if ($backingType === 'int') { + if (is_string($value)) { + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } + + if (! is_int($value)) { + throw $this->invalidValidatedType($key, $enumClass); + } + } elseif (! is_int($value) && ! is_string($value)) { + throw $this->invalidValidatedType($key, $enumClass); + } + + if (! is_subclass_of($enumClass, BackedEnum::class)) { + throw $this->invalidValidatedType($key, $enumClass); + } + + if ($backingType === 'string') { + $value = (string) $value; + } + + $enum = $enumClass::tryFrom($value); + + if ($enum === null) { + throw $this->invalidValidatedType($key, $enumClass); + } + + return $enum; + } + + private function invalidValidatedType(string $key, string $type): InvalidArgumentException + { + return new InvalidArgumentException( + sprintf('The validated "%s" value cannot be read as %s.', $key, $type), + ); + } + /** * Returns the data to be validated. * diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index 768ef9898fe0..141f2dd0cbbf 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -14,7 +14,9 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Config\Services; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\I18n\Time; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; @@ -23,6 +25,9 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use Tests\Support\Controllers\FormRequestController; +use Tests\Support\Enum\ColorEnum; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; use Tests\Support\HTTP\Requests\ValidPostFormRequest; /** @@ -298,6 +303,272 @@ public function rules(): array $this->assertTrue($formRequest->hasValidated('note')); } + public function testIntegerReturnsValidatedInteger(): void + { + service('superglobals')->setPost('page', '15'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['page' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(15, $formRequest->integer('page')); + } + + public function testIntegerReturnsDefaultForMissingValidatedField(): void + { + $formRequest = new ValidPostFormRequest($this->makeRequest()); + + $this->assertSame(1, $formRequest->integer('page', 1)); + } + + public function testIntegerSupportsDotSyntax(): void + { + service('superglobals')->setPost('filters', ['page' => '2']); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['filters.page' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(2, $formRequest->integer('filters.page')); + } + + public function testIntegerThrowsForInvalidValidatedValue(): void + { + service('superglobals')->setPost('page', '1.5'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['page' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "page" value cannot be read as integer.'); + + $formRequest->integer('page'); + } + + public function testBooleanReturnsValidatedBoolean(): void + { + service('superglobals')->setPost('active', 'true'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['active' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertTrue($formRequest->boolean('active')); + } + + public function testBooleanReturnsFalseForValidatedFalseString(): void + { + service('superglobals')->setPost('active', 'false'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['active' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertFalse($formRequest->boolean('active')); + } + + public function testBooleanReturnsDefaultForMissingValidatedField(): void + { + $formRequest = new ValidPostFormRequest($this->makeRequest()); + + $this->assertFalse($formRequest->boolean('active', false)); + } + + public function testBooleanThrowsForInvalidValidatedValue(): void + { + service('superglobals')->setPost('active', 'sometimes'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['active' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "active" value cannot be read as boolean.'); + + $formRequest->boolean('active'); + } + + public function testDateReturnsValidatedTime(): void + { + service('superglobals')->setPost('published_at', '2026-05-04'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['published_at' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertInstanceOf(Time::class, $formRequest->date('published_at')); + $this->assertSame('2026-05-04', $formRequest->date('published_at')->toDateString()); + } + + public function testDateSupportsCustomFormat(): void + { + service('superglobals')->setPost('published_at', '04/05/2026'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['published_at' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame('2026-05-04', $formRequest->date('published_at', 'd/m/Y')->toDateString()); + } + + public function testDateThrowsForInvalidValidatedValue(): void + { + service('superglobals')->setPost('published_at', ''); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['published_at' => 'permit_empty']; + } + }; + + $formRequest->resolveRequest(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "published_at" value cannot be read as date.'); + + $formRequest->date('published_at'); + } + + public function testEnumReturnsValidatedStringBackedEnum(): void + { + service('superglobals')->setPost('status', 'active'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['status' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(StatusEnum::ACTIVE, $formRequest->enum('status', StatusEnum::class)); + } + + public function testEnumReturnsValidatedIntBackedEnum(): void + { + service('superglobals')->setPost('role', '2'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['role' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(RoleEnum::ADMIN, $formRequest->enum('role', RoleEnum::class)); + } + + public function testEnumReturnsValidatedUnitEnum(): void + { + service('superglobals')->setPost('color', 'GREEN'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['color' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(ColorEnum::GREEN, $formRequest->enum('color', ColorEnum::class)); + } + + public function testEnumReturnsDefaultForMissingValidatedField(): void + { + $formRequest = new ValidPostFormRequest($this->makeRequest()); + + $this->assertSame(StatusEnum::PENDING, $formRequest->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } + + public function testEnumThrowsForInvalidValidatedValue(): void + { + service('superglobals')->setPost('status', 'archived'); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return ['status' => 'required']; + } + }; + + $formRequest->resolveRequest(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); + + $formRequest->enum('status', StatusEnum::class); + } + + public function testTypedAccessorsReturnNullForNullValidatedFields(): void + { + service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); + + $formRequest = new class ($this->makeRequest('{"page":null,"active":null,"published_at":null,"status":null}')) extends FormRequest { + public function rules(): array + { + return [ + 'page' => 'permit_empty', + 'active' => 'permit_empty', + 'published_at' => 'permit_empty', + 'status' => 'permit_empty', + ]; + } + }; + + $formRequest->resolveRequest(); + + $this->assertNull($formRequest->integer('page', 1)); + $this->assertNull($formRequest->boolean('active', false)); + $this->assertNotInstanceOf(Time::class, $formRequest->date('published_at')); + $this->assertNotInstanceOf(StatusEnum::class, $formRequest->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } + // ------------------------------------------------------------------------- // prepareForValidation hook // ------------------------------------------------------------------------- diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index ca51e62004e9..3eb3863f5200 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -239,7 +239,7 @@ Helpers and Functions HTTP ==== -- Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, and authorization logic for a single HTTP request. +- Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, authorization logic and typed validated accessors for a single HTTP request. - Added ``SSEResponse`` class for streaming Server-Sent Events (SSE) over HTTP. See :ref:`server-sent-events`. - ``Response`` and its child classes no longer require ``Config\App`` passed to their constructors. Consequently, ``CURLRequest``'s ``$config`` parameter is unused and will be removed in a future release. diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst index 81f44add9d01..fa582606ec4c 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -63,6 +63,40 @@ to check whether a validated key exists, including keys whose value is .. literalinclude:: form_requests/014.php :lines: 2- +Typed Validated Accessors +========================= + +FormRequest also provides small typed accessors for common controller inputs: + +.. literalinclude:: form_requests/015.php + :lines: 2- + +These methods read from the validated data only. They do not replace validation +rules; they only make successfully validated values easier to consume in the +controller. ``integer()``, ``boolean()``, and ``enum()`` return the provided +default value when the field is missing, or ``null`` when no default is given. +``date()`` returns ``null`` when the field is missing. Fields that are present +with a ``null`` value return ``null``. + +If a present value cannot be read as the requested type, an +``InvalidArgumentException`` is thrown. This usually means the validation rules +and the controller's expected type do not match. Use validation rules such as +``integer``, ``valid_date``, ``in_list``, or a custom rule to ensure the value +matches the type you plan to read. + +The ``date()`` method returns a :php:class:`CodeIgniter\\I18n\\Time` instance. +Pass a format when the value should be parsed with a specific date format. The +method only parses the value; validation rules should enforce acceptable date +formats and ranges. For strict calendar validation, add a rule such as +``valid_date[Y-m-d]``. + +The ``enum()`` method accepts PHP enum class names. Backed enums are matched by +their backing value, while unit enums are matched by case name. + +The ``boolean()`` method uses PHP's boolean validation behavior, so common form +values like ``"1"``, ``"0"``, ``"true"``, ``"false"``, ``"yes"``, ``"no"``, +``"on"``, and ``"off"`` are accepted. + Accessing Other Request Data ============================ diff --git a/user_guide_src/source/incoming/form_requests/015.php b/user_guide_src/source/incoming/form_requests/015.php new file mode 100644 index 000000000000..8649499f19a4 --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/015.php @@ -0,0 +1,8 @@ +integer('page', 1); +$active = $request->boolean('active', false); +$publishedAt = $request->date('published_at', 'Y-m-d'); +$status = $request->enum('status', PostStatus::class); From d7067f3ce104853dd69e2a81f61147b7c1892413 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 5 May 2026 21:56:22 +0200 Subject: [PATCH 2/3] refactor(validation): move typed access to ValidatedInput - add ValidatedInput for typed access to validated data - expose ValidatedInput from Validation and FormRequest - keep FormRequest focused on request validation flow - update docs and tests for the new typed input API Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/HTTP/FormRequest.php | 183 +----------- system/HTTP/ValidatedInput.php | 237 +++++++++++++++ system/Validation/Validation.php | 9 + system/Validation/ValidationInterface.php | 6 + tests/system/HTTP/FormRequestTest.php | 269 +----------------- tests/system/HTTP/ValidatedInputTest.php | 215 ++++++++++++++ tests/system/Validation/ValidationTest.php | 12 + user_guide_src/source/changelogs/v4.8.0.rst | 4 +- .../source/incoming/form_requests.rst | 42 +-- .../source/incoming/form_requests/015.php | 10 +- .../source/libraries/validation.rst | 55 ++++ .../source/libraries/validation/048.php | 30 ++ 12 files changed, 600 insertions(+), 472 deletions(-) create mode 100644 system/HTTP/ValidatedInput.php create mode 100644 tests/system/HTTP/ValidatedInputTest.php create mode 100644 user_guide_src/source/libraries/validation/048.php diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 667dac5e9f00..23506220ae49 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -13,16 +13,9 @@ namespace CodeIgniter\HTTP; -use BackedEnum; -use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\RuntimeException; -use CodeIgniter\I18n\Time; -use DateTimeZone; -use Exception; -use ReflectionEnum; use ReflectionNamedType; use ReflectionParameter; -use UnitEnum; /** * @see \CodeIgniter\HTTP\FormRequestTest @@ -189,6 +182,14 @@ public function validated(): array return $this->validatedData; } + /** + * Returns the validated data as a typed input object. + */ + public function validatedInput(): ValidatedInput + { + return new ValidatedInput($this->validatedData); + } + /** * Returns a single validated field value by name, or the default value * if the field is not present in the validated data. @@ -206,134 +207,6 @@ public function getValidated(string $key, mixed $default = null): mixed return dot_array_search($key, $this->validatedData); } - /** - * Returns a validated field as an integer. - * - * Supports dot-array syntax for nested validated data. - */ - public function integer(string $key, ?int $default = null): ?int - { - $value = $this->getValidated($key, $default); - - if ($value === null || is_int($value)) { - return $value; - } - - if (is_string($value)) { - $integer = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); - - if ($integer !== null) { - return $integer; - } - } - - throw $this->invalidValidatedType($key, 'integer'); - } - - /** - * Returns a validated field as a boolean. - * - * Supports dot-array syntax for nested validated data. - */ - public function boolean(string $key, ?bool $default = null): ?bool - { - $value = $this->getValidated($key, $default); - - if ($value === null || is_bool($value)) { - return $value; - } - - if (is_int($value) || is_string($value)) { - $boolean = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - - if ($boolean !== null) { - return $boolean; - } - } - - throw $this->invalidValidatedType($key, 'boolean'); - } - - /** - * Returns a validated field as a Time instance. - * - * Supports dot-array syntax for nested validated data. - */ - public function date( - string $key, - ?string $format = null, - DateTimeZone|string|null $timezone = null, - ): ?Time { - $value = $this->getValidated($key); - - if ($value === null) { - return null; - } - - if (! is_string($value) || $value === '') { - throw $this->invalidValidatedType($key, 'date'); - } - - try { - if ($format === null) { - return Time::parse($value, $timezone); - } - - return Time::createFromFormat($format, $value, $timezone); - } catch (Exception) { - throw $this->invalidValidatedType($key, 'date'); - } - } - - /** - * Returns a validated field as an enum instance. - * - * Supports dot-array syntax for nested validated data. - * - * @template TEnum of UnitEnum - * - * @param class-string $enumClass - * @param TEnum|null $default - * - * @return TEnum|null - */ - public function enum(string $key, string $enumClass, ?UnitEnum $default = null): ?UnitEnum - { - if (! enum_exists($enumClass)) { - throw new InvalidArgumentException('The "' . $enumClass . '" class is not a valid enum.'); - } - - $value = $this->getValidated($key, $default); - - if ($value === null) { - return null; - } - - if ($value instanceof UnitEnum) { - if ($value instanceof $enumClass) { - return $value; - } - - throw $this->invalidValidatedType($key, $enumClass); - } - - $reflection = new ReflectionEnum($enumClass); - - if ($reflection->isBacked()) { - return $this->backedEnum($key, $enumClass, $reflection, $value); - } - - if (is_string($value)) { - foreach ($enumClass::cases() as $case) { - if ($case->name === $value) { - return $case; - } - } - } - - throw $this->invalidValidatedType($key, $enumClass); - } - /** * Returns true when the named field exists in the validated data, even if * its value is null. @@ -347,46 +220,6 @@ public function hasValidated(string $key): bool return dot_array_has($key, $this->validatedData); } - private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum - { - $backingType = $reflection->getBackingType()?->getName(); - - if ($backingType === 'int') { - if (is_string($value)) { - $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); - } - - if (! is_int($value)) { - throw $this->invalidValidatedType($key, $enumClass); - } - } elseif (! is_int($value) && ! is_string($value)) { - throw $this->invalidValidatedType($key, $enumClass); - } - - if (! is_subclass_of($enumClass, BackedEnum::class)) { - throw $this->invalidValidatedType($key, $enumClass); - } - - if ($backingType === 'string') { - $value = (string) $value; - } - - $enum = $enumClass::tryFrom($value); - - if ($enum === null) { - throw $this->invalidValidatedType($key, $enumClass); - } - - return $enum; - } - - private function invalidValidatedType(string $key, string $type): InvalidArgumentException - { - return new InvalidArgumentException( - sprintf('The validated "%s" value cannot be read as %s.', $key, $type), - ); - } - /** * Returns the data to be validated. * diff --git a/system/HTTP/ValidatedInput.php b/system/HTTP/ValidatedInput.php new file mode 100644 index 000000000000..bb77cb0d9b34 --- /dev/null +++ b/system/HTTP/ValidatedInput.php @@ -0,0 +1,237 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use BackedEnum; +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use DateTimeZone; +use Exception; +use ReflectionEnum; +use UnitEnum; + +/** + * @see \CodeIgniter\HTTP\ValidatedInputTest + */ +class ValidatedInput +{ + /** + * @param array $data + */ + public function __construct(private readonly array $data) + { + } + + /** + * Returns a single validated field value by name, or the default value + * if the field is not present in the validated data. + * + * Supports dot-array syntax for nested validated data. + */ + public function get(string $key, mixed $default = null): mixed + { + helper('array'); + + if (! dot_array_has($key, $this->data)) { + return $default; + } + + return dot_array_search($key, $this->data); + } + + /** + * Returns true when the named field exists in the validated data, even if + * its value is null. + * + * Supports dot-array syntax for nested validated data. + */ + public function has(string $key): bool + { + helper('array'); + + return dot_array_has($key, $this->data); + } + + /** + * Returns a validated field as an integer. + * + * Supports dot-array syntax for nested validated data. + */ + public function integer(string $key, ?int $default = null): ?int + { + $value = $this->get($key, $default); + + if ($value === null || is_int($value)) { + return $value; + } + + if (is_string($value)) { + $integer = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + + if ($integer !== null) { + return $integer; + } + } + + throw $this->invalidType($key, 'integer'); + } + + /** + * Returns a validated field as a boolean. + * + * Supports dot-array syntax for nested validated data. + */ + public function boolean(string $key, ?bool $default = null): ?bool + { + $value = $this->get($key, $default); + + if ($value === null || is_bool($value)) { + return $value; + } + + if (is_int($value) || is_string($value)) { + $boolean = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($boolean !== null) { + return $boolean; + } + } + + throw $this->invalidType($key, 'boolean'); + } + + /** + * Returns a validated field as a Time instance. + * + * Supports dot-array syntax for nested validated data. + */ + public function date( + string $key, + ?string $format = null, + DateTimeZone|string|null $timezone = null, + ): ?Time { + $value = $this->get($key); + + if ($value === null) { + return null; + } + + if (! is_string($value) || $value === '') { + throw $this->invalidType($key, 'date'); + } + + try { + if ($format === null) { + return Time::parse($value, $timezone); + } + + return Time::createFromFormat($format, $value, $timezone); + } catch (Exception) { + throw $this->invalidType($key, 'date'); + } + } + + /** + * Returns a validated field as an enum instance. + * + * Supports dot-array syntax for nested validated data. + * + * @template TEnum of UnitEnum + * + * @param class-string $enumClass + * @param TEnum|null $default + * + * @return TEnum|null + */ + public function enum(string $key, string $enumClass, ?UnitEnum $default = null): ?UnitEnum + { + if (! enum_exists($enumClass)) { + throw new InvalidArgumentException('The "' . $enumClass . '" class is not a valid enum.'); + } + + if ($default instanceof UnitEnum && ! $default instanceof $enumClass) { + throw $this->invalidType($key, $enumClass); + } + + $value = $this->get($key, $default); + + if ($value === null) { + return null; + } + + if ($value instanceof UnitEnum) { + if ($value instanceof $enumClass) { + return $value; + } + + throw $this->invalidType($key, $enumClass); + } + + $reflection = new ReflectionEnum($enumClass); + + if ($reflection->isBacked()) { + return $this->backedEnum($key, $enumClass, $reflection, $value); + } + + if (is_string($value)) { + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + } + + throw $this->invalidType($key, $enumClass); + } + + private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum + { + $backingType = $reflection->getBackingType()?->getName(); + + if ($backingType === 'int') { + if (is_string($value)) { + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } + + if (! is_int($value)) { + throw $this->invalidType($key, $enumClass); + } + } elseif (! is_int($value) && ! is_string($value)) { + throw $this->invalidType($key, $enumClass); + } + + if (! is_subclass_of($enumClass, BackedEnum::class)) { + throw $this->invalidType($key, $enumClass); + } + + if ($backingType === 'string') { + $value = (string) $value; + } + + $enum = $enumClass::tryFrom($value); + + if ($enum === null) { + throw $this->invalidType($key, $enumClass); + } + + return $enum; + } + + private function invalidType(string $key, string $type): InvalidArgumentException + { + return new InvalidArgumentException( + sprintf('The validated "%s" value cannot be read as %s.', $key, $type), + ); + } +} diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 52325bf2b6ef..ad0bd658ea7f 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -21,6 +21,7 @@ use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ValidatedInput; use CodeIgniter\Validation\Exceptions\ValidationException; use CodeIgniter\View\RendererInterface; @@ -271,6 +272,14 @@ public function getValidated(): array return $this->validated; } + /** + * Returns the actual validated data as a typed input object. + */ + public function getValidatedInput(): ValidatedInput + { + return new ValidatedInput($this->validated); + } + /** * Runs all of $rules against $field, until one fails, or * all of them have been processed. If one fails, it adds diff --git a/system/Validation/ValidationInterface.php b/system/Validation/ValidationInterface.php index 997bfb2bc0a4..50ad00d2d6ec 100644 --- a/system/Validation/ValidationInterface.php +++ b/system/Validation/ValidationInterface.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ValidatedInput; /** * Expected behavior of a validator @@ -162,4 +163,9 @@ public function showError(string $field, string $template = 'single'): string; * Returns the actual validated data. */ public function getValidated(): array; + + /** + * Returns the actual validated data as a typed input object. + */ + public function getValidatedInput(): ValidatedInput; } diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index 141f2dd0cbbf..a5d5e20d7384 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -14,9 +14,7 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Config\Services; -use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Exceptions\RuntimeException; -use CodeIgniter\I18n\Time; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; @@ -25,9 +23,6 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use Tests\Support\Controllers\FormRequestController; -use Tests\Support\Enum\ColorEnum; -use Tests\Support\Enum\RoleEnum; -use Tests\Support\Enum\StatusEnum; use Tests\Support\HTTP\Requests\ValidPostFormRequest; /** @@ -303,270 +298,18 @@ public function rules(): array $this->assertTrue($formRequest->hasValidated('note')); } - public function testIntegerReturnsValidatedInteger(): void + public function testValidatedInputReturnsValidatedInputObject(): void { - service('superglobals')->setPost('page', '15'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['page' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertSame(15, $formRequest->integer('page')); - } - - public function testIntegerReturnsDefaultForMissingValidatedField(): void - { - $formRequest = new ValidPostFormRequest($this->makeRequest()); - - $this->assertSame(1, $formRequest->integer('page', 1)); - } - - public function testIntegerSupportsDotSyntax(): void - { - service('superglobals')->setPost('filters', ['page' => '2']); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['filters.page' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertSame(2, $formRequest->integer('filters.page')); - } - - public function testIntegerThrowsForInvalidValidatedValue(): void - { - service('superglobals')->setPost('page', '1.5'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['page' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The validated "page" value cannot be read as integer.'); - - $formRequest->integer('page'); - } - - public function testBooleanReturnsValidatedBoolean(): void - { - service('superglobals')->setPost('active', 'true'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['active' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertTrue($formRequest->boolean('active')); - } - - public function testBooleanReturnsFalseForValidatedFalseString(): void - { - service('superglobals')->setPost('active', 'false'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['active' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertFalse($formRequest->boolean('active')); - } - - public function testBooleanReturnsDefaultForMissingValidatedField(): void - { - $formRequest = new ValidPostFormRequest($this->makeRequest()); - - $this->assertFalse($formRequest->boolean('active', false)); - } - - public function testBooleanThrowsForInvalidValidatedValue(): void - { - service('superglobals')->setPost('active', 'sometimes'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['active' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The validated "active" value cannot be read as boolean.'); - - $formRequest->boolean('active'); - } - - public function testDateReturnsValidatedTime(): void - { - service('superglobals')->setPost('published_at', '2026-05-04'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['published_at' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertInstanceOf(Time::class, $formRequest->date('published_at')); - $this->assertSame('2026-05-04', $formRequest->date('published_at')->toDateString()); - } - - public function testDateSupportsCustomFormat(): void - { - service('superglobals')->setPost('published_at', '04/05/2026'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['published_at' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertSame('2026-05-04', $formRequest->date('published_at', 'd/m/Y')->toDateString()); - } - - public function testDateThrowsForInvalidValidatedValue(): void - { - service('superglobals')->setPost('published_at', ''); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['published_at' => 'permit_empty']; - } - }; - - $formRequest->resolveRequest(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The validated "published_at" value cannot be read as date.'); - - $formRequest->date('published_at'); - } - - public function testEnumReturnsValidatedStringBackedEnum(): void - { - service('superglobals')->setPost('status', 'active'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['status' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertSame(StatusEnum::ACTIVE, $formRequest->enum('status', StatusEnum::class)); - } - - public function testEnumReturnsValidatedIntBackedEnum(): void - { - service('superglobals')->setPost('role', '2'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['role' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertSame(RoleEnum::ADMIN, $formRequest->enum('role', RoleEnum::class)); - } - - public function testEnumReturnsValidatedUnitEnum(): void - { - service('superglobals')->setPost('color', 'GREEN'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['color' => 'required']; - } - }; - - $formRequest->resolveRequest(); - - $this->assertSame(ColorEnum::GREEN, $formRequest->enum('color', ColorEnum::class)); - } + service('superglobals')->setPost('title', 'Hello World'); + service('superglobals')->setPost('body', 'Some body text'); - public function testEnumReturnsDefaultForMissingValidatedField(): void - { $formRequest = new ValidPostFormRequest($this->makeRequest()); - - $this->assertSame(StatusEnum::PENDING, $formRequest->enum('status', StatusEnum::class, StatusEnum::PENDING)); - } - - public function testEnumThrowsForInvalidValidatedValue(): void - { - service('superglobals')->setPost('status', 'archived'); - - $formRequest = new class ($this->makeRequest()) extends FormRequest { - public function rules(): array - { - return ['status' => 'required']; - } - }; - $formRequest->resolveRequest(); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); - - $formRequest->enum('status', StatusEnum::class); - } - - public function testTypedAccessorsReturnNullForNullValidatedFields(): void - { - service('superglobals')->setServer('CONTENT_TYPE', 'application/json'); - - $formRequest = new class ($this->makeRequest('{"page":null,"active":null,"published_at":null,"status":null}')) extends FormRequest { - public function rules(): array - { - return [ - 'page' => 'permit_empty', - 'active' => 'permit_empty', - 'published_at' => 'permit_empty', - 'status' => 'permit_empty', - ]; - } - }; - - $formRequest->resolveRequest(); + $input = $formRequest->validatedInput(); - $this->assertNull($formRequest->integer('page', 1)); - $this->assertNull($formRequest->boolean('active', false)); - $this->assertNotInstanceOf(Time::class, $formRequest->date('published_at')); - $this->assertNotInstanceOf(StatusEnum::class, $formRequest->enum('status', StatusEnum::class, StatusEnum::PENDING)); + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame('Hello World', $input->get('title')); } // ------------------------------------------------------------------------- diff --git a/tests/system/HTTP/ValidatedInputTest.php b/tests/system/HTTP/ValidatedInputTest.php new file mode 100644 index 000000000000..3db24d15ed03 --- /dev/null +++ b/tests/system/HTTP/ValidatedInputTest.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\I18n\Time; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Enum\ColorEnum; +use Tests\Support\Enum\RoleEnum; +use Tests\Support\Enum\StatusEnum; + +/** + * @internal + */ +#[Group('Others')] +final class ValidatedInputTest extends CIUnitTestCase +{ + public function testGetReturnsValidatedFieldValue(): void + { + $input = new ValidatedInput(['title' => 'Hello World']); + + $this->assertSame('Hello World', $input->get('title')); + } + + public function testGetReturnsDefaultForMissingValidatedField(): void + { + $input = new ValidatedInput([]); + + $this->assertSame('fallback', $input->get('title', 'fallback')); + } + + public function testHasReturnsTrueForNullValidatedField(): void + { + $input = new ValidatedInput(['note' => null]); + + $this->assertTrue($input->has('note')); + $this->assertNull($input->get('note', 'fallback')); + } + + public function testGetAndHasSupportDotSyntax(): void + { + $input = new ValidatedInput([ + 'post' => [ + 'meta' => [ + 'slug' => 'hello-world', + ], + ], + ]); + + $this->assertSame('hello-world', $input->get('post.meta.slug')); + $this->assertTrue($input->has('post.meta.slug')); + } + + public function testIntegerReturnsValidatedInteger(): void + { + $input = new ValidatedInput(['page' => '15']); + + $this->assertSame(15, $input->integer('page')); + } + + public function testIntegerReturnsDefaultForMissingValidatedField(): void + { + $input = new ValidatedInput([]); + + $this->assertSame(1, $input->integer('page', 1)); + } + + public function testIntegerSupportsDotSyntax(): void + { + $input = new ValidatedInput(['filters' => ['page' => '2']]); + + $this->assertSame(2, $input->integer('filters.page')); + } + + public function testIntegerThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['page' => '1.5']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "page" value cannot be read as integer.'); + + $input->integer('page'); + } + + public function testBooleanReturnsValidatedBoolean(): void + { + $input = new ValidatedInput(['active' => 'true']); + + $this->assertTrue($input->boolean('active')); + } + + public function testBooleanReturnsFalseForValidatedFalseString(): void + { + $input = new ValidatedInput(['active' => 'false']); + + $this->assertFalse($input->boolean('active')); + } + + public function testBooleanReturnsDefaultForMissingValidatedField(): void + { + $input = new ValidatedInput([]); + + $this->assertFalse($input->boolean('active', false)); + } + + public function testBooleanThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['active' => 'sometimes']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "active" value cannot be read as boolean.'); + + $input->boolean('active'); + } + + public function testDateReturnsValidatedTime(): void + { + $input = new ValidatedInput(['published_at' => '2026-05-04']); + + $this->assertInstanceOf(Time::class, $input->date('published_at')); + $this->assertSame('2026-05-04', $input->date('published_at')->toDateString()); + } + + public function testDateSupportsCustomFormat(): void + { + $input = new ValidatedInput(['published_at' => '04/05/2026']); + + $this->assertSame('2026-05-04', $input->date('published_at', 'd/m/Y')->toDateString()); + } + + public function testDateThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['published_at' => '']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "published_at" value cannot be read as date.'); + + $input->date('published_at'); + } + + public function testEnumReturnsValidatedStringBackedEnum(): void + { + $input = new ValidatedInput(['status' => 'active']); + + $this->assertSame(StatusEnum::ACTIVE, $input->enum('status', StatusEnum::class)); + } + + public function testEnumReturnsValidatedIntBackedEnum(): void + { + $input = new ValidatedInput(['role' => '2']); + + $this->assertSame(RoleEnum::ADMIN, $input->enum('role', RoleEnum::class)); + } + + public function testEnumReturnsValidatedUnitEnum(): void + { + $input = new ValidatedInput(['color' => 'GREEN']); + + $this->assertSame(ColorEnum::GREEN, $input->enum('color', ColorEnum::class)); + } + + public function testEnumReturnsDefaultForMissingValidatedField(): void + { + $input = new ValidatedInput([]); + + $this->assertSame(StatusEnum::PENDING, $input->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } + + public function testEnumThrowsForDefaultFromDifferentEnumClass(): void + { + $input = new ValidatedInput([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); + + $input->enum('status', StatusEnum::class, ColorEnum::GREEN); + } + + public function testEnumThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['status' => 'archived']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "status" value cannot be read as Tests\Support\Enum\StatusEnum.'); + + $input->enum('status', StatusEnum::class); + } + + public function testTypedAccessorsReturnNullForNullValidatedFields(): void + { + $input = new ValidatedInput([ + 'page' => null, + 'active' => null, + 'published_at' => null, + 'status' => null, + ]); + + $this->assertNull($input->integer('page', 1)); + $this->assertNull($input->boolean('active', false)); + $this->assertNotInstanceOf(Time::class, $input->date('published_at')); + $this->assertNotInstanceOf(StatusEnum::class, $input->enum('status', StatusEnum::class, StatusEnum::PENDING)); + } +} diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index e2724bd6ca18..dacb89f9bbbb 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -19,6 +19,7 @@ use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\HTTP\ValidatedInput; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Validation\Exceptions\ValidationException; @@ -253,6 +254,17 @@ public function testRunReturnsFalseWithNothingToDo(): void $this->assertSame([], $this->validation->getValidated()); } + public function testGetValidatedInputReturnsValidatedInputObject(): void + { + $this->validation->setRules(['role' => 'required']); + $this->assertTrue($this->validation->run(['role' => 'administrator'])); + + $input = $this->validation->getValidatedInput(); + + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame('administrator', $input->get('role')); + } + public function testRuleClassesInstantiatedOnce(): void { $this->validation->setRules([]); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 3eb3863f5200..a49829b8684c 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -46,6 +46,7 @@ update your implementations to include the new methods or method changes to ensu - **Database:** ``CodeIgniter\Database\ConnectionInterface`` now requires the ``afterCommit()``, ``afterRollback()``, and ``transaction()`` methods. - **Logging:** ``CodeIgniter\Log\Handlers\HandlerInterface::handle()`` now requires a third parameter ``array $context = []``. Any custom log handler that overrides ``handle()`` - whether implementing ``HandlerInterface`` directly or extending a built-in handler class - must add the parameter to its ``handle()`` method signature. - **Security:** The ``SecurityInterface``'s ``verify()`` method now has a native return type of ``static``. +- **Validation:** ``CodeIgniter\Validation\ValidationInterface`` now requires the ``getValidatedInput()`` method. Method Signature Changes ======================== @@ -239,7 +240,7 @@ Helpers and Functions HTTP ==== -- Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, authorization logic and typed validated accessors for a single HTTP request. +- Added :ref:`Form Requests ` - a new ``FormRequest`` base class that encapsulates validation rules, custom error messages, authorization logic and typed access to validated input for a single HTTP request. - Added ``SSEResponse`` class for streaming Server-Sent Events (SSE) over HTTP. See :ref:`server-sent-events`. - ``Response`` and its child classes no longer require ``Config\App`` passed to their constructors. Consequently, ``CURLRequest``'s ``$config`` parameter is unused and will be removed in a future release. @@ -261,6 +262,7 @@ Validation ========== - Custom rule methods that set an error via the ``&$error`` reference parameter now support the ``{field}``, ``{param}``, and ``{value}`` placeholders, consistent with language-file and ``setRule()``/``setRules()`` error messages. +- Added ``Validation::getValidatedInput()`` to access validated data through a typed input object. Others ====== diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst index fa582606ec4c..f700ab79da81 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -63,39 +63,23 @@ to check whether a validated key exists, including keys whose value is .. literalinclude:: form_requests/014.php :lines: 2- -Typed Validated Accessors -========================= +Typed Validated Input +===================== + +``validatedInput()`` returns the same validated data as a typed input object. +This keeps the array-based APIs unchanged while making common controller values +easier to read after validation has succeeded. -FormRequest also provides small typed accessors for common controller inputs: +After the FormRequest has been validated, read the successful values in the +controller: .. literalinclude:: form_requests/015.php :lines: 2- -These methods read from the validated data only. They do not replace validation -rules; they only make successfully validated values easier to consume in the -controller. ``integer()``, ``boolean()``, and ``enum()`` return the provided -default value when the field is missing, or ``null`` when no default is given. -``date()`` returns ``null`` when the field is missing. Fields that are present -with a ``null`` value return ``null``. - -If a present value cannot be read as the requested type, an -``InvalidArgumentException`` is thrown. This usually means the validation rules -and the controller's expected type do not match. Use validation rules such as -``integer``, ``valid_date``, ``in_list``, or a custom rule to ensure the value -matches the type you plan to read. - -The ``date()`` method returns a :php:class:`CodeIgniter\\I18n\\Time` instance. -Pass a format when the value should be parsed with a specific date format. The -method only parses the value; validation rules should enforce acceptable date -formats and ranges. For strict calendar validation, add a rule such as -``valid_date[Y-m-d]``. - -The ``enum()`` method accepts PHP enum class names. Backed enums are matched by -their backing value, while unit enums are matched by case name. - -The ``boolean()`` method uses PHP's boolean validation behavior, so common form -values like ``"1"``, ``"0"``, ``"true"``, ``"false"``, ``"yes"``, ``"no"``, -``"on"``, and ``"off"`` are accepted. +These typed methods do not replace validation rules. They only make accepted +values easier to consume in the controller. See :ref:`validation-validated-input` +for the full behavior of ``integer()``, ``boolean()``, ``date()``, and +``enum()``. Accessing Other Request Data ============================ @@ -263,7 +247,7 @@ whose type extends ``FormRequest``: #. ``run()`` executes the validation rules. If it fails, ``failedValidation()`` is called, and its response is returned to the client. #. The validated data is stored internally and available via ``validated()``, - ``getValidated()``, and ``hasValidated()``. + ``validatedInput()``, ``getValidated()``, and ``hasValidated()``. #. The resolved FormRequest object is injected into the controller method or closure. diff --git a/user_guide_src/source/incoming/form_requests/015.php b/user_guide_src/source/incoming/form_requests/015.php index 8649499f19a4..b4d06aa897af 100644 --- a/user_guide_src/source/incoming/form_requests/015.php +++ b/user_guide_src/source/incoming/form_requests/015.php @@ -2,7 +2,9 @@ use App\Enums\PostStatus; -$page = $request->integer('page', 1); -$active = $request->boolean('active', false); -$publishedAt = $request->date('published_at', 'Y-m-d'); -$status = $request->enum('status', PostStatus::class); +$input = $request->validatedInput(); + +$page = $input->integer('page', 1); +$active = $input->boolean('active', false); +$publishedAt = $input->date('published_at', 'Y-m-d'); +$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT); diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 13f7f88c51df..73fdb1c40275 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -478,6 +478,61 @@ the validation rules. .. literalinclude:: validation/045.php :lines: 2- +.. _validation-validated-input: + +Typed Validated Input +--------------------- + +``getValidatedInput()`` returns the same validated data as a typed input object. +Use it after validation succeeds when you want to read common controller values +as integers, booleans, dates, or enums: + +.. versionadded:: 4.8.0 + +.. literalinclude:: validation/048.php + :lines: 2- + +Validation decides whether input is acceptable. The typed input object only +helps you consume values that already passed validation. If a present value +cannot be read as the requested type, an ``InvalidArgumentException`` is thrown. +This usually means the validation rules and the expected type do not match. + +The typed input object has the following methods: + +All methods support dot-array syntax for nested validated data. + +* ``get($key, $default = null)`` returns the raw validated value. If the field + is missing, it returns the default value. +* ``has($key)`` returns whether the field exists in the validated data, even if + its value is ``null``. +* ``integer($key, $default = null)`` returns ``int|null``. If the field is + missing, it returns the default value or ``null``. +* ``boolean($key, $default = null)`` returns ``bool|null``. If the field is + missing, it returns the default value or ``null``. +* ``date($key, $format = null, $timezone = null)`` returns + :php:class:`CodeIgniter\\I18n\\Time` or ``null``. Pass a format when the value + should be parsed with a specific date format. +* ``enum($key, $enumClass, $default = null)`` returns an enum instance or + ``null``. The default value must be ``null`` or an instance of the requested + enum class. + +Fields that are present with a ``null`` value return ``null``. This lets you +distinguish a missing optional field from a field that was validated as +``null``. + +Use validation rules such as ``integer``, ``valid_date``, ``in_list``, or a +custom rule to ensure the value matches the type you plan to read. The +``date()`` method only parses the value; validation rules should enforce +acceptable date formats and ranges. For strict calendar validation, add a rule +such as ``valid_date[Y-m-d]``. + +The ``enum()`` method accepts PHP enum class names. Backed enums are matched by +their backing value, while unit enums are matched by case name. + +The ``boolean()`` method uses PHP's boolean validation behavior, so common form +values like ``"1"``, ``"0"``, ``"true"``, ``"false"``, ``"yes"``, ``"no"``, +``"on"``, and ``"off"`` are accepted. + .. _saving-validation-rules-to-config-file: Saving Sets of Validation Rules to the Config File diff --git a/user_guide_src/source/libraries/validation/048.php b/user_guide_src/source/libraries/validation/048.php new file mode 100644 index 000000000000..0b52800ef0f9 --- /dev/null +++ b/user_guide_src/source/libraries/validation/048.php @@ -0,0 +1,30 @@ +setRules([ + 'page' => 'permit_empty|integer', + 'active' => 'permit_empty|in_list[0,1,true,false,yes,no,on,off]', + 'published_at' => 'permit_empty|valid_date[Y-m-d]', + 'status' => 'permit_empty|in_list[draft,published]', +]); + +$data = [ + 'page' => '2', + 'active' => 'true', + 'published_at' => '2026-05-04', + 'status' => 'published', +]; + +if (! $validation->run($data)) { + // The validation failed. + return; +} + +$input = $validation->getValidatedInput(); + +$page = $input->integer('page', 1); +$active = $input->boolean('active', false); +$publishedAt = $input->date('published_at', 'Y-m-d'); +$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT); From 06a737a4bb3cc93d9467ce6d8b99a6419d4ecc93 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 6 May 2026 02:34:43 +0200 Subject: [PATCH 3/3] refactor(http): remove redundant enum guard Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/HTTP/ValidatedInput.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/system/HTTP/ValidatedInput.php b/system/HTTP/ValidatedInput.php index bb77cb0d9b34..6003db03d6f6 100644 --- a/system/HTTP/ValidatedInput.php +++ b/system/HTTP/ValidatedInput.php @@ -13,7 +13,6 @@ namespace CodeIgniter\HTTP; -use BackedEnum; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; use DateTimeZone; @@ -211,10 +210,6 @@ private function backedEnum(string $key, string $enumClass, ReflectionEnum $refl throw $this->invalidType($key, $enumClass); } - if (! is_subclass_of($enumClass, BackedEnum::class)) { - throw $this->invalidType($key, $enumClass); - } - if ($backingType === 'string') { $value = (string) $value; }