diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 5e831e69be5d..23506220ae49 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -182,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. diff --git a/system/HTTP/ValidatedInput.php b/system/HTTP/ValidatedInput.php new file mode 100644 index 000000000000..6003db03d6f6 --- /dev/null +++ b/system/HTTP/ValidatedInput.php @@ -0,0 +1,232 @@ + + * + * 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 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 ($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 768ef9898fe0..a5d5e20d7384 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -298,6 +298,20 @@ public function rules(): array $this->assertTrue($formRequest->hasValidated('note')); } + public function testValidatedInputReturnsValidatedInputObject(): void + { + service('superglobals')->setPost('title', 'Hello World'); + service('superglobals')->setPost('body', 'Some body text'); + + $formRequest = new ValidPostFormRequest($this->makeRequest()); + $formRequest->resolveRequest(); + + $input = $formRequest->validatedInput(); + + $this->assertInstanceOf(ValidatedInput::class, $input); + $this->assertSame('Hello World', $input->get('title')); + } + // ------------------------------------------------------------------------- // prepareForValidation hook // ------------------------------------------------------------------------- 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 ca51e62004e9..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, 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 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 81f44add9d01..f700ab79da81 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -63,6 +63,24 @@ to check whether a validated key exists, including keys whose value is .. literalinclude:: form_requests/014.php :lines: 2- +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. + +After the FormRequest has been validated, read the successful values in the +controller: + +.. literalinclude:: form_requests/015.php + :lines: 2- + +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 ============================ @@ -229,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 new file mode 100644 index 000000000000..b4d06aa897af --- /dev/null +++ b/user_guide_src/source/incoming/form_requests/015.php @@ -0,0 +1,10 @@ +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);