├── 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 |

Social Card of Laravel Dry Requests

8 | 9 | # X-Dry-Run your requests 10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/dive-be/laravel-dry-requests.svg?style=flat-square)](https://packagist.org/packages/dive-be/laravel-dry-requests) 12 | [![Total Downloads](https://img.shields.io/packagist/dt/dive-be/laravel-dry-requests.svg?style=flat-square)](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 | ![LDR Demo](./art/demo.gif) 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 |