From e92187e27336678f81e8bb5ac4229452571c6df0 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 31 May 2026 20:41:55 +0200 Subject: [PATCH 1/4] feat(http): expose prepared FormRequest data during validation failure - Store prepared validation data before running FormRequest validation - Allow failedValidation() overrides to reuse the exact prepared payload - Avoid calling prepareForValidation() a second time in custom failure responses - Document normalized old-input handling Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/HTTP/FormRequest.php | 29 ++++++++++++- tests/system/HTTP/FormRequestTest.php | 42 +++++++++++++++++++ .../source/incoming/form_requests.rst | 13 ++++-- .../source/incoming/form_requests/013.php | 8 ++-- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 624114060cda..5e5e431db9d4 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -35,6 +35,13 @@ abstract class FormRequest */ private array $validatedData = []; + /** + * Data after prepareForValidation() and before validation rules run. + * + * @var array + */ + private array $preparedValidationData = []; + /** * When called by the framework, the current IncomingRequest is injected * explicitly. When instantiated manually (e.g. in tests), the constructor @@ -143,6 +150,21 @@ protected function prepareForValidation(array $data): array return $data; } + /** + * Returns the data after prepareForValidation() has run. + * + * This is useful in failedValidation() when a custom failure response needs + * the same prepared data that was passed to validation. This data has not + * passed validation; use getValidated() or getValidatedInput() after + * successful validation for trusted values. + * + * @return array + */ + protected function getPreparedValidationData(): array + { + return $this->preparedValidationData; + } + /** * Called when validation fails. Override to customize the failure response. * @@ -249,16 +271,19 @@ protected function validationData(): array */ final public function resolveRequest(): ?ResponseInterface { + $this->validatedData = []; + $this->preparedValidationData = []; + if (! $this->isAuthorized()) { return $this->failedAuthorization(); } - $data = $this->prepareForValidation($this->validationData()); + $this->preparedValidationData = $this->prepareForValidation($this->validationData()); $validation = service('validation') ->setRules($this->rules(), $this->messages()); - if (! $validation->run($data)) { + if (! $validation->run($this->preparedValidationData)) { return $this->failedValidation($validation->getErrors()); } diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index fe811fb3ee6a..030e5d737de4 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -395,6 +395,48 @@ public function testResolveRequestRedirectsForWildcardAcceptHeader(): void $this->assertSame(303, $response->getStatusCode()); } + public function testPreparedValidationDataIsAvailableDuringFailedValidationWithoutPreparingAgain(): void + { + service('superglobals')->setPost('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public int $prepareCount = 0; + + /** + * @var array + */ + public array $preparedData = []; + + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $this->prepareCount++; + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + + protected function failedValidation(array $errors): ResponseInterface + { + $this->preparedData = $this->getPreparedValidationData(); + + return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(1, $formRequest->prepareCount); + $this->assertSame(['title' => 'Hello World'], $formRequest->preparedData); + } + // ------------------------------------------------------------------------- // Authorization failure // ------------------------------------------------------------------------- diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst index ebcd6ff28bd3..cdfc594838df 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -226,13 +226,20 @@ Flashing Normalized Input If your ``prepareForValidation()`` transforms visible form fields (for example, trimming strings or canonicalizing values), ``old()`` will return the original -submitted input because the redirect flashes the raw superglobals. To make -``old()`` reflect the normalized values instead, override ``failedValidation()`` -and flash the normalized payload manually: +submitted input because the redirect flashes the raw superglobals. + +To make ``old()`` reflect the normalized values instead, override +``failedValidation()`` and flash the data returned from +``prepareForValidation()``: .. literalinclude:: form_requests/013.php :lines: 2- +Use ``getPreparedValidationData()`` inside ``failedValidation()`` to read that +prepared data without running ``prepareForValidation()`` again. The prepared +data has not passed validation. After successful validation, use +``getValidated()`` or ``getValidatedInput()`` for trusted values. + ***************************************** How the Framework Resolves Form Requests ***************************************** diff --git a/user_guide_src/source/incoming/form_requests/013.php b/user_guide_src/source/incoming/form_requests/013.php index 92511ff3903b..c742347d9033 100644 --- a/user_guide_src/source/incoming/form_requests/013.php +++ b/user_guide_src/source/incoming/form_requests/013.php @@ -32,14 +32,14 @@ protected function failedValidation(array $errors): ResponseInterface return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); } - // withInput() flashes the original superglobals and the validation - // errors. We then overwrite old input with the normalized payload so - // that old() returns the same values that were validated. + // withInput() flashes the original superglobals and validation errors. + // Then we overwrite old input with the prepared data so old() returns + // the same values that were passed to validation. $redirect = redirect()->back()->withInput(); service('session')->setFlashdata('_ci_old_input', [ 'get' => [], - 'post' => $this->prepareForValidation($this->validationData()), + 'post' => $this->getPreparedValidationData(), ]); return $redirect; From c89f9839c3751b9edcabec807ff9161d413fb990 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:18:04 +0200 Subject: [PATCH 2/4] refactor: pass prepared `FormRequest` data to `failedValidation` Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/HTTP/FormRequest.php | 48 +++++-------- tests/system/HTTP/FormRequestTest.php | 70 +++++++++++++++++-- .../source/incoming/form_requests.rst | 21 +++--- .../source/incoming/form_requests/008.php | 2 +- .../source/incoming/form_requests/013.php | 11 ++- 5 files changed, 100 insertions(+), 52 deletions(-) diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 5e5e431db9d4..f32baac14b89 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -35,13 +35,6 @@ abstract class FormRequest */ private array $validatedData = []; - /** - * Data after prepareForValidation() and before validation rules run. - * - * @var array - */ - private array $preparedValidationData = []; - /** * When called by the framework, the current IncomingRequest is injected * explicitly. When instantiated manually (e.g. in tests), the constructor @@ -150,21 +143,6 @@ protected function prepareForValidation(array $data): array return $data; } - /** - * Returns the data after prepareForValidation() has run. - * - * This is useful in failedValidation() when a custom failure response needs - * the same prepared data that was passed to validation. This data has not - * passed validation; use getValidated() or getValidatedInput() after - * successful validation for trusted values. - * - * @return array - */ - protected function getPreparedValidationData(): array - { - return $this->preparedValidationData; - } - /** * Called when validation fails. Override to customize the failure response. * @@ -175,14 +153,27 @@ protected function getPreparedValidationData(): array * returns a 422 JSON response instead. * * @param array $errors + * @param array $preparedData */ - protected function failedValidation(array $errors): ResponseInterface + protected function failedValidation(array $errors, array $preparedData): ResponseInterface { if ($this->shouldReturnJsonResponse()) { return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); } - return redirect()->back()->withInput(); + $redirect = redirect()->back()->withInput(); + + $key = in_array($this->request->getMethod(), [Method::GET, Method::HEAD], true) + ? 'get' + : 'post'; + + service('session')->setFlashdata('_ci_old_input', [ + 'get' => [], + 'post' => [], + $key => $preparedData, + ]); + + return $redirect; } /** @@ -271,20 +262,19 @@ protected function validationData(): array */ final public function resolveRequest(): ?ResponseInterface { - $this->validatedData = []; - $this->preparedValidationData = []; + $this->validatedData = []; if (! $this->isAuthorized()) { return $this->failedAuthorization(); } - $this->preparedValidationData = $this->prepareForValidation($this->validationData()); + $data = $this->prepareForValidation($this->validationData()); $validation = service('validation') ->setRules($this->rules(), $this->messages()); - if (! $validation->run($this->preparedValidationData)) { - return $this->failedValidation($validation->getErrors()); + if (! $validation->run($data)) { + return $this->failedValidation($validation->getErrors(), $data); } $this->validatedData = $validation->getValidated(); diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index 030e5d737de4..ec6ab2021376 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -395,7 +395,7 @@ public function testResolveRequestRedirectsForWildcardAcceptHeader(): void $this->assertSame(303, $response->getStatusCode()); } - public function testPreparedValidationDataIsAvailableDuringFailedValidationWithoutPreparingAgain(): void + public function testPreparedValidationDataIsPassedToFailedValidationWithoutPreparingAgain(): void { service('superglobals')->setPost('title', ' Hello World '); @@ -423,9 +423,9 @@ protected function prepareForValidation(array $data): array return $data; } - protected function failedValidation(array $errors): ResponseInterface + protected function failedValidation(array $errors, array $preparedData): ResponseInterface { - $this->preparedData = $this->getPreparedValidationData(); + $this->preparedData = $preparedData; return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); } @@ -437,6 +437,68 @@ protected function failedValidation(array $errors): ResponseInterface $this->assertSame(['title' => 'Hello World'], $formRequest->preparedData); } + #[RunInSeparateProcess] + public function testDefaultFailedValidationFlashesPreparedValidationDataAsOldInput(): void + { + /** @var array $_SESSION */ + $_SESSION = []; + + service('superglobals')->setPost('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(['title' => 'Hello World'], $_SESSION['_ci_old_input']['post']); + } + + #[RunInSeparateProcess] + public function testDefaultFailedValidationFlashesPreparedGetDataAsOldInput(): void + { + /** @var array $_SESSION */ + $_SESSION = []; + + service('superglobals')->setServer('REQUEST_METHOD', 'GET'); + service('superglobals')->setGet('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(['title' => 'Hello World'], $_SESSION['_ci_old_input']['get']); + $this->assertSame([], $_SESSION['_ci_old_input']['post']); + } + // ------------------------------------------------------------------------- // Authorization failure // ------------------------------------------------------------------------- @@ -530,7 +592,7 @@ public function rules(): array return ['title' => 'required']; } - protected function failedValidation(array $errors): ResponseInterface + protected function failedValidation(array $errors, array $preparedData): ResponseInterface { self::$called = true; diff --git a/user_guide_src/source/incoming/form_requests.rst b/user_guide_src/source/incoming/form_requests.rst index cdfc594838df..278703cd237e 100644 --- a/user_guide_src/source/incoming/form_requests.rst +++ b/user_guide_src/source/incoming/form_requests.rst @@ -171,10 +171,9 @@ normalized phone numbers, or trimmed strings. .. literalinclude:: form_requests/006.php :lines: 2- -.. note:: ``old()`` returns the original submitted input, not the normalized - values. Use ``getValidated()`` to access the processed data after a successful - request. If you need ``old()`` to reflect normalized values, see - :ref:`form-request-flash-normalized`. +.. note:: When validation fails and the default redirect response is used, + ``old()`` returns the prepared validation data. Use ``getValidated()`` to + access the processed data after a successful request. .. _form-request-validation-data: @@ -225,19 +224,17 @@ Flashing Normalized Input ========================= If your ``prepareForValidation()`` transforms visible form fields (for example, -trimming strings or canonicalizing values), ``old()`` will return the original -submitted input because the redirect flashes the raw superglobals. +trimming strings or canonicalizing values), the default redirect response flashes +the prepared validation data as old input. -To make ``old()`` reflect the normalized values instead, override -``failedValidation()`` and flash the data returned from -``prepareForValidation()``: +If you override ``failedValidation()`` and still need to flash normalized input, +use the second ``$preparedData`` argument. It contains the same data that was +passed to validation: .. literalinclude:: form_requests/013.php :lines: 2- -Use ``getPreparedValidationData()`` inside ``failedValidation()`` to read that -prepared data without running ``prepareForValidation()`` again. The prepared -data has not passed validation. After successful validation, use +The prepared data has not passed validation. After successful validation, use ``getValidated()`` or ``getValidatedInput()`` for trusted values. ***************************************** diff --git a/user_guide_src/source/incoming/form_requests/008.php b/user_guide_src/source/incoming/form_requests/008.php index e5d99b9fe72a..98103e8fef7e 100644 --- a/user_guide_src/source/incoming/form_requests/008.php +++ b/user_guide_src/source/incoming/form_requests/008.php @@ -16,7 +16,7 @@ public function rules(): array } // Always respond with JSON, regardless of the request type. - protected function failedValidation(array $errors): ResponseInterface + protected function failedValidation(array $errors, array $preparedData): ResponseInterface { return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); } diff --git a/user_guide_src/source/incoming/form_requests/013.php b/user_guide_src/source/incoming/form_requests/013.php index c742347d9033..041164fd43b7 100644 --- a/user_guide_src/source/incoming/form_requests/013.php +++ b/user_guide_src/source/incoming/form_requests/013.php @@ -22,8 +22,8 @@ protected function prepareForValidation(array $data): array return $data; } - // Override so that old() reflects the normalized values on redirect. - protected function failedValidation(array $errors): ResponseInterface + // Override while still flashing the prepared values on redirect. + protected function failedValidation(array $errors, array $preparedData): ResponseInterface { if ( $this->request->is('json') @@ -32,14 +32,13 @@ protected function failedValidation(array $errors): ResponseInterface return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]); } - // withInput() flashes the original superglobals and validation errors. - // Then we overwrite old input with the prepared data so old() returns - // the same values that were passed to validation. + // withInput() flashes validation errors. Then we replace old input with + // the same prepared values that were passed to validation. $redirect = redirect()->back()->withInput(); service('session')->setFlashdata('_ci_old_input', [ 'get' => [], - 'post' => $this->getPreparedValidationData(), + 'post' => $preparedData, ]); return $redirect; From d150c2fe826b385f2565b0abea042f4628de4a7f Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:52:37 +0200 Subject: [PATCH 3/4] fix: avoid duplicate old-input keys Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/HTTP/FormRequest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index f32baac14b89..0ee60af8cc4f 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -167,11 +167,13 @@ protected function failedValidation(array $errors, array $preparedData): Respons ? 'get' : 'post'; - service('session')->setFlashdata('_ci_old_input', [ + $oldInput = [ 'get' => [], 'post' => [], - $key => $preparedData, - ]); + ]; + $oldInput[$key] = $preparedData; + + service('session')->setFlashdata('_ci_old_input', $oldInput); return $redirect; } From 1c39210c055e94bed1efe04b3d133d4ab35af6f2 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:18:49 +0200 Subject: [PATCH 4/4] fix: preserve old input source during FormRequest failure Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/HTTP/FormRequest.php | 4 ++-- tests/system/HTTP/FormRequestTest.php | 32 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/system/HTTP/FormRequest.php b/system/HTTP/FormRequest.php index 0ee60af8cc4f..28ad232c302d 100644 --- a/system/HTTP/FormRequest.php +++ b/system/HTTP/FormRequest.php @@ -168,8 +168,8 @@ protected function failedValidation(array $errors, array $preparedData): Respons : 'post'; $oldInput = [ - 'get' => [], - 'post' => [], + 'get' => service('superglobals')->getGetArray(), + 'post' => service('superglobals')->getPostArray(), ]; $oldInput[$key] = $preparedData; diff --git a/tests/system/HTTP/FormRequestTest.php b/tests/system/HTTP/FormRequestTest.php index ec6ab2021376..d517b4cbb2bf 100644 --- a/tests/system/HTTP/FormRequestTest.php +++ b/tests/system/HTTP/FormRequestTest.php @@ -499,6 +499,38 @@ protected function prepareForValidation(array $data): array $this->assertSame([], $_SESSION['_ci_old_input']['post']); } + #[RunInSeparateProcess] + public function testDefaultFailedValidationPreservesGetDataWhenPostDataIsPrepared(): void + { + /** @var array $_SESSION */ + $_SESSION = []; + + service('superglobals')->setGet('category', '2'); + service('superglobals')->setPost('title', ' Hello World '); + + $formRequest = new class ($this->makeRequest()) extends FormRequest { + public function rules(): array + { + return [ + 'title' => 'required', + 'body' => 'required', + ]; + } + + protected function prepareForValidation(array $data): array + { + $data['title'] = trim($data['title'] ?? ''); + + return $data; + } + }; + + $formRequest->resolveRequest(); + + $this->assertSame(['category' => '2'], $_SESSION['_ci_old_input']['get']); + $this->assertSame(['title' => 'Hello World'], $_SESSION['_ci_old_input']['post']); + } + // ------------------------------------------------------------------------- // Authorization failure // -------------------------------------------------------------------------