├── .gitignore ├── tests ├── .htaccess └── index.php ├── .editorconfig ├── composer.json ├── Migration.md ├── composer.lock ├── src ├── Uri.php ├── Path.php └── Router.php ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | .idea/ 3 | 4 | # Composer 5 | vendor/ 6 | composer.phar 7 | -------------------------------------------------------------------------------- /tests/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | RewriteCond %{REQUEST_FILENAME} !-f 3 | RewriteRule . index.php [L] 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = tab 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | indent_style = space 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delight-im/router", 3 | "description": "Router for PHP. Simple, lightweight and convenient.", 4 | "require": { 5 | "php": ">=5.6.0" 6 | }, 7 | "type": "library", 8 | "keywords": [ "router", "routing", "route", "http" ], 9 | "homepage": "https://github.com/delight-im/PHP-Router", 10 | "license": "MIT", 11 | "autoload": { 12 | "psr-4": { 13 | "Delight\\Router\\": "src/" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Migration.md: -------------------------------------------------------------------------------- 1 | # Migration 2 | 3 | ## General 4 | 5 | Update your version of this library using Composer and its `composer update` or `composer require` commands [[?]](https://github.com/delight-im/Knowledge/blob/master/Composer%20(PHP).md#how-do-i-update-libraries-or-modules-within-my-application). 6 | 7 | ## From `v2.x.x` to `v3.x.x` 8 | 9 | * PHP 5.6.0 or higher is now required. 10 | 11 | ## From `v1.x.x` to `v2.x.x` 12 | 13 | * The license has been changed from the [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0) to the [MIT License](https://opensource.org/licenses/MIT). 14 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", 5 | "This file is @generated automatically" 6 | ], 7 | "hash": "6352eec3eb4ee4e8780486f77319c425", 8 | "content-hash": "69c138af0b08e34b7e16942c66e085ef", 9 | "packages": [], 10 | "packages-dev": [], 11 | "aliases": [], 12 | "minimum-stability": "stable", 13 | "stability-flags": [], 14 | "prefer-stable": false, 15 | "prefer-lowest": false, 16 | "platform": { 17 | "php": ">=5.6.0" 18 | }, 19 | "platform-dev": [] 20 | } 21 | -------------------------------------------------------------------------------- /src/Uri.php: -------------------------------------------------------------------------------- 1 | str = $str; 23 | } 24 | 25 | /** 26 | * Removes the query component from this string 27 | * 28 | * @return static this instance for chaining 29 | */ 30 | public function removeQuery() { 31 | $this->str = strtok($this->str, '?'); 32 | 33 | return $this; 34 | } 35 | 36 | public function __toString() { 37 | return $this->str; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /tests/index.php: -------------------------------------------------------------------------------- 1 | get('/', function () { 19 | echo '

Welcome

'; 20 | echo '

Hello world

'; 21 | 22 | // use some values from `$_GET` here 23 | }); 24 | $router->get('/user/:id/:name', function ($id, $name) { 25 | echo '

Profile: '.htmlspecialchars($name).'

'; 26 | echo '

My user ID is '.intval($id).'

'; 27 | 28 | // you may use `$_GET` in addition to the callback arguments 29 | }); 30 | $router->post('/sign_up', function () { 31 | // create a new account with values from `$_POST` 32 | }); 33 | $router->put('/articles/5', function () { 34 | // do something 35 | }); 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) delight.im (https://www.delight.im/) 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 | -------------------------------------------------------------------------------- /src/Path.php: -------------------------------------------------------------------------------- 1 | str = $str; 23 | } 24 | 25 | /** 26 | * Normalizes the path 27 | * 28 | * @return static this instance for chaining 29 | */ 30 | public function normalize() { 31 | // remove whitespace from the beginning 32 | $this->str = ltrim($this->str); 33 | 34 | // ensure that there is exactly one forward slash at the beginning 35 | $this->str = '/'.ltrim($this->str, '/'); 36 | 37 | // remove whitespace from the end 38 | $this->str = rtrim($this->str); 39 | 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * Removes any trailing slashes 46 | * 47 | * @return static this instance for chaining 48 | */ 49 | public function removeTrailingSlashes() { 50 | // ensure that there is no forward slash at the end 51 | $this->str = rtrim($this->str, '/'); 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * Whether this path is absolute 58 | * 59 | * @return bool whether the path is absolute 60 | */ 61 | public function isAbsolute() { 62 | return isset($this->str[0]) && $this->str[0] === '/'; 63 | } 64 | 65 | /** 66 | * Whether this path is relative 67 | * 68 | * @return bool whether the path is relative 69 | */ 70 | public function isRelative() { 71 | return !$this->isAbsolute(); 72 | } 73 | 74 | public function __toString() { 75 | return $this->str; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP-Router 2 | 3 | Router for PHP. Simple, lightweight and convenient. 4 | 5 | ## Requirements 6 | 7 | * PHP 5.6.0+ 8 | 9 | ## Installation 10 | 11 | 1. Include the library via Composer [[?]](https://github.com/delight-im/Knowledge/blob/master/Composer%20(PHP).md): 12 | 13 | ``` 14 | $ composer require delight-im/router 15 | ``` 16 | 17 | 1. Include the Composer autoloader: 18 | 19 | ```php 20 | require __DIR__ . '/vendor/autoload.php'; 21 | ``` 22 | 23 | ## Usage 24 | 25 | 1. Enable URL rewriting on your web server 26 | 27 | * Apache (in `.htaccess` or `httpd.conf`) 28 | 29 | ``` 30 | RewriteEngine On 31 | RewriteCond %{REQUEST_FILENAME} !-f 32 | RewriteRule . index.php [L] 33 | ``` 34 | 35 | * Nginx (in `nginx.conf`) 36 | 37 | ``` 38 | try_files $uri /index.php; 39 | ``` 40 | 41 | 1. Create a new `Router` instance 42 | 43 | * for the web root 44 | 45 | ```php 46 | $router = new \Delight\Router\Router(); 47 | ``` 48 | 49 | * for any subdirectory 50 | 51 | ```php 52 | $router = new \Delight\Router\Router('/my/base/path'); 53 | ``` 54 | 55 | 1. Add some routes and map them to anonymous functions or closures 56 | 57 | * Static route: 58 | 59 | ```php 60 | $router->get('/', function () { 61 | // do something 62 | }); 63 | ``` 64 | 65 | * Dynamic route (with parameters): 66 | 67 | ```php 68 | $router->get('/users/:id/photo', function ($id) { 69 | // get the photo for user `$id` 70 | }); 71 | ``` 72 | 73 | The values of parameters matched in the URL can be captured as arguments in the callback. 74 | 75 | * Route with multiple supported request methods: 76 | 77 | ```php 78 | $router->any([ 'POST', 'PUT' ], '/users/:id/address', function ($id) { 79 | // update the address for user `$id` 80 | }); 81 | ``` 82 | 83 | 1. Map routes to controller methods instead for more complex callbacks 84 | 85 | ```php 86 | // use static methods 87 | $router->get('/photos/:id/convert/:mode', [ 'PhotoController', 'myStaticMethod' ]); 88 | 89 | // or 90 | 91 | // instance methods 92 | $router->get('/photos/:id/convert/:mode', [ $myPhotoController, 'myInstanceMethod' ]); 93 | ``` 94 | 95 | 1. Inject arguments for access to further values and objects (prepended to those matched in the route) 96 | 97 | ```php 98 | class MyController { 99 | 100 | public static function someStaticMethod($database, $uuid) { 101 | // do something 102 | } 103 | 104 | } 105 | ``` 106 | 107 | and 108 | 109 | ```php 110 | $database = new MyDatabase(); 111 | 112 | // ... 113 | 114 | $router->delete('/messages/:uuid', [ 'MyController', 'someStaticMethod' ], [ $database ]); 115 | ``` 116 | 117 | ## Contributing 118 | 119 | All contributions are welcome! If you wish to contribute, please create an issue first so that your feature, problem or question can be discussed. 120 | 121 | ## License 122 | 123 | This project is licensed under the terms of the [MIT License](https://opensource.org/licenses/MIT). 124 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | rootPath = (string) (new Path($rootPath))->normalize()->removeTrailingSlashes(); 38 | 39 | if (isset($_SERVER['REQUEST_URI'])) { 40 | $this->route = urldecode((string) (new Uri($_SERVER['REQUEST_URI']))->removeQuery()); 41 | } 42 | else { 43 | $this->route = $this->rootPath . '/'; 44 | } 45 | 46 | if (isset($_SERVER['REQUEST_METHOD'])) { 47 | $this->requestMethod = strtolower($_SERVER['REQUEST_METHOD']); 48 | } 49 | else { 50 | $this->requestMethod = 'get'; 51 | } 52 | } 53 | 54 | /** 55 | * Adds a new route for the HTTP request method `GET` and executes the specified callback if the route matches 56 | * 57 | * @param string $route the route to match, e.g. `/users/jane` 58 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 59 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 60 | * @return bool whether the route matched the current request 61 | */ 62 | public function get($route, $callback = null, $injectArgs = null) { 63 | return $this->addRoute([ 'get' ], $route, $callback, $injectArgs); 64 | } 65 | 66 | /** 67 | * Adds a new route for the HTTP request method `POST` and executes the specified callback if the route matches 68 | * 69 | * @param string $route the route to match, e.g. `/users/jane` 70 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 71 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 72 | * @return bool whether the route matched the current request 73 | */ 74 | public function post($route, $callback = null, $injectArgs = null) { 75 | return $this->addRoute([ 'post' ], $route, $callback, $injectArgs); 76 | } 77 | 78 | /** 79 | * Adds a new route for the HTTP request method `PUT` and executes the specified callback if the route matches 80 | * 81 | * @param string $route the route to match, e.g. `/users/jane` 82 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 83 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 84 | * @return bool whether the route matched the current request 85 | */ 86 | public function put($route, $callback = null, $injectArgs = null) { 87 | return $this->addRoute([ 'put' ], $route, $callback, $injectArgs); 88 | } 89 | 90 | /** 91 | * Adds a new route for the HTTP request method `PATCH` and executes the specified callback if the route matches 92 | * 93 | * @param string $route the route to match, e.g. `/users/jane` 94 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 95 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 96 | * @return bool whether the route matched the current request 97 | */ 98 | public function patch($route, $callback = null, $injectArgs = null) { 99 | return $this->addRoute([ 'patch' ], $route, $callback, $injectArgs); 100 | } 101 | 102 | /** 103 | * Adds a new route for the HTTP request method `DELETE` and executes the specified callback if the route matches 104 | * 105 | * @param string $route the route to match, e.g. `/users/jane` 106 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 107 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 108 | * @return bool whether the route matched the current request 109 | */ 110 | public function delete($route, $callback = null, $injectArgs = null) { 111 | return $this->addRoute([ 'delete' ], $route, $callback, $injectArgs); 112 | } 113 | 114 | /** 115 | * Adds a new route for the HTTP request method `HEAD` and executes the specified callback if the route matches 116 | * 117 | * @param string $route the route to match, e.g. `/users/jane` 118 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 119 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 120 | * @return bool whether the route matched the current request 121 | */ 122 | public function head($route, $callback = null, $injectArgs = null) { 123 | return $this->addRoute([ 'head' ], $route, $callback, $injectArgs); 124 | } 125 | 126 | /** 127 | * Adds a new route for the HTTP request method `TRACE` and executes the specified callback if the route matches 128 | * 129 | * @param string $route the route to match, e.g. `/users/jane` 130 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 131 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 132 | * @return bool whether the route matched the current request 133 | */ 134 | public function trace($route, $callback = null, $injectArgs = null) { 135 | return $this->addRoute([ 'trace' ], $route, $callback, $injectArgs); 136 | } 137 | 138 | /** 139 | * Adds a new route for the HTTP request method `OPTIONS` and executes the specified callback if the route matches 140 | * 141 | * @param string $route the route to match, e.g. `/users/jane` 142 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 143 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 144 | * @return bool whether the route matched the current request 145 | */ 146 | public function options($route, $callback = null, $injectArgs = null) { 147 | return $this->addRoute([ 'options' ], $route, $callback, $injectArgs); 148 | } 149 | 150 | /** 151 | * Adds a new route for the HTTP request method `CONNECT` and executes the specified callback if the route matches 152 | * 153 | * @param string $route the route to match, e.g. `/users/jane` 154 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 155 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 156 | * @return bool whether the route matched the current request 157 | */ 158 | public function connect($route, $callback = null, $injectArgs = null) { 159 | return $this->addRoute([ 'connect' ], $route, $callback, $injectArgs); 160 | } 161 | 162 | /** 163 | * Adds a new route for all of the specified HTTP request methods and executes the specified callback if the route matches 164 | * 165 | * @param string[] $requestMethods the request methods, one of which to match 166 | * @param string $route the route to match, e.g. `/users/jane` 167 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 168 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 169 | * @return bool whether the route matched the current request 170 | */ 171 | public function any(array $requestMethods, $route, $callback = null, $injectArgs = null) { 172 | return $this->addRoute($requestMethods, $route, $callback, $injectArgs); 173 | } 174 | 175 | /** 176 | * Returns the root path that this instance is working under 177 | * 178 | * @return string the path 179 | */ 180 | public function getRootPath() { 181 | return $this->rootPath; 182 | } 183 | 184 | /** 185 | * Returns the route of the current request 186 | * 187 | * @return string the route 188 | */ 189 | public function getRoute() { 190 | return $this->route; 191 | } 192 | 193 | /** 194 | * Returns the request method of the current request 195 | * 196 | * @return string the method name 197 | */ 198 | public function getRequestMethod() { 199 | return $this->requestMethod; 200 | } 201 | 202 | /** 203 | * Attempts to match the route of the current request against the specified route 204 | * 205 | * @param string $expectedRoute the route to match against 206 | * @return array|null the list of matched parameters or `null` if the route didn't match 207 | */ 208 | private function matchRoute($expectedRoute) { 209 | $params = []; 210 | 211 | // create the regex that matches paths against the route 212 | $expectedRouteRegex = $this->createRouteRegex($expectedRoute, $params); 213 | 214 | // if the route regex matches the current request path 215 | if (preg_match($expectedRouteRegex, $this->route, $matches)) { 216 | if (count($matches) > 1) { 217 | // remove the first match (which is the full route match) 218 | array_shift($matches); 219 | 220 | // use the extracted parameters as the arguments' keys and the matches as the arguments' values 221 | return array_combine($params, $matches); 222 | } 223 | else { 224 | return []; 225 | } 226 | } 227 | // if the route regex does not match the current request path 228 | else { 229 | return null; 230 | } 231 | } 232 | 233 | /** 234 | * Checks the specified request method and route against the current request to see whether it matches 235 | * 236 | * @param string[] $expectedRequestMethods the request methods, one of which must be detected in order to have a match 237 | * @param string $expectedRoute the route that must be found in order to have a match 238 | * @param callable|null $callback (optional) the callback to execute, e.g. an anonymous function 239 | * @param array|null $injectArgs (optional) any arguments that should be prepended to those matched in the route 240 | * @return bool whether the route matched the current request 241 | */ 242 | private function addRoute(array $expectedRequestMethods, $expectedRoute, $callback = null, $injectArgs = null) { 243 | $expectedRequestMethods = array_map('strtolower', $expectedRequestMethods); 244 | 245 | if (in_array($this->requestMethod, $expectedRequestMethods, true)) { 246 | $matchedArgs = $this->matchRoute($expectedRoute); 247 | 248 | // if the route matches the current request 249 | if ($matchedArgs !== null) { 250 | // if a callback has been set 251 | if (isset($callback)) { 252 | // if the callback can be executed 253 | if (is_callable($callback)) { 254 | // use an empty array as the default value for the arguments to be injected 255 | if ($injectArgs === null) { 256 | $injectArgs = []; 257 | } 258 | 259 | // execute the callback 260 | $callback(...$injectArgs, ...array_values($matchedArgs)); 261 | } 262 | // if the callback is invalid 263 | else { 264 | throw new \InvalidArgumentException('Invalid callback for methods `'.implode('|', $expectedRequestMethods).'` at route `'.$expectedRoute.'`'); 265 | } 266 | } 267 | 268 | // the route matches the current request 269 | return true; 270 | } 271 | } 272 | 273 | // the route does not match the current request 274 | return false; 275 | } 276 | 277 | /** 278 | * Creates a regular expression that can be used to match the specified route 279 | * 280 | * @param string $expectedRoute the route to create a regular expression for 281 | * @param array $params the array that should receive the matched parameters 282 | * @return string the composed regular expression 283 | */ 284 | private function createRouteRegex($expectedRoute, &$params) { 285 | // extract the parameters from the route (if any) and make the route a regex 286 | self::processUriParams($expectedRoute, $params); 287 | 288 | // escape the base path for regex and prepend it to the route 289 | return static::REGEX_DELIMITER . '^' . static::regexEscape($this->rootPath) . $expectedRoute . '$' . static::REGEX_DELIMITER; 290 | } 291 | 292 | /** 293 | * Extracts parameters from a path 294 | * 295 | * @param string $path the path to extract the parameters from 296 | * @param array $params the array that should receive the matched parameters 297 | */ 298 | private static function processUriParams(&$path, &$params) { 299 | // if the route path contains parameters like `:key` 300 | if (preg_match_all(static::REGEX_PATH_PARAMS, $path, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { 301 | $previousMatchEnd = 0; 302 | $regexParts = []; 303 | 304 | // extract all parameter names and create a regex that matches URIs and captures the parameters' values 305 | foreach ($matches as $match) { 306 | // remember the boundaries of the full match (e.g. `:key`) in the subject 307 | $matchStart = $match[0][1]; 308 | $matchEnd = $matchStart + strlen($match[0][0]); 309 | 310 | // keep the part between this one and the previous match and escape it for regex 311 | $regexParts[] = static::regexEscape(substr($path, $previousMatchEnd, $matchStart - $previousMatchEnd)); 312 | 313 | // save the current parameter's name 314 | $params[] = $match[1][0]; 315 | 316 | // insert an expression that will match the parameter's value 317 | $regexParts[] = static::REGEX_PATH_SEGMENT; 318 | 319 | // remember the end index of the current match 320 | $previousMatchEnd = $matchEnd; 321 | } 322 | 323 | // keep the part after the last match and escape it for regex 324 | $regexParts[] = static::regexEscape(substr($path, $previousMatchEnd)); 325 | 326 | // replace the parameterized URI with a regex that matches the parameters' values 327 | $path = implode('', $regexParts); 328 | } 329 | // if the route path is not parameterized 330 | else { 331 | // just escape the path for literal usage in regex 332 | $path = static::regexEscape($path); 333 | } 334 | } 335 | 336 | /** 337 | * Escapes the supplied string for use in a regular expression 338 | * 339 | * @param string $str the string to escape 340 | * @return string the escaped string 341 | */ 342 | private static function regexEscape($str) { 343 | return preg_quote($str, static::REGEX_DELIMITER); 344 | } 345 | 346 | } 347 | --------------------------------------------------------------------------------