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

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 |