├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── composer.json
├── config
└── dry-requests.php
└── src
├── Dry.php
├── DryRequestMacros.php
├── DryRunnable.php
├── Dryer.php
├── RequestRanDry.php
├── Responder.php
├── ServiceProvider.php
├── SucceededException.php
└── Validation.php
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to `laravel-dry-requests` will be documented in this file.
4 |
5 | ## 2.3.0 - 2023-05-03
6 |
7 | ### Removed
8 |
9 | - PHP 8.1 support
10 |
11 | ## 2.2.0 - 2023-05-03
12 |
13 | ### Added
14 |
15 | - Laravel 10 support
16 |
17 | ### Removed
18 |
19 | - Laravel 9 support
20 |
21 | ## 2.1.0 - 2022-06-16
22 |
23 | ### Added
24 |
25 | - Invoke dry request behavior through regular `Illuminate\Http\Request` objects.
26 |
27 | ## 2.0.0 - 2022-06-16
28 |
29 | ### Added
30 |
31 | - Change validation behavior of dry requests using the `Dry` attribute
32 | - Change validation behavior of dry requests using the `X-Dry-Run` header
33 | - Globally define default validation behavior using the config file.
34 |
35 | ### Changed
36 |
37 | - Succesful dry requests now return `200 OK` instead of `202 Accepted`.
38 | This is to ensure compatibility with apps using Inertia.
39 |
40 | ### Removed
41 |
42 | - `dry` request parameter. Use header `X-Dry-Run` instead.
43 |
44 | ## 1.1.0 - 2022-04-28
45 |
46 | ### Added
47 |
48 | - Made the `DryRunnable` trait more flexible
49 |
50 | ## 1.0.0 - 2022-04-03
51 |
52 | - Initial release
53 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) DIVE bv info@dive.be
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > **Warning** two months after the release of our package,
2 | > [Taylor Otwell announced an almost identical functionality](https://youtu.be/f4QShF42c6E?t=20679) as a core package.
3 | > Since this package has pretty much been made obsolete, we have decided to stop maintaining it.
4 | >
5 | > So, please consider migrating to [Laravel Precognition](https://github.com/laravel/framework/pull/44339).
6 |
7 |
8 |
9 | # X-Dry-Run your requests
10 |
11 | [](https://packagist.org/packages/dive-be/laravel-dry-requests)
12 | [](https://packagist.org/packages/dive-be/laravel-dry-requests)
13 |
14 | This package allows you to check if your requests would pass validation if you executed them normally.
15 | The Laravel equivalent of `--dry-run` in various CLI tools, or what some devs call "preflight requests".
16 |
17 | 🚀 Hit the endpoint as users are entering information on the form to provide real-time feedback with 100% accuracy.
18 |
19 | 🚀 Validate only a subset of data of a multi-step form to guarantee success when the form is eventually submitted.
20 |
21 | ## Showcase
22 |
23 | 
24 |
25 | ## What problem does this package solve?
26 |
27 | A traditional approach to validating user input in JavaScript applications (Inertia / SPA / Mobile) is using a library such as **yup**
28 | to do the heavy lifting and delegating complex business validations to the server.
29 |
30 | However, the client-side can never be trusted, so you can't simply omit the validation rules that ran on the front-end.
31 | This means that validation has to live in 2 distinct places and you will have to keep them in sync.
32 | This is very tedious and wasteful, so this is where this package comes into play.
33 |
34 | ## Installation
35 |
36 | You can install the package via composer:
37 |
38 | ```bash
39 | composer require dive-be/laravel-dry-requests
40 | ```
41 |
42 | You can publish the config file with:
43 | ```bash
44 | php artisan vendor:publish --provider="Dive\DryRequests\ServiceProvider" --tag="config"
45 | ```
46 |
47 | This is the contents of the published config file:
48 |
49 | ```php
50 | return [
51 | /*
52 | |--------------------------------------------------------------------------
53 | | Default Dry Validation Behavior
54 | |--------------------------------------------------------------------------
55 | |
56 | | All dry requests are validated against a subset of the defined rules.
57 | | In other words only present fields are checked during the request.
58 | | You may choose to halt validation as soon as a failure occurs,
59 | | or continue validating all fields and return all failures.
60 | |
61 | | Supported: "all", "first"
62 | |
63 | */
64 |
65 | 'validation' => 'first',
66 | ];
67 | ```
68 |
69 | ## Behavior
70 |
71 | 💡 `Controller` logic is not executed after a successful validation attempt. `200 OK` is returned upon a successful dry run.
72 |
73 | 💡 **Only present fields** are validated to ensure good UX. Other fields are skipped using the `sometimes` rule.
74 | This means that *you* are responsible for only sending the relevant fields for validating e.g. a step of a multi-step wizard.
75 |
76 | ## Usage
77 |
78 | Assume the following endpoint: `POST /users` and `Controller`.
79 |
80 | ### Option 1 - using `FormRequest`s
81 |
82 | `Controller` injecting a `StoreUserRequest`:
83 |
84 | ```php
85 | class UserController
86 | {
87 | public function store(StoreUserRequest $request): UserResource
88 | {
89 | $user = User::create($request->validated());
90 |
91 | return new UserResource($user);
92 | }
93 | }
94 | ```
95 |
96 | Add the `DryRunnable` trait to your `FormRequest`:
97 |
98 | ```php
99 | class StoreUserRequest extends FormRequest
100 | {
101 | use DryRunnable;
102 |
103 | public function rules(): array
104 | {
105 | return [
106 | 'email' => ['required', 'email', 'max:255', 'unique:users'],
107 | 'username' => ['required', 'string', 'min:2', 'max:255', 'unique:users'],
108 | 'nickname' => ['nullable', 'string', 'min:2', 'max:255'],
109 | ];
110 | }
111 | }
112 | ```
113 |
114 | That's it 😎.
115 |
116 | ### Option 2 - using `validate` method on the `Request` object
117 |
118 | ```php
119 | class UserController
120 | {
121 | public function store(Request $request): UserResource
122 | {
123 | $validated = $request->validate([
124 | 'email' => ['required', 'email', 'max:255', 'unique:users'],
125 | 'username' => ['required', 'string', 'min:2', 'max:255', 'unique:users'],
126 | 'nickname' => ['nullable', 'string', 'min:2', 'max:255'],
127 | ]);
128 |
129 | $user = User::create($request->validated());
130 |
131 | return new UserResource($user);
132 | }
133 | }
134 | ```
135 |
136 | You don't have to do anything at all 🤙.
137 |
138 | ### Front-end execution
139 |
140 | Now, hit the endpoint from the client-side like you normally would.
141 | But with the added `X-Dry-Run` header.
142 |
143 | ```js
144 | // 1. "Username has already been taken" validation error
145 | axios.post('/users', { username: 'Agent007' }, { headers: { 'X-Dry-Run': true } })
146 | .then(response => response.status); // 422 Unprocessable Entity
147 |
148 | // 2. Successful unique username check: Controller did not execute
149 | axios.post('/users', { username: 'Asil Kan' }, { headers: { 'X-Dry-Run': true } })
150 | .then(response => response.status); // 200 OK
151 |
152 | // 3. Successful unique e-mail check: Controller did not execute
153 | axios.post('/users', { email: 'muhammed@dive.be' }, { headers: { 'X-Dry-Run': true } })
154 | .then(response => response.status); // 200 OK
155 |
156 | // 4. Submit entire form: Controller executed
157 | axios.post('/users', { email: 'muhammed@dive.be', username: 'Asil Kan' })
158 | .then(response => response.status); // 201 Created
159 | ```
160 |
161 | ### Inertia.js example
162 |
163 | ```js
164 | const { clearErrors, data, errors, setData } = useForm({
165 | email: '',
166 | password: '',
167 | password_confirmation: '',
168 | });
169 |
170 | const pick = (obj, fields) => fields.reduce((acc, cur) => (acc[cur] = obj[cur], acc), {});
171 |
172 | const validateAsync = (...fields) => () => {
173 | Inertia.post(route('register'), pick(data, fields) , {
174 | headers: { 'X-Dry-Run': 'all' },
175 | onError: setError,
176 | onSuccess() { clearErrors(...fields); },
177 | });
178 | }
179 |
180 | // Somewhere in your template
181 |
186 |
187 |
191 |
192 |
197 | ```
198 |
199 | ### Fine-tuning Dry Validations: `AllFailures` / `FirstFailure`
200 |
201 | - The default validation behavior for dry requests is halting validation as soon as an error is found.
202 | This is especially useful when handling async validation for a **single field**.
203 | - The other option is to keep validating even if an error is encountered.
204 | This is especially useful for multi-step forms.
205 |
206 | You can alter this behavior on 3 distinct levels.
207 |
208 | 1. Change `first` to `all` (or vice versa) in the `dry-request` config file. This will apply to all of your requests.
209 | 2. **FormRequest only** - Use the `Dive\DryRequests\Dry` attribute along with `Dive\DryRequests\Validation` on the `rules` method
210 | to force a specific `Validation` behavior for a particular `FormRequest`.
211 | ```php
212 | #[Dry(Validation::AllFailures)]
213 | public function rules(): array
214 | {
215 | return [...];
216 | }
217 | ```
218 | 3. Dictate the behavior on the fly from the front-end using the `X-Dry-Run` header. Valid values: `all`, `first`.
219 | ```php
220 | axios.post('/users', { email: '...', username: '...' }, { headers: { 'X-Dry-Run': 'all' } })
221 | .then(response => response.status); // 200 OK
222 | ```
223 | *Note: the header value will be ignored if you have explicitly set a validation behavior on the `FormRequest` using the `Dry` attribute.*
224 |
225 | ### Conflicting `FormRequest` methods
226 |
227 | The package makes use of the available methods `passedValidation` and `withValidator` available on `FormRequest` classes to enable its behavior.
228 |
229 | If you define these in your own requests, you must call the "dry" methods manually:
230 |
231 | ```php
232 | class CreateUserRequest extends FormRequest
233 | {
234 | protected function passedValidation()
235 | {
236 | $this->stopWhenDry(); // must be called first
237 |
238 | // your custom logic
239 | }
240 |
241 | protected function withValidator(Validator $instance)
242 | {
243 | $instance = /* your custom logic */;
244 |
245 | $this->withDryValidator($instance); // must be called last
246 | }
247 | }
248 | ```
249 |
250 | ## Testing
251 |
252 | ```bash
253 | composer test
254 | ```
255 |
256 | ## Upgrading
257 |
258 | Please see [UPGRADING](UPGRADING.md) for details.
259 |
260 | ## Changelog
261 |
262 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
263 |
264 | ## Contributing
265 |
266 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details.
267 |
268 | ## Security
269 |
270 | If you discover any security related issues, please email oss@dive.be instead of using the issue tracker.
271 |
272 | ## Credits
273 |
274 | - [Muhammed Sari](https://github.com/mabdullahsari)
275 | - [All Contributors](../../contributors)
276 |
277 | ## License
278 |
279 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
280 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dive-be/laravel-dry-requests",
3 | "description": "Dry run your Laravel requests",
4 | "keywords": [
5 | "dive",
6 | "laravel",
7 | "validation",
8 | "dry",
9 | "requests",
10 | "async",
11 | "validation"
12 | ],
13 | "homepage": "https://github.com/dive-be/laravel-dry-requests",
14 | "license": "MIT",
15 | "authors": [
16 | {
17 | "name": "Muhammed Sari",
18 | "email": "muhammed@dive.be",
19 | "homepage": "https://dive.be",
20 | "role": "Developer"
21 | }
22 | ],
23 | "require": {
24 | "php": "^8.1",
25 | "dive-be/php-enum-utils": "^1.1",
26 | "dive-be/php-utils": "^0.1.0",
27 | "laravel/framework": "^10.0"
28 | },
29 | "require-dev": {
30 | "friendsofphp/php-cs-fixer": "^3.8",
31 | "nunomaduro/larastan": "^2.6",
32 | "orchestra/testbench": "^8.5",
33 | "pestphp/pest": "^2.6",
34 | "pestphp/pest-plugin-laravel": "^2.0"
35 | },
36 | "autoload": {
37 | "psr-4": {
38 | "Dive\\DryRequests\\": "src"
39 | }
40 | },
41 | "autoload-dev": {
42 | "psr-4": {
43 | "Tests\\": "tests"
44 | }
45 | },
46 | "scripts": {
47 | "analyse": "vendor/bin/phpstan analyse --memory-limit=2G",
48 | "format": "vendor/bin/php-cs-fixer fix --config .php-cs-fixer.dist.php --allow-risky=yes",
49 | "test": "vendor/bin/pest",
50 | "verify": "@composer analyse && composer test"
51 | },
52 | "config": {
53 | "sort-packages": true,
54 | "allow-plugins": {
55 | "pestphp/pest-plugin": true
56 | }
57 | },
58 | "extra": {
59 | "laravel": {
60 | "providers": [
61 | "Dive\\DryRequests\\ServiceProvider"
62 | ]
63 | }
64 | },
65 | "minimum-stability": "dev",
66 | "prefer-stable": true
67 | }
68 |
--------------------------------------------------------------------------------
/config/dry-requests.php:
--------------------------------------------------------------------------------
1 | 'first',
19 | ];
20 |
--------------------------------------------------------------------------------
/src/Dry.php:
--------------------------------------------------------------------------------
1 | headers->has(ServiceProvider::HEADER);
17 | };
18 | }
19 |
20 | public function stopDryRequest(): Closure
21 | {
22 | return function (): never {
23 | /** @var \Illuminate\Http\Request $this */
24 | Event::dispatch(RequestRanDry::make($this));
25 |
26 | throw SucceededException::make();
27 | };
28 | }
29 |
30 | public function validate(): Closure
31 | {
32 | return function (array $rules, array $messages = [], array $customAttributes = []): array {
33 | /** @var \Illuminate\Http\Request $this */
34 | $validator = Validator::make($this->all(), $rules, $messages, $customAttributes);
35 |
36 | if (! $this->isDry()) {
37 | return $validator->validate();
38 | }
39 |
40 | Dryer::make($this)
41 | ->onlyPresent($validator)
42 | ->setBehavior($validator)
43 | ->validate();
44 |
45 | $this->stopDryRequest();
46 | };
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/DryRunnable.php:
--------------------------------------------------------------------------------
1 | stopWhenDry();
18 | }
19 |
20 | protected function stopWhenDry(): void
21 | {
22 | if ($this->isDry()) {
23 | $this->stopDryRequest();
24 | }
25 | }
26 |
27 | protected function withDryValidator(Validator $instance): Validator
28 | {
29 | return $this->isDry()
30 | ? Dryer::make($this)->onlyPresent($instance)->setBehavior($instance, $this->getBehavior())
31 | : $instance;
32 | }
33 |
34 | protected function withValidator(Validator $instance): void
35 | {
36 | $this->withDryValidator($instance);
37 | }
38 |
39 | private function getBehavior(): ?Validation
40 | {
41 | foreach ((new ReflectionMethod($this, 'rules'))->getAttributes(Dry::class) as $attribute) {
42 | return $attribute->newInstance()->behavior;
43 | }
44 |
45 | return null;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Dryer.php:
--------------------------------------------------------------------------------
1 | stopOnFirstFailure($this->getBehavior($behavior)->isFirstFailure());
20 | }
21 |
22 | public function onlyPresent(Validator $validator): self
23 | {
24 | $rules = $validator->getRules();
25 |
26 | foreach ($rules as &$definitions) {
27 | if (count($definitions) && reset($definitions) !== 'sometimes') {
28 | array_unshift($definitions, 'sometimes');
29 | }
30 | }
31 |
32 | $validator->setRules($rules);
33 |
34 | return $this;
35 | }
36 |
37 | private function getBehavior(?Validation $behavior): Validation
38 | {
39 | if ($behavior instanceof Validation) {
40 | return $behavior;
41 | }
42 |
43 | $default = Config::get('dry-requests.validation');
44 |
45 | if (in_array($header = $this->request->headers->get(ServiceProvider::HEADER), Validation::toValues())) {
46 | $default = $header;
47 | }
48 |
49 | return Validation::from($default);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/RequestRanDry.php:
--------------------------------------------------------------------------------
1 | ServiceProvider::HEADER]);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/ServiceProvider.php:
--------------------------------------------------------------------------------
1 | app->runningInConsole()) {
17 | $this->registerConfig();
18 | }
19 |
20 | Request::mixin(new DryRequestMacros());
21 | }
22 |
23 | public function register(): void
24 | {
25 | $this->callAfterResolving(ExceptionHandler::class, $this->registerException(...));
26 |
27 | $this->mergeConfigFrom(__DIR__ . '/../config/dry-requests.php', 'dry-requests');
28 | }
29 |
30 | private function registerConfig(): void
31 | {
32 | $this->publishes([
33 | __DIR__ . '/../config/dry-requests.php' => $this->app->configPath('dry-requests.php'),
34 | ], 'config');
35 | }
36 |
37 | private function registerException(ExceptionHandler $handler): void
38 | {
39 | if ($handler instanceof Handler) {
40 | $handler->ignore(SucceededException::class);
41 | $handler->renderable(fn (SucceededException $e) => $this->app->make(Responder::class)->respond());
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/SucceededException.php:
--------------------------------------------------------------------------------
1 |