├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── composer.json
└── src
└── ModelRouteValidator.php
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Laravel Real Model Route
2 | ========================
3 |
4 | 1.0.6, March 6, 2025
5 | --------------------
6 |
7 | - Enh: Added support for "illuminate/routing" 12.0 (klimov-paul)
8 |
9 |
10 | 1.0.5, March 25, 2024
11 | ---------------------
12 |
13 | - Enh: Added support for "illuminate/routing" 11.0 (klimov-paul)
14 |
15 |
16 | 1.0.4, February 27, 2023
17 | ------------------------
18 |
19 | - Enh: Added support for "illuminate/routing" 10.0 (klimov-paul)
20 |
21 |
22 | 1.0.3, February 9, 2022
23 | -----------------------
24 |
25 | - Enh: Added support for "illuminate/routing" 9.0 (klimov-paul)
26 |
27 |
28 | 1.0.2, September 9, 2020
29 | ------------------------
30 |
31 | - Enh: Added support for "illuminate/routing" 8.0 (klimov-paul)
32 |
33 |
34 | 1.0.1, March 4, 2020
35 | --------------------
36 |
37 | - Enh: Added support for "illuminate/routing" 7.0 (klimov-paul)
38 |
39 |
40 | 1.0.0, October 22, 2019
41 | -----------------------
42 |
43 | - Initial release.
44 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | This is free software. It is released under the terms of the
2 | following BSD License.
3 |
4 | Copyright © 2019 by Illuminatech (https://github.com/illuminatech)
5 | All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without
8 | modification, are permitted provided that the following conditions
9 | are met:
10 |
11 | * Redistributions of source code must retain the above copyright
12 | notice, this list of conditions and the following disclaimer.
13 | * Redistributions in binary form must reproduce the above copyright
14 | notice, this list of conditions and the following disclaimer in
15 | the documentation and/or other materials provided with the
16 | distribution.
17 | * Neither the name of Illuminatech nor the names of its
18 | contributors may be used to endorse or promote products derived
19 | from this software without specific prior written permission.
20 |
21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
24 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
25 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
26 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32 | POSSIBILITY OF SUCH DAMAGE.
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Real Laravel Model Route Matching
6 |
7 |
8 |
9 | This extension allows continuing route matching in case bound model does not exist.
10 |
11 | For license information check the [LICENSE](LICENSE.md)-file.
12 |
13 | [](https://packagist.org/packages/illuminatech/model-route)
14 | [](https://packagist.org/packages/illuminatech/model-route)
15 | [](https://github.com/illuminatech/model-route/actions)
16 |
17 |
18 | Installation
19 | ------------
20 |
21 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/).
22 |
23 | Either run
24 |
25 | ```
26 | php composer.phar require --prefer-dist illuminatech/model-route
27 | ```
28 |
29 | or add
30 |
31 | ```json
32 | "illuminatech/model-route": "*"
33 | ```
34 |
35 | to the require section of your composer.json.
36 |
37 |
38 | Usage
39 | -----
40 |
41 | This extension allows continuing route matching in case bound model does not exist.
42 |
43 | Imagine we need to create URL structure like the one GitHub has. There are individual users and organizations, each of which
44 | has its own page responding the URL starting from their name:
45 |
46 | - [https://github.com/klimov-paul](https://github.com/klimov-paul) - user's page, where "klimov-paul" - name of the user.
47 | - [https://github.com/illuminatech](https://github.com/illuminatech) - organization's page, where "illuminatech" - name of the organization.
48 |
49 | Most likely, in your project users and organizations will be stored in different database tables, so the Laravel routes
50 | configuration for this case will look like following:
51 |
52 | ```php
53 | name('users.show');
60 | Route::get('{organization}', OrganizationController::class.'@show')->name('organizations.show');
61 | ```
62 |
63 | And the controllers code will look like following:
64 |
65 | ```php
66 | setBinders([
114 | 'user' => \App\Models\User::class.'@username',
115 | 'organization' => \App\Models\Organization::class.'@name',
116 | ])
117 | ->register();
118 |
119 | parent::boot();
120 | }
121 |
122 | // ...
123 | }
124 | ```
125 |
126 | Once it is set, the routes specified above will be parsed correctly. In case there is no User record matching the requested URL
127 | route 'users.show' will be considered as 'not matched' and routing will continue to 'organizations.show'.
128 |
129 | `\Illuminatech\ModelRoute\ModelRouteValidator` allows setup of the route parameter binding in the similar way to the [standard explicit binding](https://laravel.com/docs/6.x/routing#explicit-binding).
130 | Binders are set via `\Illuminatech\ModelRoute\ModelRouteValidator::setBinders()` as an array, which key is the route parameter name
131 | and value is a binder specification. Each binder can be specified as:
132 |
133 | - string, Eloquent model class name, for example: 'App\Models\User'; in this case parameter binding will be searched in this
134 | class using a its route key field.
135 |
136 | - string, pair of Eloquent model class name and search field separated by `@` symbol, for example: 'App\Models\Item@slug';
137 | in this case parameter binding will be searched in the specified model using specified field.
138 |
139 | - callable, a PHP callback, which should accept parameter raw value and return binding for it; in case no binding is found -
140 | `null` should be returned.
141 |
142 | For example:
143 |
144 | ```php
145 | setBinders([
151 | 'blog' => \App\Models\BlogPost::class, // search using `\App\Models\BlogPost::getRouteKeyName()`
152 | 'item' => \App\Models\Item::class.'@slug', // search using `\App\Models\Item::$slug`
153 | 'project' => function ($value) {
154 | return \App\Models\Project::query()->where('name', $value)->first(); // if not found - `null` will be returned
155 | },
156 | ])
157 | ->register();
158 | ```
159 |
160 | > Note: do not specify standard explicit route parameter binding for the parameter covered by `\Illuminatech\ModelRoute\ModelRouteValidator::setBinders()`,
161 | as it will cause extra redundant database query. Parameter binding will be setup by `\Illuminatech\ModelRoute\ModelRouteValidator` automatically.
162 |
163 |
164 | ### Performance Tuning
165 |
166 | Remember that you should specify routes for any static pages **before** you write the route with model binding. While this
167 | extension allows routes matching to continue, if binding does not exist, matching check comes with the cost of a database query.
168 | Thus in our 'GitHub' example routes to any predefined site sections like static pages, contact page or blog, should be
169 | described beforehand:
170 |
171 | ```php
172 | name('about');
180 | Route::view('privacy-policy', 'pages/privacy-policy')->name('privacy-policy');
181 |
182 | Route::get('blog', BlogController::class.'@index')->name('blog.index');
183 | Route::get('blog/{blogArticle}', BlogController::class.'@show')->name('blog.show');
184 |
185 | // only once all other routes are defined, we can use dynamic binding:
186 | Route::get('{user}', UserController::class.'@show')->name('users.show'); // matching check will cause a DB query against model `App\Models\User`
187 | Route::get('{organization}', OrganizationController::class.'@show')->name('organizations.show'); // matching check will cause a DB query against model `App\Models\Organization`
188 | ```
189 |
190 | Unfortunally, you can not always control the order of all your routes definition. Some packages like [Telescope](https://laravel.com/docs/6.x/telescope),
191 | [Horizon](https://laravel.com/docs/6.x/horizon) and [Nova](https://nova.laravel.com) register their own routes via separated service provider.
192 | Those routes may appear to be registered after our "users.show" and "organizations.show" ones.
193 | You may manually exclude particular URL paths from the matching using `\Illuminatech\ModelRoute\ModelRouteValidator::setIgnoredUrlPaths()`.
194 | For example:
195 |
196 | ```php
197 | setBinders([
203 | 'user' => \App\Models\User::class.'@username',
204 | 'organization' => \App\Models\Organization::class.'@name',
205 | ])
206 | ->setIgnoredUrlPaths([
207 | config('telescope.path'), // exclude Telescope URLs
208 | config('horizon.path'), // exclude Horizon URLs
209 | config('nova.path'), // exclude Nova URLs
210 | 'nova-api', // exclude Nova API URLs
211 | ])
212 | ->register();
213 | ```
214 |
215 | With such configuration parsing of the URLs starting from '/telescope', '/horizon' or '/nova' will never trigger a database
216 | query around "users.show" and "organizations.show" routes.
217 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "illuminatech/model-route",
3 | "description": "Allows continuing route matching in case bound model does not exist",
4 | "keywords": ["laravel", "routing", "route", "model", "slug", "match", "matching"],
5 | "license": "BSD-3-Clause",
6 | "support": {
7 | "issues": "https://github.com/illuminatech/model-route/issues",
8 | "wiki": "https://github.com/illuminatech/model-route/wiki",
9 | "source": "https://github.com/illuminatech/model-route"
10 | },
11 | "authors": [
12 | {
13 | "name": "Paul Klimov",
14 | "email": "klimov.paul@gmail.com"
15 | }
16 | ],
17 | "require": {
18 | "illuminate/routing": "^5.8 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0"
19 | },
20 | "require-dev": {
21 | "illuminate/database": "*",
22 | "illuminate/events": "*",
23 | "phpunit/phpunit": "^7.5 || ^8.0 || ^9.3 || ^10.5"
24 | },
25 | "autoload": {
26 | "psr-4": {
27 | "Illuminatech\\ModelRoute\\": "src"
28 | }
29 | },
30 | "autoload-dev": {
31 | "psr-4": {
32 | "Illuminatech\\ModelRoute\\Test\\": "tests"
33 | }
34 | },
35 | "extra": {
36 | "branch-alias": {
37 | "dev-master": "1.0.x-dev"
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/src/ModelRouteValidator.php:
--------------------------------------------------------------------------------
1 | setBinders([
32 | * 'blog' => \App\Models\BlogPost::class,
33 | * 'item' => \App\Models\Item::class.'@slug',
34 | * 'project' => function ($value) {
35 | * return \App\Models\Project::query()->where('name', $value)->first();
36 | * },
37 | * ])
38 | * ->register();
39 | *
40 | * parent::boot();
41 | * }
42 | *
43 | * // ...
44 | * }
45 | * ```
46 | *
47 | * @see \Illuminate\Routing\Route::$validators
48 | * @see \Illuminate\Routing\Middleware\SubstituteBindings
49 | *
50 | * @author Paul Klimov
51 | * @since 1.0
52 | */
53 | class ModelRouteValidator implements ValidatorInterface
54 | {
55 | /**
56 | * @var array route parameter binders in format: `[parameterName => binder]`.
57 | * @see setBinders()
58 | */
59 | private $binders = [];
60 |
61 | /**
62 | * @var array list of URL paths, which should be skipped from matching.
63 | */
64 | private $ignoredUrlPaths = [];
65 |
66 | /**
67 | * @return array route parameter binders in format: `[parameterName => binder]`.
68 | */
69 | public function getBinders(): array
70 | {
71 | return $this->binders;
72 | }
73 |
74 | /**
75 | * Sets up route parameter binders to be used while route matching.
76 | * Each binder can be specified as:
77 | *
78 | * - string, Eloquent model class name, for example: 'App\Models\User'; in this case parameter binding will be searched in this
79 | * class using a its route key field.
80 | *
81 | * - string, pair of Eloquent model class name and search field separated by `@` symbol, for example: 'App\Models\Item@slug';
82 | * in this case parameter binding will be searched in the specified model using specified field.
83 | *
84 | * - callable, a PHP callback, which should accept parameter raw value and return binding for it; in case no binding is found -
85 | * `null` should be returned.
86 | *
87 | * For example:
88 | *
89 | * ```php
90 | * [
91 | * 'blog' => \App\Models\BlogPost::class,
92 | * 'item' => \App\Models\Item::class.'@slug',
93 | * 'project' => function ($value) {
94 | * return \App\Models\Project::query()->where('name', $value)->first();
95 | * },
96 | * ]
97 | * ```
98 | *
99 | * @param array $binders route parameter binders in format: `[parameterName => binder]`.
100 | * @return $this self reference.
101 | */
102 | public function setBinders(array $binders): self
103 | {
104 | $this->binders = $binders;
105 |
106 | return $this;
107 | }
108 |
109 | /**
110 | * @return array list of URL paths, which should be skipped from matching.
111 | */
112 | public function getIgnoredUrlPaths(): array
113 | {
114 | return $this->ignoredUrlPaths;
115 | }
116 |
117 | /**
118 | * Sets up URL path, for which parameter binding should not be performed.
119 | * For example:
120 | * ```php
121 | * [
122 | * config('telescope.path'),
123 | * config('horizon.path'),
124 | * config('nova.path'),
125 | * 'nova-api',
126 | * ]
127 | * ```
128 | *
129 | * @param array $ignoredUrlPaths list of URL paths, which should be skipped from matching.
130 | * @return $this self reference.
131 | */
132 | public function setIgnoredUrlPaths(array $ignoredUrlPaths): self
133 | {
134 | $this->ignoredUrlPaths = $ignoredUrlPaths;
135 |
136 | return $this;
137 | }
138 |
139 | /**
140 | * Appends this instance to the route validators.
141 | *
142 | * @return $this self reference.
143 | */
144 | public function register(): self
145 | {
146 | $validators = Route::getValidators();
147 | $validators[] = $this;
148 |
149 | Route::$validators = $validators;
150 |
151 | return $this;
152 | }
153 |
154 | /**
155 | * {@inheritdoc}
156 | */
157 | public function matches(Route $route, Request $request): bool
158 | {
159 | $routeVariables = $route->getCompiled()->getVariables();
160 |
161 | foreach ($this->getBinders() as $parameterName => $binder) {
162 | if (in_array($parameterName, $routeVariables)) {
163 | foreach ($this->getIgnoredUrlPaths() as $ignoredUrlPath) {
164 | if (Str::startsWith(trim($request->path(), '/').'/', trim($ignoredUrlPath, '/').'/')) {
165 | return false;
166 | }
167 | }
168 |
169 | $route = clone $route;
170 | $route->bind($request);
171 |
172 | $model = $this->findParameterBinding($route->parameter($parameterName), $binder);
173 |
174 | if ($model === null) {
175 | return false;
176 | }
177 |
178 | $router = $this->getRouter($route);
179 |
180 | $router->bind($parameterName, function() use ($model) {
181 | return $model;
182 | });
183 |
184 | return true;
185 | }
186 | }
187 |
188 | return true;
189 | }
190 |
191 | /**
192 | * Finds the actual value for route parameter binding.
193 | *
194 | * @param mixed $value route parameter value.
195 | * @param callable|string $binder parameter binding resolver.
196 | * @return mixed|null bound parameter value, `null` - if no binding found.
197 | */
198 | protected function findParameterBinding($value, $binder)
199 | {
200 | if (is_string($binder)) {
201 | /** @var $model \Illuminate\Database\Eloquent\Model */
202 | if (strpos($binder, '@') === false) {
203 |
204 | $model = $binder;
205 |
206 | return $model::query()->newModelInstance()->resolveRouteBinding($value);
207 | }
208 |
209 | [$model, $attribute] = explode('@', $binder);
210 |
211 | return $model::query()->where($attribute, '=', $value)->first();
212 | }
213 |
214 | return call_user_func($binder, $value);
215 | }
216 |
217 | /**
218 | * Extracts router instance for the specified route.
219 | *
220 | * @param Route $route route instance.
221 | * @return \Illuminate\Routing\Router router related to the given route.
222 | */
223 | protected function getRouter(Route $route)
224 | {
225 | try {
226 | $reflection = new \ReflectionObject($route);
227 | $property = $reflection->getProperty('router');
228 | $property->setAccessible(true);
229 |
230 | return $property->getValue($route);
231 | } catch (\Throwable $e) {
232 | return \Illuminate\Support\Facades\Route::getFacadeRoot();
233 | }
234 | }
235 | }
236 |
--------------------------------------------------------------------------------