├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── art ├── header.png ├── logo@1x.png ├── logo@2x.png ├── logo@3x.png ├── logo@4x.png └── socialcard.png ├── composer.json ├── infection.json5 ├── license.txt ├── phpstan.neon ├── phpunit.10.xml ├── phpunit.xml ├── pint.json ├── readme.md ├── src └── HasParameters.php └── tests ├── HasParametersTest.php └── Middleware ├── Aliased.php ├── Basic.php ├── Optional.php ├── OptionalRequired.php ├── Required.php ├── RequiredOptionalVariadic.php └── Variadic.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "19:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | checks: 11 | runs-on: ubuntu-latest 12 | name: 'Check' 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Get composer cache directory 18 | id: composer-cache 19 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: ${{ steps.composer-cache.outputs.dir }} 25 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 26 | restore-keys: ${{ runner.os }}-composer- 27 | 28 | - name: Setup PHP 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: '8.3' 32 | coverage: pcov 33 | tools: infection, pint, phpstan 34 | 35 | - name: Install dependencies 36 | run: composer install 37 | 38 | - name: Check platform requirements 39 | run: composer check-platform-reqs 40 | 41 | - name: Pint 42 | run: pint --test 43 | 44 | - name: Infection 45 | run: infection --show-mutations 46 | 47 | - name: PHPStan 48 | run: phpstan 49 | 50 | tests: 51 | runs-on: ubuntu-latest 52 | name: 'PHP ${{ matrix.php }} Illuminate ${{ matrix.illuminate }} Testbench ${{ matrix.testbench }} PHPUnit ${{ matrix.phpunit }}' 53 | strategy: 54 | matrix: 55 | php: ['8.1', '8.2', '8.3'] 56 | illuminate: ['10', '11'] 57 | include: 58 | - illuminate: '10' 59 | testbench: '8' 60 | phpunit: '10' 61 | - illuminate: '11' 62 | testbench: '9' 63 | phpunit: '11' 64 | exclude: 65 | - php: '8.1' 66 | illuminate: '11' 67 | steps: 68 | - name: Checkout code 69 | uses: actions/checkout@v4 70 | 71 | - name: Get composer cache directory 72 | id: composer-cache 73 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 74 | 75 | - name: Cache dependencies 76 | uses: actions/cache@v4 77 | with: 78 | path: ${{ steps.composer-cache.outputs.dir }} 79 | key: ${{ runner.os }}-php-${{ matrix.php }}-illuminate-${{ matrix.illuminate }}-composer-${{ hashFiles('**/composer.json') }} 80 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-illuminate-${{ matrix.illuminate }}-composer- 81 | 82 | - name: Setup PHP 83 | uses: shivammathur/setup-php@v2 84 | with: 85 | php-version: ${{ matrix.php }} 86 | coverage: none 87 | 88 | - name: Install dependencies 89 | run: | 90 | composer require --no-update \ 91 | illuminate/http:^${{ matrix.illuminate }} \ 92 | illuminate/support:^${{ matrix.illuminate }} 93 | composer require --no-update --dev \ 94 | orchestra/testbench:^${{ matrix.testbench }} 95 | composer update 96 | 97 | - name: Configure PHPUnit 98 | run: "if [ -f './phpunit.${{ matrix.phpunit }}.xml' ]; then cp ./phpunit.${{ matrix.phpunit }}.xml ./phpunit.xml; fi" 99 | 100 | - name: PHPUnit 101 | run: ./vendor/bin/phpunit --do-not-cache-result 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /.phpunit.cache 4 | -------------------------------------------------------------------------------- /art/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/has-parameters/e2bbca69e7b2f50c192f8b5e09bbee656ca607bb/art/header.png -------------------------------------------------------------------------------- /art/logo@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/has-parameters/e2bbca69e7b2f50c192f8b5e09bbee656ca607bb/art/logo@1x.png -------------------------------------------------------------------------------- /art/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/has-parameters/e2bbca69e7b2f50c192f8b5e09bbee656ca607bb/art/logo@2x.png -------------------------------------------------------------------------------- /art/logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/has-parameters/e2bbca69e7b2f50c192f8b5e09bbee656ca607bb/art/logo@3x.png -------------------------------------------------------------------------------- /art/logo@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/has-parameters/e2bbca69e7b2f50c192f8b5e09bbee656ca607bb/art/logo@4x.png -------------------------------------------------------------------------------- /art/socialcard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/has-parameters/e2bbca69e7b2f50c192f8b5e09bbee656ca607bb/art/socialcard.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timacdonald/has-parameters", 3 | "description": "A trait that allows you to pass arguments to Laravel middleware in a more PHP'ish way.", 4 | "keywords": [ 5 | "laravel", 6 | "middleware", 7 | "parameters", 8 | "arguments" 9 | ], 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Tim MacDonald", 14 | "email": "hello@timacdonald.me", 15 | "homepage": "https://timacdonald.me" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "illuminate/http": "^10.0 || ^11.0 || ^12.0", 21 | "illuminate/support": "^10.0 || ^11.0 || ^12.0" 22 | }, 23 | "require-dev": { 24 | "orchestra/testbench": "^8.0 || ^9.0 || ^10.0", 25 | "phpunit/phpunit": "^10.0 || ^11.0" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "TiMacDonald\\Middleware\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Tests\\": "tests/" 35 | } 36 | }, 37 | "config": { 38 | "preferred-install": "dist", 39 | "sort-packages": true 40 | }, 41 | "minimum-stability": "stable", 42 | "prefer-stable": true 43 | } 44 | -------------------------------------------------------------------------------- /infection.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "vendor/infection/infection/resources/schema.json", 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "mutators": { 9 | "@default": true 10 | }, 11 | "minMsi": 100 12 | } 13 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tim MacDonald 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | - tests 6 | -------------------------------------------------------------------------------- /phpunit.10.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "notPath": [ 3 | "tests/Middleware/OptionalRequired.php" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

Has Parameters: a Laravel package by Tim MacDonald

2 | 3 | # Has Parameters 4 | 5 | A trait for Laravel middleware that allows you to pass arguments in a more PHP'ish way, including as a key => value pair for named parameters, and as a list for variadic parameters. Improves static analysis / IDE support, allows you to specify arguments by referencing the parameter name, enables skipping optional parameters (which fallback to their default value), and adds some validation so you don't forget any required parameters by accident. 6 | 7 | Read more about the why in my blog post [Rethinking Laravel's middleware argument API](https://timacdonald.me/rethinking-laravels-middleware-argument-api/) 8 | 9 | ## Installation 10 | 11 | You can install using [composer](https://getcomposer.org/): 12 | 13 | ``` 14 | composer require timacdonald/has-parameters 15 | ``` 16 | 17 | ## Basic usage 18 | 19 | To get started with an example, I'm going to use a stripped back version of Laravel's `ThrottleRequests`. First up, add the `HasParameters` trait to your middleware. 20 | 21 | ```php 22 | class ThrottleRequests 23 | { 24 | use HasParameters; 25 | 26 | public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '') 27 | { 28 | // 29 | } 30 | } 31 | ``` 32 | 33 | You can now pass arguments to this middleware using the static `with()` method, using the parameter name as the key. 34 | 35 | ```php 36 | Route::stuff() 37 | ->middleware([ 38 | ThrottleRequests::with([ 39 | 'maxAttempts' => 120, 40 | ]), 41 | ]); 42 | ``` 43 | 44 | You'll notice at first this is a little more verbose, but I think you'll enjoy the complete feature set after reading these docs and taking it for a spin. 45 | 46 | ## Middleware::with() 47 | 48 | The static `with()` method allows you to easily see which values represent what when declaring your middleware, instead of just declaring a comma seperate list of values. 49 | The order of the keys does not matter. The trait will pair up the keys to the parameter names in the `handle()` method. 50 | 51 | ```php 52 | // before... 53 | Route::stuff() 54 | ->middleware([ 55 | 'throttle:10,1' // what does 10 or 1 stand for here? 56 | ]); 57 | 58 | // after... 59 | Route::stuff() 60 | ->middleware([ 61 | ThrottleRequests::with([ 62 | 'decayMinutes' => 1, 63 | 'maxAttempts' => 10, 64 | ]), 65 | ]); 66 | ``` 67 | 68 | ### Skipping parameters 69 | 70 | If any parameters in the `handle` method have a default value, you do not need to pass them through - unless you are changing their value. As an example, if you'd like to only specify a prefix for the `ThrottleRequests` middleware, but keep the `$decayMinutes` and `$maxAttempts` as their default values, you can do the following... 71 | 72 | ```php 73 | Route::stuff() 74 | ->middleware([ 75 | ThrottleRequests::with([ 76 | 'prefix' => 'admins', 77 | ]), 78 | ]); 79 | ``` 80 | 81 | As we saw previously in the handle method, the default values of `$decayMinutes` is `1` and `$maxAttempts` is `60`. The middleware will receive those values for those parameters, but will now receive `"admins"` for the `$prefix`. 82 | 83 | ### Arrays for variadic parameters 84 | 85 | When your middleware ends in a variadic paramater, you can pass an array of values for the variadic parameter key. Take a look at the following `handle()` method. 86 | 87 | ```php 88 | public function handle(Request $request, Closure $next, string $ability, string ...$models) 89 | ``` 90 | 91 | Here is how we can pass a list of values to the variadic `$models` parameter... 92 | 93 | ```php 94 | Route::stuff() 95 | ->middleware([ 96 | Authorize::with([ 97 | 'ability' => PostVideoPolicy::UPDATE, 98 | 'models' => [Post::class, Video::class], 99 | ]), 100 | ]); 101 | ``` 102 | 103 | ### Parameter aliases 104 | 105 | Some middleware will have different behaviour based on the type of values passed through to a specific parameter. As an example, Laravel's `ThrottleRequests` middleware allows you to pass the name of a rate limiter to the `$maxAttempts` parameter, instead of a numeric value, in order to utilise that named limiter on the endpoint. 106 | 107 | ```php 108 | // a named rate limiter... 109 | 110 | RateLimiter::for('api', function (Request $request) { 111 | return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip()); 112 | }); 113 | 114 | // using the rate limiter WITHOUT an alias... 115 | 116 | Route::stuff() 117 | ->middleware([ 118 | ThrottleRequests::with([ 119 | 'maxAttempts' => 'api', 120 | ]), 121 | ]); 122 | ``` 123 | 124 | In this kind of scenario, it is nice to be able to alias the `$maxAttempts` parameter name to something more readable. 125 | 126 | ```php 127 | Route::stuff() 128 | ->middleware([ 129 | ThrottleRequests::with([ 130 | 'limiter' => 'api', 131 | ]), 132 | ]); 133 | ``` 134 | 135 | To achieve this, you can setup a parameter alias map in your middleware... 136 | 137 | ```php 138 | class ThrottleRequests 139 | { 140 | use HasParameters; 141 | 142 | public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '') 143 | { 144 | // 145 | } 146 | 147 | protected static function parameterAliasMap(): array 148 | { 149 | return [ 150 | 'limiter' => 'maxAttempts', 151 | // 'alias' => 'parameter', 152 | ]; 153 | } 154 | } 155 | ``` 156 | 157 | ### Validation 158 | 159 | These validations occur whenever the routes file is loaded or compiled, not just when you hit a route that contains the declaration. 160 | 161 | #### Unexpected parameter 162 | 163 | Ensures that you do not declare any keys that do not exist as parameter variables in the `handle()` method. This helps make sure you don't mis-type a parameter name. 164 | 165 | #### Required parameters 166 | 167 | Ensures all required parameters (those without default values) have been provided. 168 | 169 | #### Aliases 170 | 171 | - Ensures all aliases specified reference an existing parameter. 172 | - Provided aliases don't reference the same parameter. 173 | - An original parameter key and an alias have not both been provided. 174 | 175 | ## Middleware::in() 176 | 177 | The static `in()` method very much reflects and works the same as the existing concatination API. It accepts a list of values, i.e. a non-associative array. You should use this method if your `handle()` method is a single variadic parameter, i.e. expecting a single list of values, as shown in the following middleware handle method... 178 | . 179 | ```php 180 | public function handle(Request $request, Closure $next, string ...$states) 181 | { 182 | // 183 | } 184 | ``` 185 | 186 | You can pass through a list of "states" to the middleware like so... 187 | 188 | ```php 189 | Route::stuff() 190 | ->middleware([ 191 | EnsurePostState::in([PostState::DRAFT, PostState::UNDER_REVIEW]), 192 | ]); 193 | ``` 194 | 195 | ### Validation 196 | 197 | #### Required parameters 198 | 199 | Just like the `with()` method, the `in()` method will validate that you have passed enough values through to cover all the required parameters. Because variadic parameters do not require any values to be passed through, you only really rub up against this when you should probably be using the `with()` method. 200 | 201 | ## Value transformation 202 | 203 | You should keep in mind that everything will still be cast to a string. Although you are passing in, for example, integers, the middleware itself will *always* receive a string. This is how Laravel works under-the-hood to implement route caching. 204 | 205 | One thing to note is the `false` is actually cast to the string `"0"` to keep some consistency with casting `true` to a string, which results in the string `"1"`. 206 | 207 | ## Typing values 208 | 209 | It is possible to provide stronge type information by utilising docblocks on your middleware class. Here is an example of how you could create a strongly typed middleware: 210 | 211 | ```php 212 | /** 213 | * @method static string with(array{ 214 | * maxAttempts?: int, 215 | * decayMinutes?: float|int, 216 | * prefix?: string, 217 | * }|'admin' $arguments) 218 | */ 219 | class ThrottleMiddleware 220 | { 221 | use HasParameters; 222 | 223 | // ... 224 | } 225 | ``` 226 | 227 | You will then receive autocomplete and diagnostics from your language server: 228 | 229 | ```php 230 | ThrottleMiddleware::with('admin'); 231 | // ✅ 232 | 233 | ThrottleMiddleware::with(['decayMinutes' => 10]); 234 | // ✅ 235 | 236 | ThrottleMiddleware::with('foo'); 237 | // ❌ fails because 'foo' is not in the allowed string values 238 | 239 | ThrottleMiddleware::with(['maxAttempts' => 'ten']); 240 | // ❌ fails because `maxAttempts` must be an int 241 | ``` 242 | 243 | Checkout the example in the [PHPStan playground](https://phpstan.org/r/8c0ba5d8-a730-4fd9-9af8-bcec33d3b043). 244 | 245 | ## Credits 246 | 247 | - [Tim MacDonald](https://github.com/timacdonald) 248 | - [All Contributors](../../contributors) 249 | 250 | And a special (vegi) thanks to [Caneco](https://twitter.com/caneco) for the logo ✨ 251 | 252 | ## Thanksware 253 | 254 | You are free to use this package, but I ask that you reach out to someone (not me) who has previously, or is currently, maintaining or contributing to an open source library you are using in your project and thank them for their work. Consider your entire tech stack: packages, frameworks, languages, databases, operating systems, frontend, backend, etc. 255 | -------------------------------------------------------------------------------- /src/HasParameters.php: -------------------------------------------------------------------------------- 1 | |array $arguments 18 | */ 19 | public static function with($arguments): string 20 | { 21 | $arguments = new Collection($arguments); 22 | 23 | $parameters = self::parameters(); 24 | 25 | self::validateArgumentMapIsAnAssociativeArray($arguments); 26 | 27 | $aliases = new Collection(self::parameterAliasMap()); 28 | 29 | if ($aliases->isNotEmpty()) { 30 | self::validateAliasesReferenceParameters($parameters, $aliases); 31 | 32 | self::validateAliasesDontPointToSameParameters($aliases); 33 | 34 | self::validateOriginalAndAliasHaveNotBeenPassed($arguments, $aliases); 35 | 36 | $arguments = self::normaliseArguments($arguments, $aliases); 37 | } 38 | 39 | self::validateNoUnexpectedArguments($parameters, $arguments); 40 | 41 | self::validateParametersAreOptional( 42 | /** @phpstan-ignore argument.type */ 43 | $parameters->diffKeys($arguments) 44 | ); 45 | 46 | $arguments = self::parseArgumentMap($parameters, $arguments); 47 | 48 | return self::formatArguments($arguments); 49 | } 50 | 51 | /** 52 | * @param Collection|array $arguments 53 | */ 54 | public static function in($arguments): string 55 | { 56 | $arguments = new Collection($arguments); 57 | 58 | $parameters = self::parameters(); 59 | 60 | self::validateArgumentListIsNotAnAssociativeArray($arguments); 61 | 62 | self::validateParametersAreOptional($parameters->slice($arguments->count())); 63 | 64 | $arguments = self::parseArgumentList($arguments); 65 | 66 | return self::formatArguments($arguments); 67 | } 68 | 69 | /** 70 | * @infection-ignore-all 71 | * 72 | * @return array 73 | */ 74 | protected static function parameterAliasMap(): array 75 | { 76 | return [ 77 | // 'alias' => 'parameter', 78 | ]; 79 | } 80 | 81 | /** 82 | * @param Collection $arguments 83 | */ 84 | private static function formatArguments(Collection $arguments): string 85 | { 86 | if ($arguments->isEmpty()) { 87 | return static::class; 88 | } 89 | 90 | return static::class.':'.$arguments->implode(','); 91 | } 92 | 93 | /** 94 | * @param Collection $arguments 95 | * @return Collection 96 | */ 97 | private static function parseArgumentList(Collection $arguments): Collection 98 | { 99 | return $arguments->map(function ($argument): string { 100 | return self::castToString($argument); 101 | }); 102 | } 103 | 104 | /** 105 | * @param Collection $parameters 106 | * @param Collection $arguments 107 | * @return Collection 108 | */ 109 | private static function parseArgumentMap(Collection $parameters, Collection $arguments): Collection 110 | { 111 | /** @phpstan-ignore return.type */ 112 | return $parameters->map(function (ReflectionParameter $parameter) use ($arguments): ?string { 113 | if ($parameter->isVariadic()) { 114 | return self::parseVariadicArgument($parameter, $arguments); 115 | } 116 | 117 | return self::parseStandardArgument($parameter, $arguments); 118 | })->reject(function (?string $argument): bool { 119 | /** 120 | * A null value indicates that the last item in the parameter list 121 | * is a variadic function that is not expecting any values. Because 122 | * of the way variadic parameters work, we don't want to pass null, 123 | * we really want to pass void, so we just filter it out of the 124 | * list completely. null !== void. 125 | */ 126 | return $argument === null; 127 | }); 128 | } 129 | 130 | /** 131 | * @param Collection $arguments 132 | */ 133 | private static function parseVariadicArgument(ReflectionParameter $parameter, Collection $arguments): ?string 134 | { 135 | if (! $arguments->has($parameter->getName())) { 136 | return null; 137 | } 138 | 139 | /** @phpstan-ignore argument.type */ 140 | $values = new Collection($arguments->get($parameter->getName())); 141 | 142 | if ($values->isEmpty()) { 143 | return null; 144 | } 145 | 146 | return $values->map( 147 | /** 148 | * @param mixed $value 149 | */ 150 | function ($value) { 151 | return self::castToString($value); 152 | } 153 | )->implode(','); 154 | } 155 | 156 | /** 157 | * @param Collection $arguments 158 | */ 159 | private static function parseStandardArgument(ReflectionParameter $parameter, Collection $arguments): string 160 | { 161 | if ($arguments->has($parameter->getName())) { 162 | return self::castToString($arguments->get($parameter->getName())); 163 | } 164 | 165 | return self::castToString($parameter->getDefaultValue()); 166 | } 167 | 168 | /** 169 | * @return Collection 170 | */ 171 | private static function parameters(): Collection 172 | { 173 | $handle = new ReflectionMethod(static::class, 'handle'); 174 | 175 | return Collection::make($handle->getParameters()) 176 | ->skip(2) 177 | ->keyBy(function (ReflectionParameter $parameter): string { 178 | return $parameter->getName(); 179 | }); 180 | } 181 | 182 | /** 183 | * @param mixed $value 184 | */ 185 | private static function castToString($value): string 186 | { 187 | if ($value === false) { 188 | return '0'; 189 | } 190 | 191 | if ($value instanceof BackedEnum) { 192 | return (string) $value->value; 193 | } 194 | 195 | /** @phpstan-ignore cast.string */ 196 | return (string) $value; 197 | } 198 | 199 | /** 200 | * @param Collection $arguments 201 | * @param Collection $aliases 202 | * @return Collection 203 | */ 204 | private static function normaliseArguments(Collection $arguments, Collection $aliases): Collection 205 | { 206 | return $arguments->mapWithKeys( 207 | /** @param mixed $value */ 208 | function ($value, string $name) use ($aliases): array { 209 | if ($aliases->has($name)) { 210 | /** @var string */ 211 | $newName = $aliases[$name]; 212 | 213 | return [$newName => $value]; 214 | } 215 | 216 | return [$name => $value]; 217 | } 218 | ); 219 | } 220 | 221 | /** 222 | * @param Collection $parameters 223 | */ 224 | private static function validateParametersAreOptional(Collection $parameters): void 225 | { 226 | /** @var ?ReflectionParameter */ 227 | $missingRequiredParameter = $parameters->reject(function (ReflectionParameter $parameter): bool { 228 | return $parameter->isDefaultValueAvailable() || $parameter->isVariadic(); 229 | })->first(); 230 | 231 | if ($missingRequiredParameter === null) { 232 | return; 233 | } 234 | 235 | throw new TypeError('Missing required argument $'.$missingRequiredParameter->getName().' for middleware '.static::class.'::handle()'); 236 | } 237 | 238 | /** 239 | * @param Collection $arguments 240 | */ 241 | private static function validateArgumentListIsNotAnAssociativeArray(Collection $arguments): void 242 | { 243 | if (Arr::isAssoc($arguments->all())) { 244 | throw new TypeError('Expected a non-associative array in HasParameters::in() but received an associative array. You should use the HasParameters::with() method instead.'); 245 | } 246 | } 247 | 248 | /** 249 | * @param Collection $arguments 250 | */ 251 | private static function validateArgumentMapIsAnAssociativeArray(Collection $arguments): void 252 | { 253 | if ($arguments->isNotEmpty() && ! Arr::isAssoc($arguments->all())) { 254 | throw new TypeError('Expected an associative array in HasParameters::with() but received a non-associative array. You should use the HasParameters::in() method instead.'); 255 | } 256 | } 257 | 258 | /** 259 | * @param Collection $parameters 260 | * @param Collection $arguments 261 | */ 262 | private static function validateNoUnexpectedArguments(Collection $parameters, Collection $arguments): void 263 | { 264 | /** @var ?string */ 265 | $unexpectedArgument = $arguments->keys() 266 | ->first(function (string $name) use ($parameters): bool { 267 | return ! $parameters->has($name); 268 | }); 269 | 270 | if ($unexpectedArgument === null) { 271 | return; 272 | } 273 | 274 | throw new TypeError('Unknown argument $'.$unexpectedArgument.' passed to middleware '.static::class.'::handle()'); 275 | } 276 | 277 | /** 278 | * @param Collection $arguments 279 | * @param Collection $aliases 280 | */ 281 | private static function validateOriginalAndAliasHaveNotBeenPassed(Collection $arguments, Collection $aliases): void 282 | { 283 | if ($arguments->intersectByKeys($aliases->flip())->isNotEmpty()) { 284 | throw new TypeError('Cannot pass an original parameter and an aliases parameter name at the same time.'); 285 | } 286 | } 287 | 288 | /** 289 | * @param Collection $aliases 290 | */ 291 | private static function validateAliasesDontPointToSameParameters(Collection $aliases): void 292 | { 293 | if ($aliases->unique()->count() !== $aliases->count()) { 294 | throw new TypeError('Two provided aliases cannot point to the same parameter.'); 295 | } 296 | } 297 | 298 | /** 299 | * @param Collection $parameters 300 | * @param Collection $aliases 301 | */ 302 | private static function validateAliasesReferenceParameters(Collection $parameters, Collection $aliases): void 303 | { 304 | /** @phpstan-ignore argument.type */ 305 | if ($aliases->flip()->diffKeys($parameters)->isNotEmpty()) { 306 | throw new TypeError('Aliases must reference existing parameters.'); 307 | } 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /tests/HasParametersTest.php: -------------------------------------------------------------------------------- 1 | assertSame('Tests\\Middleware\\Basic', $result); 28 | 29 | $result = Basic::in([null]); 30 | $this->assertSame('Tests\\Middleware\\Basic:', $result); 31 | 32 | $result = Basic::in(['']); 33 | $this->assertSame('Tests\\Middleware\\Basic:', $result); 34 | 35 | $result = Basic::in([' ']); 36 | $this->assertSame('Tests\\Middleware\\Basic: ', $result); 37 | 38 | $result = Basic::in([1.2]); 39 | $this->assertSame('Tests\\Middleware\\Basic:1.2', $result); 40 | 41 | $result = Basic::in(['laravel']); 42 | $this->assertSame('Tests\\Middleware\\Basic:laravel', $result); 43 | 44 | $result = Basic::in([Framework::Laravel]); 45 | $this->assertSame('Tests\\Middleware\\Basic:laravel', $result); 46 | 47 | $result = Basic::in([IntEnum::Laravel]); 48 | $this->assertSame('Tests\\Middleware\\Basic:1', $result); 49 | 50 | $result = Basic::in(['laravel', 'vue']); 51 | $this->assertSame('Tests\\Middleware\\Basic:laravel,vue', $result); 52 | 53 | $result = Basic::in([Framework::Laravel, Framework::Vue]); 54 | $this->assertSame('Tests\\Middleware\\Basic:laravel,vue', $result); 55 | 56 | $result = Basic::in(['laravel', ' ', null, 'tailwind']); 57 | $this->assertSame('Tests\\Middleware\\Basic:laravel, ,,tailwind', $result); 58 | 59 | $result = Basic::in(new Collection(['laravel', 'vue'])); 60 | $this->assertSame('Tests\\Middleware\\Basic:laravel,vue', $result); 61 | 62 | $result = Basic::in(new Collection([Framework::Laravel, Framework::Vue])); 63 | $this->assertSame('Tests\\Middleware\\Basic:laravel,vue', $result); 64 | 65 | $result = Basic::in([new Collection(['laravel', 'vue'])]); 66 | $this->assertSame('Tests\\Middleware\\Basic:["laravel","vue"]', $result); 67 | 68 | $result = Basic::in([new Collection([Framework::Laravel, Framework::Vue])]); 69 | $this->assertSame('Tests\\Middleware\\Basic:["laravel","vue"]', $result); 70 | 71 | $result = Basic::in([true, false]); 72 | $this->assertSame('Tests\\Middleware\\Basic:1,0', $result); 73 | 74 | $result = Variadic::in([]); 75 | $this->assertSame('Tests\\Middleware\\Variadic', $result); 76 | 77 | $result = Variadic::in(['laravel', 'vue']); 78 | $this->assertSame('Tests\\Middleware\\Variadic:laravel,vue', $result); 79 | 80 | $result = Variadic::in([Framework::Laravel, Framework::Vue]); 81 | $this->assertSame('Tests\\Middleware\\Variadic:laravel,vue', $result); 82 | 83 | $result = RequiredOptionalVariadic::in(['laravel']); 84 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel', $result); 85 | 86 | $result = RequiredOptionalVariadic::in(['laravel', 'vue', 'tailwind', 'react']); 87 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,vue,tailwind,react', $result); 88 | 89 | $result = RequiredOptionalVariadic::in([Framework::Laravel, Framework::Vue, Framework::Tailwind, Framework::React]); 90 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,vue,tailwind,react', $result); 91 | } 92 | 93 | public function testListDoesNotAcceptSubArray(): void 94 | { 95 | $this->expectException(ErrorException::class); 96 | $this->expectExceptionMessage('Array to string conversion'); 97 | 98 | Basic::in(['laravel', ['vue', 'react']]); 99 | } 100 | 101 | public function testListDetectsRequiredParametersThatHaveNotBeenProvidedAfterAnOptional(): void 102 | { 103 | if (PHP_MAJOR_VERSION >= 8) { 104 | $this->markTestSkipped('Cannot have optional parameter before required parameter in PHP >=8.0.'); 105 | } 106 | 107 | $this->expectException(TypeError::class); 108 | $this->expectExceptionMessage('Missing required argument $required for middleware Tests\\Middleware\\OptionalRequired::handle()'); 109 | 110 | OptionalRequired::in(['laravel']); 111 | } 112 | 113 | public function testListDetectsRequiredParametersThatHaveNotBeenProvided(): void 114 | { 115 | $this->expectException(TypeError::class); 116 | $this->expectExceptionMessage('Missing required argument $required for middleware Tests\\Middleware\\Required::handle()'); 117 | 118 | Required::in([]); 119 | } 120 | 121 | public function testListDoesNotAcceptAssociativeArray(): void 122 | { 123 | $this->expectException(TypeError::class); 124 | $this->expectExceptionMessage('Expected a non-associative array in HasParameters::in() but received an associative array. You should use the HasParameters::with() method instead.'); 125 | 126 | /** @phpstan-ignore argument.type */ 127 | Basic::in(['framework' => 'laravel']); 128 | } 129 | 130 | public function testMap(): void 131 | { 132 | $result = Required::with(['required' => null]); 133 | $this->assertSame('Tests\\Middleware\\Required:', $result); 134 | 135 | $result = Required::with(['required' => '']); 136 | $this->assertSame('Tests\\Middleware\\Required:', $result); 137 | 138 | $result = Required::with(['required' => ' ']); 139 | $this->assertSame('Tests\\Middleware\\Required: ', $result); 140 | 141 | $result = Required::with(['required' => false]); 142 | $this->assertSame('Tests\\Middleware\\Required:0', $result); 143 | 144 | $result = Required::with(['required' => true]); 145 | $this->assertSame('Tests\\Middleware\\Required:1', $result); 146 | 147 | $result = Required::with(['required' => 'laravel']); 148 | $this->assertSame('Tests\\Middleware\\Required:laravel', $result); 149 | 150 | $result = Required::with(['required' => Framework::Laravel]); 151 | $this->assertSame('Tests\\Middleware\\Required:laravel', $result); 152 | 153 | $result = Required::with(['required' => 1.2]); 154 | $this->assertSame('Tests\\Middleware\\Required:1.2', $result); 155 | 156 | $result = Required::with(new Collection(['required' => 'laravel'])); 157 | $this->assertSame('Tests\\Middleware\\Required:laravel', $result); 158 | 159 | $result = Required::with(new Collection(['required' => Framework::Laravel])); 160 | $this->assertSame('Tests\\Middleware\\Required:laravel', $result); 161 | 162 | $result = Required::with(['required' => new Collection(['laravel', 'vue'])]); 163 | $this->assertSame('Tests\\Middleware\\Required:["laravel","vue"]', $result); 164 | 165 | $result = Required::with(['required' => new Collection([Framework::Laravel, Framework::Vue])]); 166 | $this->assertSame('Tests\\Middleware\\Required:["laravel","vue"]', $result); 167 | 168 | $result = Optional::with([]); 169 | $this->assertSame('Tests\\Middleware\\Optional:default', $result); 170 | 171 | $result = Optional::with(['optional' => null]); 172 | $this->assertSame('Tests\\Middleware\\Optional:', $result); 173 | 174 | $result = Optional::with(['optional' => '']); 175 | $this->assertSame('Tests\\Middleware\\Optional:', $result); 176 | 177 | $result = Optional::with(['optional' => ' ']); 178 | $this->assertSame('Tests\\Middleware\\Optional: ', $result); 179 | 180 | $result = Optional::with(['optional' => 1.2]); 181 | $this->assertSame('Tests\\Middleware\\Optional:1.2', $result); 182 | 183 | $result = Optional::with(['optional' => 'laravel']); 184 | $this->assertSame('Tests\\Middleware\\Optional:laravel', $result); 185 | 186 | $result = Optional::with(['optional' => Framework::Laravel]); 187 | $this->assertSame('Tests\\Middleware\\Optional:laravel', $result); 188 | 189 | $result = Optional::with(new Collection(['optional' => 'laravel'])); 190 | $this->assertSame('Tests\\Middleware\\Optional:laravel', $result); 191 | 192 | $result = Optional::with(new Collection(['optional' => Framework::Laravel])); 193 | $this->assertSame('Tests\\Middleware\\Optional:laravel', $result); 194 | 195 | $result = Optional::with(['optional' => new Collection(['laravel', 'vue'])]); 196 | $this->assertSame('Tests\\Middleware\\Optional:["laravel","vue"]', $result); 197 | 198 | $result = Optional::with(['optional' => new Collection([Framework::Laravel, Framework::Vue])]); 199 | $this->assertSame('Tests\\Middleware\\Optional:["laravel","vue"]', $result); 200 | 201 | $result = Optional::with(['optional' => true]); 202 | $this->assertSame('Tests\\Middleware\\Optional:1', $result); 203 | 204 | $result = Optional::with(['optional' => false]); 205 | $this->assertSame('Tests\\Middleware\\Optional:0', $result); 206 | 207 | $result = Variadic::with(['variadic' => '']); 208 | $this->assertSame('Tests\\Middleware\\Variadic:', $result); 209 | 210 | $result = Variadic::with(['variadic' => ' ']); 211 | $this->assertSame('Tests\\Middleware\\Variadic: ', $result); 212 | 213 | $result = Variadic::with(['variadic' => 1.2]); 214 | $this->assertSame('Tests\\Middleware\\Variadic:1.2', $result); 215 | 216 | $result = Variadic::with(['variadic' => 'laravel']); 217 | $this->assertSame('Tests\\Middleware\\Variadic:laravel', $result); 218 | 219 | $result = Variadic::with(['variadic' => Framework::Laravel]); 220 | $this->assertSame('Tests\\Middleware\\Variadic:laravel', $result); 221 | 222 | $result = Variadic::with(['variadic' => ['laravel', 'vue']]); 223 | $this->assertSame('Tests\\Middleware\\Variadic:laravel,vue', $result); 224 | 225 | $result = Variadic::with(['variadic' => [Framework::Laravel, Framework::Vue]]); 226 | $this->assertSame('Tests\\Middleware\\Variadic:laravel,vue', $result); 227 | 228 | $result = Variadic::with(['variadic' => ['laravel', ' ', null, 'vue']]); 229 | $this->assertSame('Tests\\Middleware\\Variadic:laravel, ,,vue', $result); 230 | 231 | $result = Variadic::with(['variadic' => new Collection(['laravel', 'vue'])]); 232 | $this->assertSame('Tests\\Middleware\\Variadic:laravel,vue', $result); 233 | 234 | $result = Variadic::with(['variadic' => new Collection([Framework::Laravel, Framework::Vue])]); 235 | $this->assertSame('Tests\\Middleware\\Variadic:laravel,vue', $result); 236 | 237 | $result = Variadic::with(['variadic' => [new Collection(['laravel', 'vue'])]]); 238 | $this->assertSame('Tests\\Middleware\\Variadic:["laravel","vue"]', $result); 239 | 240 | $result = Variadic::with(['variadic' => [new Collection([Framework::Laravel, Framework::Vue])]]); 241 | $this->assertSame('Tests\\Middleware\\Variadic:["laravel","vue"]', $result); 242 | 243 | $result = Variadic::with(['variadic' => true]); 244 | $this->assertSame('Tests\\Middleware\\Variadic:1', $result); 245 | 246 | $result = Variadic::with(['variadic' => false]); 247 | $this->assertSame('Tests\\Middleware\\Variadic:0', $result); 248 | 249 | // Cannot have optional parameter before required parameter in PHP >=8.0. 250 | if (PHP_MAJOR_VERSION < 8) { 251 | $result = OptionalRequired::with(['required' => 'laravel']); 252 | $this->assertSame('Tests\\Middleware\\OptionalRequired:default,laravel', $result); 253 | 254 | $result = OptionalRequired::with(['required' => 'laravel', 'optional' => 'vue']); 255 | $this->assertSame('Tests\\Middleware\\OptionalRequired:vue,laravel', $result); 256 | } 257 | 258 | $result = RequiredOptionalVariadic::with(['required' => 'laravel']); 259 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,default', $result); 260 | 261 | $result = RequiredOptionalVariadic::with(['required' => Framework::Laravel]); 262 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,default', $result); 263 | 264 | $result = RequiredOptionalVariadic::with(['required' => 'laravel', 'optional' => 'vue']); 265 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,vue', $result); 266 | 267 | $result = RequiredOptionalVariadic::with(['required' => 'laravel', 'optional' => 'vue', 'variadic' => 'tailwind']); 268 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,vue,tailwind', $result); 269 | 270 | $result = RequiredOptionalVariadic::with(['required' => 'laravel', 'optional' => 'vue', 'variadic' => ['tailwind', 'react']]); 271 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,vue,tailwind,react', $result); 272 | 273 | $result = RequiredOptionalVariadic::with(['required' => Framework::Laravel, 'optional' => Framework::Vue, 'variadic' => [Framework::Tailwind, Framework::React]]); 274 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,vue,tailwind,react', $result); 275 | 276 | $result = RequiredOptionalVariadic::with(['required' => 'laravel', 'optional' => 'vue', 'variadic' => []]); 277 | $this->assertSame('Tests\\Middleware\\RequiredOptionalVariadic:laravel,vue', $result); 278 | } 279 | 280 | public function testMapDoesNotAcceptSubArray(): void 281 | { 282 | $this->expectException(ErrorException::class); 283 | $this->expectExceptionMessage('Array to string conversion'); 284 | 285 | Required::with(['required' => ['vue', 'react']]); 286 | } 287 | 288 | public function testMapMustContainRequiredArguments(): void 289 | { 290 | $this->expectException(TypeError::class); 291 | $this->expectExceptionMessage('Missing required argument $required for middleware Tests\\Middleware\\RequiredOptionalVariadic::handle()'); 292 | 293 | RequiredOptionalVariadic::with(['optional' => 'vue']); 294 | } 295 | 296 | public function testMapMustHaveEnoughRequiredArguments(): void 297 | { 298 | $this->expectException(TypeError::class); 299 | $this->expectExceptionMessage('Missing required argument $required for middleware Tests\\Middleware\\Required::handle()'); 300 | 301 | Required::with([]); 302 | } 303 | 304 | public function testMapDoesNotAcceptANonAssociativeArray(): void 305 | { 306 | $this->expectException(TypeError::class); 307 | $this->expectExceptionMessage('Expected an associative array in HasParameters::with() but received a non-associative array. You should use the HasParameters::in() method instead.'); 308 | 309 | /** @phpstan-ignore argument.type */ 310 | Basic::with(['framework', 'laravel']); 311 | } 312 | 313 | public function testMapMustPassCorrectRequiredArguments(): void 314 | { 315 | $this->expectException(TypeError::class); 316 | $this->expectExceptionMessage('Unknown argument $missing passed to middleware Tests\\Middleware\\Required::handle()'); 317 | 318 | Required::with(['missing' => 'test']); 319 | } 320 | 321 | public function testMapVariadicWithIncorrectArgumentName(): void 322 | { 323 | $this->expectException(TypeError::class); 324 | $this->expectExceptionMessage('Unknown argument $missing passed to middleware Tests\\Middleware\\Variadic::handle()'); 325 | 326 | Variadic::with(['missing' => 'laravel']); 327 | } 328 | 329 | public function testVariadicDoesNotAcceptSubArray(): void 330 | { 331 | $this->expectException(ErrorException::class); 332 | $this->expectExceptionMessage('Array to string conversion'); 333 | 334 | Variadic::with(['variadic' => [['laravel', 'vue']]]); 335 | } 336 | 337 | public function testMiddlewareThatUsesFuncGetArgsCanAccessArgumentsThatAreNotPassedAsParameters(): void 338 | { 339 | $result = Optional::in(['laravel', 'vue', 'tailwind']); 340 | $this->assertSame('Tests\\Middleware\\Optional:laravel,vue,tailwind', $result); 341 | } 342 | 343 | public function testParametersCanBeAliased(): void 344 | { 345 | $result = Aliased::with([ 346 | 'aliasedFirst' => 'first', 347 | 'aliasedThird' => 'third', 348 | 'originalSecond' => 'second', 349 | ]); 350 | $this->assertSame('Tests\\Middleware\\Aliased:first,second,third', $result); 351 | } 352 | 353 | public function testParameterAliasesDontConflictWithOtherAliasNames(): void 354 | { 355 | $this->expectException(TypeError::class); 356 | $this->expectExceptionMessage('Two provided aliases cannot point to the same parameter.'); 357 | 358 | $middleware = new class() 359 | { 360 | use HasParameters; 361 | 362 | public function handle(Request $request, Closure $next, string $original, string $anotherOne): void 363 | { 364 | // 365 | } 366 | 367 | /** 368 | * @return array 369 | */ 370 | private static function parameterAliasMap(): array 371 | { 372 | return [ 373 | 'firstAlias' => 'original', 374 | 'secondAlias' => 'original', 375 | 'fourthAlias' => 'anotherOne', 376 | ]; 377 | } 378 | }; 379 | 380 | $middleware::with([ 381 | 'firstAlias' => 'xxxx', 382 | 'secondAlias' => 'xxxx', 383 | ]); 384 | } 385 | 386 | public function testAliasesReferenceActualParameters(): void 387 | { 388 | $this->expectException(TypeError::class); 389 | $this->expectExceptionMessage('Aliases must reference existing parameters.'); 390 | 391 | $middleware = new class() 392 | { 393 | use HasParameters; 394 | 395 | public function handle(Request $request, Closure $next, string $original): void 396 | { 397 | // 398 | } 399 | 400 | /** 401 | * @return array 402 | */ 403 | private static function parameterAliasMap(): array 404 | { 405 | return [ 406 | 'firstAlias' => 'doesntExist', 407 | ]; 408 | } 409 | }; 410 | 411 | $middleware::with([ 412 | 'firstAlias' => 'xxxx', 413 | ]); 414 | } 415 | 416 | public function testPassingOriginalAndAliasThrows(): void 417 | { 418 | $this->expectException(TypeError::class); 419 | $this->expectExceptionMessage('Cannot pass an original parameter and an aliases parameter name at the same time.'); 420 | 421 | Aliased::with([ 422 | 'aliasedFirst' => 'aliasValue', 423 | 'originalFirst' => 'originalValue', 424 | 'originalSecond' => 'second', 425 | ]); 426 | } 427 | } 428 | 429 | enum Framework: string 430 | { 431 | case Laravel = 'laravel'; 432 | case Vue = 'vue'; 433 | case Tailwind = 'tailwind'; 434 | case React = 'react'; 435 | } 436 | 437 | enum IntEnum: int 438 | { 439 | case Laravel = 1; 440 | } 441 | -------------------------------------------------------------------------------- /tests/Middleware/Aliased.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | private static function parameterAliasMap(): array 22 | { 23 | return [ 24 | 'aliasedFirst' => 'originalFirst', 25 | 'aliasedThird' => 'originalThird', 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Middleware/Basic.php: -------------------------------------------------------------------------------- 1 | =8.0. 10 | if (PHP_MAJOR_VERSION < 8) { 11 | class OptionalRequired 12 | { 13 | use HasParameters; 14 | 15 | /** @phpstan-ignore parameter.requiredAfterOptional */ 16 | public function handle(Request $request, Closure $next, string $optional = 'default', string $required): void 17 | { 18 | // 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Middleware/Required.php: -------------------------------------------------------------------------------- 1 |