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/7] 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/7] 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/7] 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; } From a6350cdf629c80aa409a5bd8ddd36c4dd5c76018 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 6 May 2026 17:41:04 +0200 Subject: [PATCH 4/7] refactor(validation): split typed input access by responsibility - Add generic InputData for typed access to keyed input data - Move validation-specific accessors to ValidatedInput - Return ValidatedInput from Validation and FormRequest APIs - Add non-shared inputdata and validatedinput services - Update docs, tests, changelog, and architecture rules Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- deptrac.yaml | 8 + system/Config/BaseService.php | 4 + system/Config/Services.php | 30 +++ system/HTTP/FormRequest.php | 3 +- system/Input/InputData.php | 149 ++++++++++++++ .../{HTTP => Validation}/ValidatedInput.php | 94 +-------- system/Validation/Validation.php | 3 +- system/Validation/ValidationInterface.php | 1 - tests/system/Config/ServicesTest.php | 20 ++ tests/system/HTTP/FormRequestTest.php | 1 + tests/system/Input/InputDataTest.php | 186 ++++++++++++++++++ .../ValidatedInputTest.php | 101 +--------- tests/system/Validation/ValidationTest.php | 1 - user_guide_src/source/changelogs/v4.8.0.rst | 5 +- .../source/incoming/form_requests.rst | 3 +- .../source/libraries/validation.rst | 15 +- .../source/libraries/validation/048.php | 6 + 17 files changed, 432 insertions(+), 198 deletions(-) create mode 100644 system/Input/InputData.php rename system/{HTTP => Validation}/ValidatedInput.php (61%) create mode 100644 tests/system/Input/InputDataTest.php rename tests/system/{HTTP => Validation}/ValidatedInputTest.php (55%) diff --git a/deptrac.yaml b/deptrac.yaml index 955fda2394ee..eb8daa61f03e 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -78,6 +78,10 @@ deptrac: - type: classNameRegex # includes the Filter value: '/^CodeIgniter\\.*Honeypot.*$/' + - name: Input + collectors: + - type: classNameRegex + value: '/^CodeIgniter\\Input\\.*$/' - name: HTTP collectors: - type: bool @@ -248,6 +252,8 @@ deptrac: - I18n Validation: - HTTP + - I18n + - Input - Database View: - Cache @@ -267,6 +273,8 @@ deptrac: - CodeIgniter\Entity\Entity CodeIgniter\Entity\Cast\URICast: - CodeIgniter\HTTP\URI + CodeIgniter\HTTP\FormRequest: + - CodeIgniter\Validation\ValidatedInput CodeIgniter\Log\Handlers\ChromeLoggerHandler: - CodeIgniter\HTTP\ResponseInterface CodeIgniter\Security\CheckPhpIni: diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 6564bddaf948..fbc08282f2b3 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -46,6 +46,7 @@ use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Input\InputData; use CodeIgniter\Language\Language; use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; @@ -58,6 +59,7 @@ use CodeIgniter\Superglobals; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\ValidatedInput; use CodeIgniter\Validation\ValidationInterface; use CodeIgniter\View\Cell; use CodeIgniter\View\Parser; @@ -120,6 +122,7 @@ * @method static Honeypot honeypot(ConfigHoneyPot $config = null, $getShared = true) * @method static BaseHandler image($handler = null, Images $config = null, $getShared = true) * @method static IncomingRequest incomingrequest(?App $config = null, bool $getShared = true) + * @method static InputData inputdata(array $data = [], bool $getShared = false) * @method static Iterator iterator($getShared = true) * @method static Language language($locale = null, $getShared = true) * @method static LockManager locks(?CacheInterface $cache = null, bool $getShared = true) @@ -144,6 +147,7 @@ * @method static Toolbar toolbar(ConfigToolbar $config = null, $getShared = true) * @method static Typography typography($getShared = true) * @method static URI uri($uri = null, $getShared = true) + * @method static ValidatedInput validatedinput(array $data = [], bool $getShared = false) * @method static ValidationInterface validation(ConfigValidation $config = null, $getShared = true) * @method static Cell viewcell($getShared = true) */ diff --git a/system/Config/Services.php b/system/Config/Services.php index 51244718e221..3e20d1928669 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -46,6 +46,7 @@ use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Images\Handlers\BaseHandler; +use CodeIgniter\Input\InputData; use CodeIgniter\Language\Language; use CodeIgniter\Lock\LockManager; use CodeIgniter\Log\Logger; @@ -62,6 +63,7 @@ use CodeIgniter\Superglobals; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\ValidatedInput; use CodeIgniter\Validation\Validation; use CodeIgniter\Validation\ValidationInterface; use CodeIgniter\View\Cell; @@ -387,6 +389,20 @@ public static function image(?string $handler = null, ?Images $config = null, bo return new $class($config); } + /** + * Returns a typed input data object. + * + * @param array $data + */ + public static function inputdata(array $data = [], bool $getShared = false): InputData + { + if ($getShared) { + return static::getSharedInstance('inputdata', $data); + } + + return new InputData($data); + } + /** * The Iterator class provides a simple way of looping over a function * and timing the results and memory usage. Used when debugging and @@ -873,6 +889,20 @@ public static function validation(?ValidationConfig $config = null, bool $getSha return new Validation($config, AppServices::get('renderer')); } + /** + * Returns a typed validated input object. + * + * @param array $data + */ + public static function validatedinput(array $data = [], bool $getShared = false): ValidatedInput + { + if ($getShared) { + return static::getSharedInstance('validatedinput', $data); + } + + return new ValidatedInput($data); + } + /** * View cells are intended to let you insert HTML into view * that has been generated by any callable in the system. diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 23506220ae49..e35e002c614b 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\Validation\ValidatedInput; use ReflectionNamedType; use ReflectionParameter; @@ -187,7 +188,7 @@ public function validated(): array */ public function validatedInput(): ValidatedInput { - return new ValidatedInput($this->validatedData); + return service('validatedinput', $this->validatedData, false); } /** diff --git a/system/Input/InputData.php b/system/Input/InputData.php new file mode 100644 index 000000000000..c47f76f85342 --- /dev/null +++ b/system/Input/InputData.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * @see \CodeIgniter\Input\InputDataTest + */ +class InputData +{ + /** + * @param array $data + */ + public function __construct(private readonly array $data) + { + } + + /** + * Returns a single input value by name, or the default value if the field + * is not present. + * + * Supports dot-array syntax for nested input 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, even if its value is null. + * + * Supports dot-array syntax for nested input data. + */ + public function has(string $key): bool + { + helper('array'); + + return dot_array_has($key, $this->data); + } + + /** + * Returns an input field as a string. + * + * Supports dot-array syntax for nested input data. + */ + public function string(string $key, ?string $default = null): ?string + { + $value = $this->get($key, $default); + + if ($value === null || is_string($value)) { + return $value; + } + + throw $this->invalidType($key, 'string'); + } + + /** + * Returns an input field as an integer. + * + * Supports dot-array syntax for nested input 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 an input field as a boolean. + * + * Supports dot-array syntax for nested input 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 an input field as an array. + * + * Supports dot-array syntax for nested input data. + * + * @param array|null $default + * + * @return array|null + */ + public function array(string $key, ?array $default = null): ?array + { + $value = $this->get($key, $default); + + if ($value === null || is_array($value)) { + return $value; + } + + throw $this->invalidType($key, 'array'); + } + + protected function invalidType(string $key, string $type): InvalidArgumentException + { + return new InvalidArgumentException( + sprintf('The input "%s" value cannot be read as %s.', $key, $type), + ); + } +} diff --git a/system/HTTP/ValidatedInput.php b/system/Validation/ValidatedInput.php similarity index 61% rename from system/HTTP/ValidatedInput.php rename to system/Validation/ValidatedInput.php index 6003db03d6f6..025833457458 100644 --- a/system/HTTP/ValidatedInput.php +++ b/system/Validation/ValidatedInput.php @@ -11,105 +11,21 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\HTTP; +namespace CodeIgniter\Validation; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; +use CodeIgniter\Input\InputData; use DateTimeZone; use Exception; use ReflectionEnum; use UnitEnum; /** - * @see \CodeIgniter\HTTP\ValidatedInputTest + * @see \CodeIgniter\Validation\ValidatedInputTest */ -class ValidatedInput +class ValidatedInput extends InputData { - /** - * @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. * @@ -223,7 +139,7 @@ private function backedEnum(string $key, string $enumClass, ReflectionEnum $refl return $enum; } - private function invalidType(string $key, string $type): InvalidArgumentException + protected 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 ad0bd658ea7f..8cb900c9b6a5 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -21,7 +21,6 @@ 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; @@ -277,7 +276,7 @@ public function getValidated(): array */ public function getValidatedInput(): ValidatedInput { - return new ValidatedInput($this->validated); + return service('validatedinput', $this->validated, false); } /** diff --git a/system/Validation/ValidationInterface.php b/system/Validation/ValidationInterface.php index 50ad00d2d6ec..1bf5c61a707e 100644 --- a/system/Validation/ValidationInterface.php +++ b/system/Validation/ValidationInterface.php @@ -15,7 +15,6 @@ use CodeIgniter\Database\BaseConnection; use CodeIgniter\HTTP\RequestInterface; -use CodeIgniter\HTTP\ValidatedInput; /** * Expected behavior of a validator diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 9790a217501f..e954a2f2fc4e 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -31,6 +31,7 @@ use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\URI; use CodeIgniter\Images\ImageHandlerInterface; +use CodeIgniter\Input\InputData; use CodeIgniter\Language\Language; use CodeIgniter\Lock\LockManager; use CodeIgniter\Pager\Pager; @@ -44,6 +45,7 @@ use CodeIgniter\Test\Mock\MockSecurity; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\ValidatedInput; use CodeIgniter\Validation\Validation; use CodeIgniter\View\Cell; use CodeIgniter\View\Parser; @@ -275,6 +277,24 @@ public function testNewValidation(): void $this->assertInstanceOf(Validation::class, $actual); } + public function testNewInputData(): void + { + $actual = Services::inputdata(['page' => '2']); + + $this->assertInstanceOf(InputData::class, $actual); + $this->assertSame(2, $actual->integer('page')); + $this->assertNotSame($actual, Services::inputdata(['page' => '2'])); + } + + public function testNewValidatedInput(): void + { + $actual = Services::validatedinput(['page' => '2']); + + $this->assertInstanceOf(ValidatedInput::class, $actual); + $this->assertSame(2, $actual->integer('page')); + $this->assertNotSame($actual, Services::validatedinput(['page' => '2'])); + } + public function testNewViewcellFromShared(): void { $actual = Services::viewcell(); diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index a5d5e20d7384..ee6af1a28e19 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCodeIgniter; +use CodeIgniter\Validation\ValidatedInput; use Config\App; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/system/Input/InputDataTest.php b/tests/system/Input/InputDataTest.php new file mode 100644 index 000000000000..5e3b81e48d0c --- /dev/null +++ b/tests/system/Input/InputDataTest.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Input; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class InputDataTest extends CIUnitTestCase +{ + public function testGetReturnsInputFieldValue(): void + { + $input = new InputData(['title' => 'Hello World']); + + $this->assertSame('Hello World', $input->get('title')); + } + + public function testGetReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame('fallback', $input->get('title', 'fallback')); + } + + public function testHasReturnsTrueForNullInputField(): void + { + $input = new InputData(['note' => null]); + + $this->assertTrue($input->has('note')); + $this->assertNull($input->get('note', 'fallback')); + } + + public function testGetAndHasSupportDotSyntax(): void + { + $input = new InputData([ + 'post' => [ + 'meta' => [ + 'slug' => 'hello-world', + ], + ], + ]); + + $this->assertSame('hello-world', $input->get('post.meta.slug')); + $this->assertTrue($input->has('post.meta.slug')); + } + + public function testStringReturnsInputString(): void + { + $input = new InputData(['title' => 'Hello World']); + + $this->assertSame('Hello World', $input->string('title')); + } + + public function testStringReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame('Untitled', $input->string('title', 'Untitled')); + } + + public function testStringThrowsForInvalidInputValue(): void + { + $input = new InputData(['title' => 123]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "title" value cannot be read as string.'); + + $input->string('title'); + } + + public function testIntegerReturnsInputInteger(): void + { + $input = new InputData(['page' => '15']); + + $this->assertSame(15, $input->integer('page')); + } + + public function testIntegerReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame(1, $input->integer('page', 1)); + } + + public function testIntegerSupportsDotSyntax(): void + { + $input = new InputData(['filters' => ['page' => '2']]); + + $this->assertSame(2, $input->integer('filters.page')); + } + + public function testIntegerThrowsForInvalidInputValue(): void + { + $input = new InputData(['page' => '1.5']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "page" value cannot be read as integer.'); + + $input->integer('page'); + } + + public function testBooleanReturnsInputBoolean(): void + { + $input = new InputData(['active' => 'true']); + + $this->assertTrue($input->boolean('active')); + } + + public function testBooleanReturnsFalseForInputFalseString(): void + { + $input = new InputData(['active' => 'false']); + + $this->assertFalse($input->boolean('active')); + } + + public function testBooleanReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertFalse($input->boolean('active', false)); + } + + public function testBooleanThrowsForInvalidInputValue(): void + { + $input = new InputData(['active' => 'sometimes']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "active" value cannot be read as boolean.'); + + $input->boolean('active'); + } + + public function testArrayReturnsInputArray(): void + { + $input = new InputData(['tags' => ['php', 'ci']]); + + $this->assertSame(['php', 'ci'], $input->array('tags')); + } + + public function testArrayReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertSame(['draft'], $input->array('tags', ['draft'])); + } + + public function testArrayThrowsForInvalidInputValue(): void + { + $input = new InputData(['tags' => 'php']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "tags" value cannot be read as array.'); + + $input->array('tags'); + } + + public function testTypedAccessorsReturnNullForNullInputFields(): void + { + $input = new InputData([ + 'title' => null, + 'page' => null, + 'active' => null, + 'tags' => null, + ]); + + $this->assertNull($input->string('title', 'Untitled')); + $this->assertNull($input->integer('page', 1)); + $this->assertNull($input->boolean('active', false)); + $this->assertNull($input->array('tags', ['draft'])); + } +} diff --git a/tests/system/HTTP/ValidatedInputTest.php b/tests/system/Validation/ValidatedInputTest.php similarity index 55% rename from tests/system/HTTP/ValidatedInputTest.php rename to tests/system/Validation/ValidatedInputTest.php index 3db24d15ed03..e40c4e665a19 100644 --- a/tests/system/HTTP/ValidatedInputTest.php +++ b/tests/system/Validation/ValidatedInputTest.php @@ -11,10 +11,11 @@ * the LICENSE file that was distributed with this source code. */ -namespace CodeIgniter\HTTP; +namespace CodeIgniter\Validation; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\I18n\Time; +use CodeIgniter\Input\InputData; use CodeIgniter\Test\CIUnitTestCase; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Enum\ColorEnum; @@ -27,104 +28,14 @@ #[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 + public function testValidatedInputExtendsInputData(): void { $input = new ValidatedInput(['page' => '15']); + $this->assertInstanceOf(InputData::class, $input); $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']); @@ -201,14 +112,10 @@ public function testEnumThrowsForInvalidValidatedValue(): void 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 dacb89f9bbbb..0b7c7eaef766 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -19,7 +19,6 @@ 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; diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index a49829b8684c..477fe8ff3f11 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -46,7 +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. +- **Validation:** ``CodeIgniter\Validation\ValidationInterface`` now requires the ``getValidatedInput()`` method, which returns a ``CodeIgniter\Validation\ValidatedInput`` instance. Method Signature Changes ======================== @@ -262,12 +262,13 @@ 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. +- Added ``Validation::getValidatedInput()`` to access validated data through a ``ValidatedInput`` object. Others ====== - **Float and Double Casting:** Added support for precision and rounding mode when casting to float or double in entities. +- Added ``CodeIgniter\Input\InputData`` as a reusable typed input data object. - Float and Double casting now throws ``CastException::forInvalidFloatRoundingMode()`` if an rounding mode other than up, down, even or odd is provided. - **Environment:** Added ``CodeIgniter\EnvironmentDetector`` class and corresponding ``environment`` service as a mockable wrapper around the ``ENVIRONMENT`` constant. Framework internals that previously compared ``ENVIRONMENT`` directly now go through this service, making environment-specific branches reachable in tests via ``Services::injectMock()``. See :ref:`environment-detector-service`. diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst index f700ab79da81..79aa1522bfde 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -78,8 +78,7 @@ controller: 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()``. +for the full behavior of the typed input methods. Accessing Other Request Data ============================ diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 73fdb1c40275..7040d65c5127 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -483,9 +483,10 @@ the validation rules. 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: +``getValidatedInput()`` returns the same validated data as a +``CodeIgniter\Validation\ValidatedInput`` object. Use it after validation +succeeds when you want to read common controller values as strings, integers, +booleans, arrays, dates, or enums: .. versionadded:: 4.8.0 @@ -497,6 +498,10 @@ 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. +``ValidatedInput`` extends ``CodeIgniter\Input\InputData``. This keeps generic +typed input access reusable while adding validation-specific readers for dates +and enums. + The typed input object has the following methods: All methods support dot-array syntax for nested validated data. @@ -505,10 +510,14 @@ All methods support dot-array syntax for nested validated data. is missing, it returns the default value. * ``has($key)`` returns whether the field exists in the validated data, even if its value is ``null``. +* ``string($key, $default = null)`` returns ``string|null``. If the field is + missing, it returns the default value or ``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``. +* ``array($key, $default = null)`` returns ``array|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. diff --git a/user_guide_src/source/libraries/validation/048.php b/user_guide_src/source/libraries/validation/048.php index 0b52800ef0f9..98457fcd8823 100644 --- a/user_guide_src/source/libraries/validation/048.php +++ b/user_guide_src/source/libraries/validation/048.php @@ -4,15 +4,19 @@ $validation = service('validation'); $validation->setRules([ + 'title' => 'required|string', 'page' => 'permit_empty|integer', 'active' => 'permit_empty|in_list[0,1,true,false,yes,no,on,off]', + 'tags' => 'permit_empty|is_array', 'published_at' => 'permit_empty|valid_date[Y-m-d]', 'status' => 'permit_empty|in_list[draft,published]', ]); $data = [ + 'title' => 'Hello World', 'page' => '2', 'active' => 'true', + 'tags' => ['php', 'codeigniter'], 'published_at' => '2026-05-04', 'status' => 'published', ]; @@ -24,7 +28,9 @@ $input = $validation->getValidatedInput(); +$title = $input->string('title'); $page = $input->integer('page', 1); $active = $input->boolean('active', false); +$tags = $input->array('tags', []); $publishedAt = $input->date('published_at', 'Y-m-d'); $status = $input->enum('status', PostStatus::class, PostStatus::DRAFT); From c8dc6f1b59d199fce142ee1b3325cb54d59794a6 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 6 May 2026 17:50:39 +0200 Subject: [PATCH 5/7] feat(input): add float typed accessor - Add InputData::float() for numeric input values - Cover float strings, integers, defaults, nulls, and invalid values - Document float access with matching decimal validation examples Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Input/InputData.php | 28 ++++++++++++++++ tests/system/Input/InputDataTest.php | 33 +++++++++++++++++++ .../source/incoming/form_requests/015.php | 1 + .../source/libraries/validation.rst | 8 +++-- .../source/libraries/validation/048.php | 3 ++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/system/Input/InputData.php b/system/Input/InputData.php index c47f76f85342..0087ca662039 100644 --- a/system/Input/InputData.php +++ b/system/Input/InputData.php @@ -96,6 +96,34 @@ public function integer(string $key, ?int $default = null): ?int throw $this->invalidType($key, 'integer'); } + /** + * Returns an input field as a float. + * + * Supports dot-array syntax for nested input data. + */ + public function float(string $key, ?float $default = null): ?float + { + $value = $this->get($key, $default); + + if ($value === null || is_float($value)) { + return $value; + } + + if (is_int($value)) { + return (float) $value; + } + + if (is_string($value)) { + $float = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + + if ($float !== null) { + return $float; + } + } + + throw $this->invalidType($key, 'float'); + } + /** * Returns an input field as a boolean. * diff --git a/tests/system/Input/InputDataTest.php b/tests/system/Input/InputDataTest.php index 5e3b81e48d0c..e9cf78723e78 100644 --- a/tests/system/Input/InputDataTest.php +++ b/tests/system/Input/InputDataTest.php @@ -114,6 +114,37 @@ public function testIntegerThrowsForInvalidInputValue(): void $input->integer('page'); } + public function testFloatReturnsInputFloat(): void + { + $input = new InputData(['price' => '15.50']); + + $this->assertEqualsWithDelta(15.50, $input->float('price'), PHP_FLOAT_EPSILON); + } + + public function testFloatReturnsInputIntegerAsFloat(): void + { + $input = new InputData(['price' => 15]); + + $this->assertEqualsWithDelta(15.0, $input->float('price'), PHP_FLOAT_EPSILON); + } + + public function testFloatReturnsDefaultForMissingInputField(): void + { + $input = new InputData([]); + + $this->assertEqualsWithDelta(1.5, $input->float('price', 1.5), PHP_FLOAT_EPSILON); + } + + public function testFloatThrowsForInvalidInputValue(): void + { + $input = new InputData(['price' => 'free']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The input "price" value cannot be read as float.'); + + $input->float('price'); + } + public function testBooleanReturnsInputBoolean(): void { $input = new InputData(['active' => 'true']); @@ -174,12 +205,14 @@ public function testTypedAccessorsReturnNullForNullInputFields(): void $input = new InputData([ 'title' => null, 'page' => null, + 'price' => null, 'active' => null, 'tags' => null, ]); $this->assertNull($input->string('title', 'Untitled')); $this->assertNull($input->integer('page', 1)); + $this->assertNull($input->float('price', 1.5)); $this->assertNull($input->boolean('active', false)); $this->assertNull($input->array('tags', ['draft'])); } diff --git a/user_guide_src/source/incoming/form_requests/015.php b/user_guide_src/source/incoming/form_requests/015.php index b4d06aa897af..12d76e1b8d46 100644 --- a/user_guide_src/source/incoming/form_requests/015.php +++ b/user_guide_src/source/incoming/form_requests/015.php @@ -5,6 +5,7 @@ $input = $request->validatedInput(); $page = $input->integer('page', 1); +$rating = $input->float('rating', 0.0); $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 7040d65c5127..2300650cfdd9 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -486,7 +486,7 @@ Typed Validated Input ``getValidatedInput()`` returns the same validated data as a ``CodeIgniter\Validation\ValidatedInput`` object. Use it after validation succeeds when you want to read common controller values as strings, integers, -booleans, arrays, dates, or enums: +floats, booleans, arrays, dates, or enums: .. versionadded:: 4.8.0 @@ -514,6 +514,8 @@ All methods support dot-array syntax for nested validated data. missing, it returns the default value or ``null``. * ``integer($key, $default = null)`` returns ``int|null``. If the field is missing, it returns the default value or ``null``. +* ``float($key, $default = null)`` returns ``float|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``. * ``array($key, $default = null)`` returns ``array|null``. If the field is @@ -529,8 +531,8 @@ 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 +Use validation rules such as ``integer``, ``decimal``, ``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]``. diff --git a/user_guide_src/source/libraries/validation/048.php b/user_guide_src/source/libraries/validation/048.php index 98457fcd8823..17dbbf9b30f9 100644 --- a/user_guide_src/source/libraries/validation/048.php +++ b/user_guide_src/source/libraries/validation/048.php @@ -6,6 +6,7 @@ $validation->setRules([ 'title' => 'required|string', 'page' => 'permit_empty|integer', + 'rating' => 'permit_empty|decimal', 'active' => 'permit_empty|in_list[0,1,true,false,yes,no,on,off]', 'tags' => 'permit_empty|is_array', 'published_at' => 'permit_empty|valid_date[Y-m-d]', @@ -15,6 +16,7 @@ $data = [ 'title' => 'Hello World', 'page' => '2', + 'rating' => '4.5', 'active' => 'true', 'tags' => ['php', 'codeigniter'], 'published_at' => '2026-05-04', @@ -30,6 +32,7 @@ $title = $input->string('title'); $page = $input->integer('page', 1); +$rating = $input->float('rating', 0.0); $active = $input->boolean('active', false); $tags = $input->array('tags', []); $publishedAt = $input->date('published_at', 'Y-m-d'); From 7997dc5142d5a4242e3022ff363fd6b2cf8f7166 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 6 May 2026 21:56:44 +0200 Subject: [PATCH 6/7] refactor(input): split raw and validated input behavior - Return defaults for invalid raw InputData values - Keep ValidatedInput strict through an override - Cover fallback and strict behavior in tests - Document the raw versus validated input trust levels Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Input/InputData.php | 18 +++---- system/Validation/ValidatedInput.php | 20 ++++---- tests/system/Input/InputDataTest.php | 36 ++++--------- .../system/Validation/ValidatedInputTest.php | 50 +++++++++++++++++++ .../source/libraries/validation.rst | 3 +- 5 files changed, 79 insertions(+), 48 deletions(-) diff --git a/system/Input/InputData.php b/system/Input/InputData.php index 0087ca662039..cae88b10b03b 100644 --- a/system/Input/InputData.php +++ b/system/Input/InputData.php @@ -13,8 +13,6 @@ namespace CodeIgniter\Input; -use CodeIgniter\Exceptions\InvalidArgumentException; - /** * @see \CodeIgniter\Input\InputDataTest */ @@ -69,7 +67,7 @@ public function string(string $key, ?string $default = null): ?string return $value; } - throw $this->invalidType($key, 'string'); + return $this->invalidValue($key, 'string', $default); } /** @@ -93,7 +91,7 @@ public function integer(string $key, ?int $default = null): ?int } } - throw $this->invalidType($key, 'integer'); + return $this->invalidValue($key, 'integer', $default); } /** @@ -121,7 +119,7 @@ public function float(string $key, ?float $default = null): ?float } } - throw $this->invalidType($key, 'float'); + return $this->invalidValue($key, 'float', $default); } /** @@ -145,7 +143,7 @@ public function boolean(string $key, ?bool $default = null): ?bool } } - throw $this->invalidType($key, 'boolean'); + return $this->invalidValue($key, 'boolean', $default); } /** @@ -165,13 +163,11 @@ public function array(string $key, ?array $default = null): ?array return $value; } - throw $this->invalidType($key, 'array'); + return $this->invalidValue($key, 'array', $default); } - protected function invalidType(string $key, string $type): InvalidArgumentException + protected function invalidValue(string $key, string $type, mixed $default): mixed { - return new InvalidArgumentException( - sprintf('The input "%s" value cannot be read as %s.', $key, $type), - ); + return $default; } } diff --git a/system/Validation/ValidatedInput.php b/system/Validation/ValidatedInput.php index 025833457458..0b89db874d1f 100644 --- a/system/Validation/ValidatedInput.php +++ b/system/Validation/ValidatedInput.php @@ -43,7 +43,7 @@ public function date( } if (! is_string($value) || $value === '') { - throw $this->invalidType($key, 'date'); + return $this->invalidValue($key, 'date', null); } try { @@ -53,7 +53,7 @@ public function date( return Time::createFromFormat($format, $value, $timezone); } catch (Exception) { - throw $this->invalidType($key, 'date'); + return $this->invalidValue($key, 'date', null); } } @@ -76,7 +76,7 @@ public function enum(string $key, string $enumClass, ?UnitEnum $default = null): } if ($default instanceof UnitEnum && ! $default instanceof $enumClass) { - throw $this->invalidType($key, $enumClass); + return $this->invalidValue($key, $enumClass, $default); } $value = $this->get($key, $default); @@ -90,7 +90,7 @@ public function enum(string $key, string $enumClass, ?UnitEnum $default = null): return $value; } - throw $this->invalidType($key, $enumClass); + return $this->invalidValue($key, $enumClass, $default); } $reflection = new ReflectionEnum($enumClass); @@ -107,7 +107,7 @@ public function enum(string $key, string $enumClass, ?UnitEnum $default = null): } } - throw $this->invalidType($key, $enumClass); + return $this->invalidValue($key, $enumClass, $default); } private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum @@ -120,10 +120,10 @@ private function backedEnum(string $key, string $enumClass, ReflectionEnum $refl } if (! is_int($value)) { - throw $this->invalidType($key, $enumClass); + return $this->invalidValue($key, $enumClass, null); } } elseif (! is_int($value) && ! is_string($value)) { - throw $this->invalidType($key, $enumClass); + return $this->invalidValue($key, $enumClass, null); } if ($backingType === 'string') { @@ -133,15 +133,15 @@ private function backedEnum(string $key, string $enumClass, ReflectionEnum $refl $enum = $enumClass::tryFrom($value); if ($enum === null) { - throw $this->invalidType($key, $enumClass); + return $this->invalidValue($key, $enumClass, null); } return $enum; } - protected function invalidType(string $key, string $type): InvalidArgumentException + protected function invalidValue(string $key, string $type, mixed $default): never { - return new InvalidArgumentException( + throw new InvalidArgumentException( sprintf('The validated "%s" value cannot be read as %s.', $key, $type), ); } diff --git a/tests/system/Input/InputDataTest.php b/tests/system/Input/InputDataTest.php index e9cf78723e78..9e1122fbbb32 100644 --- a/tests/system/Input/InputDataTest.php +++ b/tests/system/Input/InputDataTest.php @@ -13,7 +13,6 @@ namespace CodeIgniter\Input; -use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use PHPUnit\Framework\Attributes\Group; @@ -73,14 +72,11 @@ public function testStringReturnsDefaultForMissingInputField(): void $this->assertSame('Untitled', $input->string('title', 'Untitled')); } - public function testStringThrowsForInvalidInputValue(): void + public function testStringReturnsDefaultForInvalidInputValue(): void { $input = new InputData(['title' => 123]); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The input "title" value cannot be read as string.'); - - $input->string('title'); + $this->assertSame('Untitled', $input->string('title', 'Untitled')); } public function testIntegerReturnsInputInteger(): void @@ -104,14 +100,11 @@ public function testIntegerSupportsDotSyntax(): void $this->assertSame(2, $input->integer('filters.page')); } - public function testIntegerThrowsForInvalidInputValue(): void + public function testIntegerReturnsDefaultForInvalidInputValue(): void { $input = new InputData(['page' => '1.5']); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The input "page" value cannot be read as integer.'); - - $input->integer('page'); + $this->assertSame(1, $input->integer('page', 1)); } public function testFloatReturnsInputFloat(): void @@ -135,14 +128,11 @@ public function testFloatReturnsDefaultForMissingInputField(): void $this->assertEqualsWithDelta(1.5, $input->float('price', 1.5), PHP_FLOAT_EPSILON); } - public function testFloatThrowsForInvalidInputValue(): void + public function testFloatReturnsDefaultForInvalidInputValue(): void { $input = new InputData(['price' => 'free']); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The input "price" value cannot be read as float.'); - - $input->float('price'); + $this->assertEqualsWithDelta(1.5, $input->float('price', 1.5), PHP_FLOAT_EPSILON); } public function testBooleanReturnsInputBoolean(): void @@ -166,14 +156,11 @@ public function testBooleanReturnsDefaultForMissingInputField(): void $this->assertFalse($input->boolean('active', false)); } - public function testBooleanThrowsForInvalidInputValue(): void + public function testBooleanReturnsDefaultForInvalidInputValue(): void { $input = new InputData(['active' => 'sometimes']); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The input "active" value cannot be read as boolean.'); - - $input->boolean('active'); + $this->assertFalse($input->boolean('active', false)); } public function testArrayReturnsInputArray(): void @@ -190,14 +177,11 @@ public function testArrayReturnsDefaultForMissingInputField(): void $this->assertSame(['draft'], $input->array('tags', ['draft'])); } - public function testArrayThrowsForInvalidInputValue(): void + public function testArrayReturnsDefaultForInvalidInputValue(): void { $input = new InputData(['tags' => 'php']); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The input "tags" value cannot be read as array.'); - - $input->array('tags'); + $this->assertSame(['draft'], $input->array('tags', ['draft'])); } public function testTypedAccessorsReturnNullForNullInputFields(): void diff --git a/tests/system/Validation/ValidatedInputTest.php b/tests/system/Validation/ValidatedInputTest.php index e40c4e665a19..e999b53e3faa 100644 --- a/tests/system/Validation/ValidatedInputTest.php +++ b/tests/system/Validation/ValidatedInputTest.php @@ -61,6 +61,56 @@ public function testDateThrowsForInvalidValidatedValue(): void $input->date('published_at'); } + public function testStringThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['title' => 123]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "title" value cannot be read as string.'); + + $input->string('title', 'Untitled'); + } + + 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', 1); + } + + public function testFloatThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['price' => 'free']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "price" value cannot be read as float.'); + + $input->float('price', 1.5); + } + + 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', false); + } + + public function testArrayThrowsForInvalidValidatedValue(): void + { + $input = new ValidatedInput(['tags' => 'php']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The validated "tags" value cannot be read as array.'); + + $input->array('tags', ['draft']); + } + public function testEnumReturnsValidatedStringBackedEnum(): void { $input = new ValidatedInput(['status' => 'active']); diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 2300650cfdd9..80b4c0ed308a 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -500,7 +500,8 @@ This usually means the validation rules and the expected type do not match. ``ValidatedInput`` extends ``CodeIgniter\Input\InputData``. This keeps generic typed input access reusable while adding validation-specific readers for dates -and enums. +and enums. ``InputData`` is fallback-friendly for raw input; ``ValidatedInput`` +is strict because it represents data that has already passed validation. The typed input object has the following methods: From 8f3ba7ad6ec41c46bae0bb04c1b6bfdd424218bb Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 6 May 2026 22:04:05 +0200 Subject: [PATCH 7/7] fix(validation): satisfy Psalm for strict invalid input handling Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Validation/ValidatedInput.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/system/Validation/ValidatedInput.php b/system/Validation/ValidatedInput.php index 0b89db874d1f..baf25ba241d7 100644 --- a/system/Validation/ValidatedInput.php +++ b/system/Validation/ValidatedInput.php @@ -43,7 +43,7 @@ public function date( } if (! is_string($value) || $value === '') { - return $this->invalidValue($key, 'date', null); + $this->invalidValue($key, 'date', null); } try { @@ -53,7 +53,7 @@ public function date( return Time::createFromFormat($format, $value, $timezone); } catch (Exception) { - return $this->invalidValue($key, 'date', null); + $this->invalidValue($key, 'date', null); } } @@ -76,7 +76,7 @@ public function enum(string $key, string $enumClass, ?UnitEnum $default = null): } if ($default instanceof UnitEnum && ! $default instanceof $enumClass) { - return $this->invalidValue($key, $enumClass, $default); + $this->invalidValue($key, $enumClass, $default); } $value = $this->get($key, $default); @@ -90,7 +90,7 @@ public function enum(string $key, string $enumClass, ?UnitEnum $default = null): return $value; } - return $this->invalidValue($key, $enumClass, $default); + $this->invalidValue($key, $enumClass, $default); } $reflection = new ReflectionEnum($enumClass); @@ -107,7 +107,7 @@ public function enum(string $key, string $enumClass, ?UnitEnum $default = null): } } - return $this->invalidValue($key, $enumClass, $default); + $this->invalidValue($key, $enumClass, $default); } private function backedEnum(string $key, string $enumClass, ReflectionEnum $reflection, mixed $value): UnitEnum @@ -120,10 +120,10 @@ private function backedEnum(string $key, string $enumClass, ReflectionEnum $refl } if (! is_int($value)) { - return $this->invalidValue($key, $enumClass, null); + $this->invalidValue($key, $enumClass, null); } } elseif (! is_int($value) && ! is_string($value)) { - return $this->invalidValue($key, $enumClass, null); + $this->invalidValue($key, $enumClass, null); } if ($backingType === 'string') { @@ -133,7 +133,7 @@ private function backedEnum(string $key, string $enumClass, ReflectionEnum $refl $enum = $enumClass::tryFrom($value); if ($enum === null) { - return $this->invalidValue($key, $enumClass, null); + $this->invalidValue($key, $enumClass, null); } return $enum;