├── 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 | [![Latest Stable Version](https://img.shields.io/packagist/v/illuminatech/model-route.svg)](https://packagist.org/packages/illuminatech/model-route) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/illuminatech/model-route.svg)](https://packagist.org/packages/illuminatech/model-route) 15 | [![Build Status](https://github.com/illuminatech/model-route/workflows/build/badge.svg)](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 | --------------------------------------------------------------------------------