├── bmc_qr.png ├── .phplint.yml ├── .gitignore ├── tests ├── Unit │ ├── Enums.php │ ├── RoutingRedirectorTest.php │ └── RoutingUrlGeneratorTest.php └── bootstrap.php ├── phpunit.xml ├── public └── .htaccess ├── LICENSE.md ├── .github └── workflows │ └── php.yml ├── composer.json ├── CHANGELOG.md ├── src ├── RoutingServiceProvider.php └── UrlGenerator.php └── README.md /bmc_qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsasvari/laravel-trailing-slash/HEAD/bmc_qr.png -------------------------------------------------------------------------------- /.phplint.yml: -------------------------------------------------------------------------------- 1 | path: ./ 2 | jobs: 10 3 | cache: phplint.cache 4 | extensions: 5 | - php 6 | exclude: 7 | - vendor 8 | warning: true 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.project 3 | /nbproject 4 | /.idea 5 | /.phpunit.cache 6 | .phpunit.result.cache 7 | .editorconfig 8 | composer.phar 9 | composer.lock 10 | phpstan.neon 11 | phplint.cache 12 | Thumbs.db 13 | -------------------------------------------------------------------------------- /tests/Unit/Enums.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | 10 | ./tests 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Redirect To Trailing Slashes If Not A Folder Or A File... 9 | RewriteCond %{REQUEST_FILENAME} !-d 10 | RewriteCond %{REQUEST_FILENAME} !-f 11 | RewriteCond %{REQUEST_URI} !(/$|\.) 12 | RewriteRule (.*) %{REQUEST_URI}/ [R=301,L] 13 | 14 | # Handle Front Controller... 15 | RewriteCond %{REQUEST_FILENAME} !-d 16 | RewriteCond %{REQUEST_FILENAME} !-f 17 | RewriteRule ^ index.php [L] 18 | 19 | # Handle Authorization Header 20 | RewriteCond %{HTTP:Authorization} . 21 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Frano Sasvari 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 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | registerUrlGenerator(); 14 | } 15 | 16 | protected function registerUrlGenerator(): void 17 | { 18 | $this->app->singleton('url', function ($app) { 19 | $routes = $app['router']->getRoutes(); 20 | 21 | // The URL generator needs the route collection that exists on the router. 22 | // Keep in mind this is an object, so we're passing by references here 23 | // and all the registered routes will be available to the generator. 24 | $app->instance('routes', $routes); 25 | 26 | $url = new UrlGenerator( 27 | $routes, 28 | $app->rebinding( 29 | 'request', 30 | $this->requestRebinder() 31 | ), 32 | $app['config']['app.asset_url'] 33 | ); 34 | 35 | // Next we will set a few service resolvers on the URL generator, so it can 36 | // get the information it needs to function. This just provides some of 37 | // the convenience features to this URL generator like "signed" URLs. 38 | $url->setSessionResolver(function () use ($app) { 39 | return $app['session'] ?? null; 40 | }); 41 | 42 | $url->setKeyResolver(function () use ($app) { 43 | return $app->make('config')->get('app.key'); 44 | }); 45 | 46 | // If the route collection is "rebound", for example, when the routes stay 47 | // cached for the application, we will need to rebind the routes on the 48 | // URL generator instance so it has the latest version of the routes. 49 | $app->rebinding('routes', function ($app, $routes) { 50 | $app['url']->setRoutes($routes); 51 | }); 52 | 53 | return $url; 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/UrlGenerator.php: -------------------------------------------------------------------------------- 1 | getTrailingSlash($path); 25 | } 26 | 27 | /** 28 | * Determine if the signature from the given request matches the URL. 29 | * 30 | * @param \Illuminate\Http\Request $request 31 | * @param bool $absolute 32 | * @param array $ignoreQuery 33 | * 34 | * @return bool 35 | */ 36 | public function hasCorrectSignature(Request $request, $absolute = true, Closure|array $ignoreQuery = []) 37 | { 38 | $ignoreQuery[] = 'signature'; 39 | 40 | $url = ($absolute ? $request->url() : '/'.$request->path()); 41 | $url = $url.$this->getTrailingSlash($url); 42 | 43 | $queryString = (new Collection(explode('&', (string) $request->server->get('QUERY_STRING')))) 44 | ->reject(fn ($parameter) => in_array(Str::before($parameter, '='), $ignoreQuery)) 45 | ->join('&'); 46 | 47 | $original = rtrim($url.'?'.$queryString, '?'); 48 | 49 | $keys = call_user_func($this->keyResolver); 50 | 51 | $keys = is_array($keys) ? $keys : [$keys]; 52 | 53 | foreach ($keys as $key) { 54 | if (hash_equals( 55 | hash_hmac('sha256', $original, $key), 56 | (string) $request->query('signature', '') 57 | )) { 58 | return true; 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | 65 | /** 66 | * Get the previous path info for the request. 67 | * 68 | * @param mixed $fallback 69 | * 70 | * @return string 71 | */ 72 | public function previousPath($fallback = false) 73 | { 74 | $rootToPath = rtrim($this->to('/'), '/'); 75 | $previousPath = rtrim(preg_replace('/\?.*/', '', $this->previous($fallback)), '/'); 76 | 77 | $previousPath = str_replace($rootToPath, '', $previousPath.$this->getTrailingSlash($previousPath)); 78 | 79 | return $previousPath === '' ? '/' : $previousPath; 80 | } 81 | 82 | /** 83 | * Get trailing slash suffix for path or url, if no dash (#) is present. 84 | * 85 | * @param string|null $url 86 | * 87 | * @return string 88 | */ 89 | private function getTrailingSlash(?string $url = null): string 90 | { 91 | if ($url === null) { 92 | return '/'; 93 | } 94 | 95 | return Str::contains($url, '#') ? '' : '/'; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Trailing Slash 2 | 3 | Adds url formatting and redirection with trailing slash to Laravel framework versions 12.x, 11.x, 10.x, 9.x, 8.x, 7.x, 6.x and 5.x. 4 | 5 | [![Build For Laravel](https://img.shields.io/badge/Built_for-Laravel-orange.svg)](https://styleci.io/repos/79834672) 6 | [![Latest Stable Version](https://poser.pugx.org/fsasvari/laravel-trailing-slash/v/stable)](https://packagist.org/packages/fsasvari/laravel-trailing-slash) 7 | [![Latest Unstable Version](https://poser.pugx.org/fsasvari/laravel-trailing-slash/v/unstable)](https://packagist.org/packages/fsasvari/laravel-trailing-slash) 8 | [![Total Downloads](https://poser.pugx.org/fsasvari/laravel-trailing-slash/downloads)](https://packagist.org/packages/fsasvari/laravel-trailing-slash) 9 | [![License](https://poser.pugx.org/fsasvari/laravel-trailing-slash/license)](https://packagist.org/packages/fsasvari/laravel-trailing-slash) 10 | 11 | ## Compatibility Chart 12 | 13 | | Laravel Trailing Slash | Laravel | PHP | 14 | |-----------------------------------------------------------------------|---------|-----------| 15 | | [7.x](https://github.com/fsasvari/laravel-trailing-slash/tree/v7.0.0) | 12.x | 8.2+ | 16 | | [6.x](https://github.com/fsasvari/laravel-trailing-slash/tree/v6.1.1) | 11.x | 8.2+ | 17 | | [5.x](https://github.com/fsasvari/laravel-trailing-slash/tree/v5.0.0) | 10.x | 8.1+ | 18 | | [4.x](https://github.com/fsasvari/laravel-trailing-slash/tree/v4.0.0) | 9.x | 8.0.2+ | 19 | | [3.x](https://github.com/fsasvari/laravel-trailing-slash/tree/v3.0.0) | 8.x | 7.3+/8.0+ | 20 | | [2.x](https://github.com/fsasvari/laravel-trailing-slash/tree/v2.0.2) | 7.x | 7.3+ | 21 | | [1.x](https://github.com/fsasvari/laravel-trailing-slash/tree/v1.1.0) | 6.x | 7.2+ | 22 | | [0.3.x](https://github.com/fsasvari/laravel-trailing-slash/tree/0.3) | 5.7-5.8 | 7.1.3+ | 23 | | [0.2.x](https://github.com/fsasvari/laravel-trailing-slash/tree/0.2) | 5.6 | 7.1.3+ | 24 | | [0.1.x](https://github.com/fsasvari/laravel-trailing-slash/tree/0.1) | 5.5 | 7.0.0+ | 25 | 26 | ## Installation 27 | 28 | ### Step 1: Install package 29 | 30 | To get started with Laravel Trailing Slash, use Composer command to add the package to your composer.json project's dependencies: 31 | 32 | ``` 33 | composer require fsasvari/laravel-trailing-slash 34 | ``` 35 | 36 | Or add it directly by copying next line into composer.json: 37 | 38 | ``` 39 | "fsasvari/laravel-trailing-slash": "7.*" 40 | ``` 41 | 42 | ### Step 2: Service Provider 43 | 44 | If you are using `Laravel 11.x` and above, register the `LaravelTrailingSlash\RoutingServiceProvider` in your `bootstrap/providers.php` configuration file: 45 | 46 | ```php 47 | return [ 48 | // Package Service Providers... 49 | // ... 50 | LaravelTrailingSlash\RoutingServiceProvider::class, 51 | // ... 52 | ], 53 | ``` 54 | 55 | If you are using `Laravel 10.x` and below, register the `LaravelTrailingSlash\RoutingServiceProvider` in your `config/app.php` configuration file: 56 | 57 | ```php 58 | 'providers' => [ 59 | // Application Service Providers... 60 | // ... 61 | 62 | // Package Service Providers... 63 | // ... 64 | LaravelTrailingSlash\RoutingServiceProvider::class, 65 | // ... 66 | ], 67 | ``` 68 | 69 | ### Step 3: .htaccess 70 | 71 | If you are using apache, copy following redirection code from `public/.htaccess` to your own project: 72 | 73 | ``` 74 | 75 | # Redirect To Trailing Slashes If Not A Folder Or A File... 76 | RewriteCond %{REQUEST_FILENAME} !-d 77 | RewriteCond %{REQUEST_FILENAME} !-f 78 | RewriteCond %{REQUEST_URI} !(/$|\.) 79 | RewriteRule (.*) %{REQUEST_URI}/ [R=301,L] 80 | 81 | ``` 82 | 83 | ### Step 4: Routes 84 | 85 | In routes/web.php, you must use routes with trailing slashes now: 86 | 87 | ```php 88 | Route::get('/', function () { 89 | return view('welcome'); 90 | }); 91 | 92 | Route::get('about/', function () { 93 | return view('about'); 94 | }); 95 | 96 | Route::get('contact/', function () { 97 | return view('contact'); 98 | }); 99 | ``` 100 | 101 | ## Usage 102 | 103 | Every time you use some Laravel redirect function, trailing slash ("/") will be applied at the end of url. 104 | 105 | ```php 106 | return redirect('about/'); 107 | 108 | return back()->withInput(); 109 | 110 | return redirect()->route('text', ['id' => 1]); 111 | 112 | return redirect()->action('IndexController@about'); 113 | ``` 114 | 115 | ## Change log 116 | 117 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 118 | 119 | ## Notice 120 | 121 | There is a problem with overriding Laravel `Paginator` and `LengthAwarePaginator` classes. So, every time you use `paginate()` method on your models, query builders etc., you must set current path for pagination links. Example: 122 | 123 | ```php 124 | $texts = Text::where('is_active', 1)->paginate(); 125 | $texts->setPath(URL::current()); 126 | 127 | $texts->links(); 128 | ``` 129 | 130 | ## Licence 131 | 132 | MIT Licence. Refer to the [LICENSE](https://github.com/fsasvari/laravel-trailing-slash/blob/main/LICENSE.md) file to get more info. 133 | 134 | ## Author 135 | 136 | Frano Šašvari 137 | 138 | Email: sasvari.frano@gmail.com 139 | 140 | ## Buy me a Beer 141 | 142 | [![Buy me a Beer](https://github.com/fsasvari/laravel-trailing-slash/blob/main/bmc_qr.png)](https://buymeacoffee.com/sasvarifras) 143 | 144 | -------------------------------------------------------------------------------- /tests/Unit/RoutingRedirectorTest.php: -------------------------------------------------------------------------------- 1 | headers = m::mock(HeaderBag::class); 27 | 28 | $this->request = m::mock(Request::class); 29 | $this->request->shouldReceive('isMethod')->andReturn(true)->byDefault(); 30 | $this->request->shouldReceive('method')->andReturn('GET')->byDefault(); 31 | $this->request->shouldReceive('route')->andReturn(true)->byDefault(); 32 | $this->request->shouldReceive('ajax')->andReturn(false)->byDefault(); 33 | $this->request->shouldReceive('expectsJson')->andReturn(false)->byDefault(); 34 | $this->request->headers = $this->headers; 35 | 36 | $this->url = m::mock(UrlGenerator::class); 37 | $this->url->shouldReceive('getRequest')->andReturn($this->request); 38 | $this->url->shouldReceive('to')->with('bar', [], null)->andReturn('http://foo.com/bar/'); 39 | $this->url->shouldReceive('to')->with('bar', [], true)->andReturn('https://foo.com/bar/'); 40 | $this->url->shouldReceive('to')->with('login', [], null)->andReturn('http://foo.com/login/'); 41 | $this->url->shouldReceive('to')->with('http://foo.com/bar/', [], null)->andReturn('http://foo.com/bar/'); 42 | $this->url->shouldReceive('to')->with('/', [], null)->andReturn('http://foo.com/'); 43 | $this->url->shouldReceive('to')->with('http://foo.com/bar/#foo', [], null)->andReturn('http://foo.com/bar/#foo'); 44 | $this->url->shouldReceive('to')->with('http://foo.com/bar/?signature=secret', [], null)->andReturn('http://foo.com/bar/?signature=secret'); 45 | 46 | $this->session = m::mock(Store::class); 47 | 48 | $this->redirect = new Redirector($this->url); 49 | $this->redirect->setSession($this->session); 50 | } 51 | 52 | protected function tearDown(): void 53 | { 54 | m::close(); 55 | } 56 | 57 | public function testBasicRedirectTo() 58 | { 59 | $response = $this->redirect->to('bar'); 60 | 61 | $this->assertInstanceOf(RedirectResponse::class, $response); 62 | $this->assertSame('http://foo.com/bar/', $response->getTargetUrl()); 63 | $this->assertEquals(302, $response->getStatusCode()); 64 | $this->assertEquals($this->session, $response->getSession()); 65 | } 66 | 67 | public function testComplexRedirectTo() 68 | { 69 | $response = $this->redirect->to('bar', 303, ['X-RateLimit-Limit' => 60, 'X-RateLimit-Remaining' => 59], true); 70 | 71 | $this->assertSame('https://foo.com/bar/', $response->getTargetUrl()); 72 | $this->assertEquals(303, $response->getStatusCode()); 73 | $this->assertEquals(60, $response->headers->get('X-RateLimit-Limit')); 74 | $this->assertEquals(59, $response->headers->get('X-RateLimit-Remaining')); 75 | } 76 | 77 | public function testGuestPutCurrentUrlInSession() 78 | { 79 | $this->url->shouldReceive('full')->andReturn('http://foo.com/bar/'); 80 | $this->session->shouldReceive('put')->once()->with('url.intended', 'http://foo.com/bar/'); 81 | 82 | $response = $this->redirect->guest('login'); 83 | 84 | $this->assertSame('http://foo.com/login/', $response->getTargetUrl()); 85 | } 86 | 87 | public function testGuestPutPreviousUrlInSession() 88 | { 89 | $this->request->shouldReceive('isMethod')->once()->with('GET')->andReturn(false); 90 | $this->session->shouldReceive('put')->once()->with('url.intended', 'http://foo.com/bar/'); 91 | $this->url->shouldReceive('previous')->once()->andReturn('http://foo.com/bar/'); 92 | 93 | $response = $this->redirect->guest('login'); 94 | 95 | $this->assertSame('http://foo.com/login/', $response->getTargetUrl()); 96 | } 97 | 98 | public function testIntendedRedirectToIntendedUrlInSession() 99 | { 100 | $this->session->shouldReceive('pull')->with('url.intended', '/')->andReturn('http://foo.com/bar/'); 101 | 102 | $response = $this->redirect->intended(); 103 | 104 | $this->assertSame('http://foo.com/bar/', $response->getTargetUrl()); 105 | } 106 | 107 | public function testIntendedWithoutIntendedUrlInSession() 108 | { 109 | $this->session->shouldReceive('forget')->with('url.intended'); 110 | 111 | // without fallback url 112 | $this->session->shouldReceive('pull')->with('url.intended', '/')->andReturn('/'); 113 | $response = $this->redirect->intended(); 114 | $this->assertSame('http://foo.com/', $response->getTargetUrl()); 115 | 116 | // with a fallback url 117 | $this->session->shouldReceive('pull')->with('url.intended', 'bar')->andReturn('bar'); 118 | $response = $this->redirect->intended('bar'); 119 | $this->assertSame('http://foo.com/bar/', $response->getTargetUrl()); 120 | } 121 | 122 | public function testRefreshRedirectToCurrentUrl() 123 | { 124 | $this->request->shouldReceive('path')->andReturn('http://foo.com/bar/'); 125 | $response = $this->redirect->refresh(); 126 | $this->assertSame('http://foo.com/bar/', $response->getTargetUrl()); 127 | } 128 | 129 | public function testBackRedirectToHttpReferer() 130 | { 131 | $this->headers->shouldReceive('has')->with('referer')->andReturn(true); 132 | $this->url->shouldReceive('previous')->andReturn('http://foo.com/bar/'); 133 | $response = $this->redirect->back(); 134 | $this->assertSame('http://foo.com/bar/', $response->getTargetUrl()); 135 | } 136 | 137 | public function testAwayDoesntValidateTheUrl() 138 | { 139 | $response = $this->redirect->away('bar'); 140 | $this->assertSame('bar', $response->getTargetUrl()); 141 | } 142 | 143 | public function testSecureRedirectToHttpsUrl() 144 | { 145 | $response = $this->redirect->secure('bar'); 146 | $this->assertSame('https://foo.com/bar/', $response->getTargetUrl()); 147 | } 148 | 149 | public function testAction() 150 | { 151 | $this->url->shouldReceive('action')->with('bar@index', [])->andReturn('http://foo.com/bar/'); 152 | $response = $this->redirect->action('bar@index'); 153 | $this->assertSame('http://foo.com/bar/', $response->getTargetUrl()); 154 | } 155 | 156 | public function testRoute() 157 | { 158 | $this->url->shouldReceive('route')->with('home')->andReturn('http://foo.com/bar/'); 159 | $this->url->shouldReceive('route')->with('home', [])->andReturn('http://foo.com/bar/'); 160 | 161 | $response = $this->redirect->route('home'); 162 | $this->assertSame('http://foo.com/bar/', $response->getTargetUrl()); 163 | } 164 | 165 | public function testSignedRoute() 166 | { 167 | $this->url->shouldReceive('signedRoute')->with('home', [], null)->andReturn('http://foo.com/bar/?signature=secret'); 168 | 169 | $response = $this->redirect->signedRoute('home'); 170 | $this->assertSame('http://foo.com/bar/?signature=secret', $response->getTargetUrl()); 171 | } 172 | 173 | public function testTemporarySignedRoute() 174 | { 175 | $this->url->shouldReceive('temporarySignedRoute')->with('home', 10, [])->andReturn('http://foo.com/bar/?signature=secret'); 176 | 177 | $response = $this->redirect->temporarySignedRoute('home', 10); 178 | $this->assertSame('http://foo.com/bar/?signature=secret', $response->getTargetUrl()); 179 | } 180 | 181 | public function testItSetsAndGetsValidIntendedUrl() 182 | { 183 | $this->session->shouldReceive('put')->once()->with('url.intended', 'http://foo.com/bar/'); 184 | $this->session->shouldReceive('get')->andReturn('http://foo.com/bar/'); 185 | 186 | $result = $this->redirect->setIntendedUrl('http://foo.com/bar/'); 187 | $this->assertInstanceOf(Redirector::class, $result); 188 | 189 | $this->assertSame('http://foo.com/bar/', $this->redirect->getIntendedUrl()); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /tests/Unit/RoutingUrlGeneratorTest.php: -------------------------------------------------------------------------------- 1 | assertSame('http://www.foo.com/foo/bar/', $url->to('foo/bar')); 32 | $this->assertSame('https://www.foo.com/foo/bar/', $url->to('foo/bar', [], true)); 33 | $this->assertSame('https://www.foo.com/foo/bar/baz/boom/', $url->to('foo/bar', ['baz', 'boom'], true)); 34 | $this->assertSame('https://www.foo.com/foo/bar/baz/?foo=bar', $url->to('foo/bar?foo=bar', ['baz'], true)); 35 | 36 | /* 37 | * Test HTTPS request URL generation... 38 | */ 39 | $url = new UrlGenerator( 40 | new RouteCollection(), 41 | Request::create('https://www.foo.com/') 42 | ); 43 | 44 | $this->assertSame('https://www.foo.com/foo/bar/', $url->to('foo/bar')); 45 | } 46 | 47 | public function testQueryGeneration() 48 | { 49 | $url = new UrlGenerator( 50 | new RouteCollection(), 51 | Request::create('http://www.foo.com/') 52 | ); 53 | 54 | $this->assertSame('http://www.foo.com/foo/bar/', $url->query('foo/bar')); 55 | $this->assertSame('http://www.foo.com/foo/bar/?0=foo', $url->query('foo/bar', ['foo'])); 56 | $this->assertSame('http://www.foo.com/foo/bar/?baz=boom', $url->query('foo/bar', ['baz' => 'boom'])); 57 | $this->assertSame('http://www.foo.com/foo/bar/?baz=zee&zal=bee', $url->query('foo/bar?baz=boom&zal=bee', ['baz' => 'zee'])); 58 | $this->assertSame('http://www.foo.com/foo/bar/?zal=bee', $url->query('foo/bar?baz=boom&zal=bee', ['baz' => null])); 59 | $this->assertSame('http://www.foo.com/foo/bar/?baz=boom', $url->query('foo/bar?baz=boom', ['nonexist' => null])); 60 | $this->assertSame('http://www.foo.com/foo/bar/', $url->query('foo/bar?baz=boom', ['baz' => null])); 61 | $this->assertSame('https://www.foo.com/foo/bar/baz/?foo=bar&zal=bee', $url->query('foo/bar?foo=bar', ['zal' => 'bee'], ['baz'], true)); 62 | $this->assertSame('http://www.foo.com/foo/bar/?baz[0]=boom&baz[1]=bam&baz[2]=bim', urldecode($url->query('foo/bar', ['baz' => ['boom', 'bam', 'bim']]))); 63 | } 64 | 65 | public function testAssetGeneration() 66 | { 67 | $url = new UrlGenerator( 68 | new RouteCollection(), 69 | Request::create('http://www.foo.com/index.php/') 70 | ); 71 | 72 | $this->assertSame('http://www.foo.com/foo/bar', $url->asset('foo/bar')); 73 | $this->assertSame('https://www.foo.com/foo/bar', $url->asset('foo/bar', true)); 74 | 75 | $url = new UrlGenerator( 76 | new RouteCollection(), 77 | Request::create('http://www.foo.com/index.php/'), 78 | '/' 79 | ); 80 | 81 | $this->assertSame('/foo/bar', $url->asset('foo/bar')); 82 | $this->assertSame('/foo/bar', $url->asset('foo/bar', true)); 83 | } 84 | 85 | public function testBasicGenerationWithHostFormatting() 86 | { 87 | $url = new UrlGenerator( 88 | $routes = new RouteCollection(), 89 | Request::create('http://www.foo.com/') 90 | ); 91 | 92 | $route = new Route(['GET'], '/named-route/', ['as' => 'plain']); 93 | $routes->add($route); 94 | 95 | $url->formatHostUsing(function ($host) { 96 | return str_replace('foo.com', 'foo.org', $host); 97 | }); 98 | 99 | $this->assertSame('http://www.foo.org/foo/bar/', $url->to('foo/bar')); 100 | $this->assertSame('/named-route/', $url->route('plain', [], false)); 101 | } 102 | 103 | public function testBasicGenerationWithRequestBaseUrlWithSubfolder() 104 | { 105 | $request = Request::create('http://www.foo.com/subfolder/foo/bar/subfolder/'); 106 | 107 | $request->server->set('SCRIPT_FILENAME', '/var/www/laravel-project/public/subfolder/index.php'); 108 | $request->server->set('PHP_SELF', '/subfolder/index.php'); 109 | 110 | $url = new UrlGenerator( 111 | $routes = new RouteCollection(), 112 | $request 113 | ); 114 | 115 | $route = new Route(['GET'], 'foo/bar/subfolder/', ['as' => 'foobar']); 116 | $routes->add($route); 117 | 118 | $this->assertSame('/subfolder', $request->getBaseUrl()); 119 | $this->assertSame('/foo/bar/subfolder/', $url->route('foobar', [], false)); 120 | } 121 | 122 | public function testBasicGenerationWithRequestBaseUrlWithSubfolderAndFileSuffix() 123 | { 124 | $request = Request::create('http://www.foo.com/subfolder/index.php'); 125 | 126 | $request->server->set('SCRIPT_FILENAME', '/var/www/laravel-project/public/subfolder/index.php'); 127 | $request->server->set('PHP_SELF', '/subfolder/index.php'); 128 | 129 | $url = new UrlGenerator( 130 | $routes = new RouteCollection(), 131 | $request 132 | ); 133 | 134 | $route = new Route(['GET'], 'foo/bar/subfolder/', ['as' => 'foobar']); 135 | $routes->add($route); 136 | 137 | $this->assertSame('/subfolder', $request->getBasePath()); 138 | $this->assertSame('/subfolder/index.php', $request->getBaseUrl()); 139 | $this->assertSame('/foo/bar/subfolder/', $url->route('foobar', [], false)); 140 | } 141 | 142 | public function testBasicGenerationWithRequestBaseUrlWithFileSuffix() 143 | { 144 | $request = Request::create('http://www.foo.com/other.php'); 145 | 146 | $request->server->set('SCRIPT_FILENAME', '/var/www/laravel-project/public/other.php'); 147 | $request->server->set('PHP_SELF', '/other.php'); 148 | 149 | $url = new UrlGenerator( 150 | $routes = new RouteCollection(), 151 | $request 152 | ); 153 | 154 | $route = new Route(['GET'], 'foo/bar/subfolder/', ['as' => 'foobar']); 155 | $routes->add($route); 156 | 157 | $this->assertSame('', $request->getBasePath()); 158 | $this->assertSame('/other.php', $request->getBaseUrl()); 159 | $this->assertSame('/foo/bar/subfolder/', $url->route('foobar', [], false)); 160 | } 161 | 162 | public function testBasicGenerationWithPathFormatting() 163 | { 164 | $url = new UrlGenerator( 165 | $routes = new RouteCollection(), 166 | Request::create('http://www.foo.com/') 167 | ); 168 | 169 | $route = new Route(['GET'], '/named-route/', ['as' => 'plain']); 170 | $routes->add($route); 171 | 172 | $url->formatPathUsing(function ($path) { 173 | return '/something'.$path; 174 | }); 175 | 176 | $this->assertSame('http://www.foo.com/something/foo/bar/', $url->to('foo/bar')); 177 | $this->assertSame('/something/named-route/', $url->route('plain', [], false)); 178 | } 179 | 180 | public function testUrlFormattersShouldReceiveTargetRoute() 181 | { 182 | $url = new UrlGenerator( 183 | $routes = new RouteCollection(), 184 | Request::create('http://abc.com/') 185 | ); 186 | 187 | $namedRoute = new Route(['GET'], '/bar/', ['as' => 'plain', 'root' => 'bar.com', 'path' => 'foo']); 188 | $routes->add($namedRoute); 189 | 190 | $url->formatHostUsing(function ($root, $route) { 191 | return $route ? 'http://'.$route->getAction('root') : $root; 192 | }); 193 | 194 | $url->formatPathUsing(function ($path, $route) { 195 | return $route ? '/'.$route->getAction('path') : $path; 196 | }); 197 | 198 | $this->assertSame('http://abc.com/foo/bar/', $url->to('foo/bar')); 199 | $this->assertSame('http://bar.com/foo/', $url->route('plain')); 200 | } 201 | 202 | public function testBasicRouteGeneration() 203 | { 204 | $url = new UrlGenerator( 205 | $routes = new RouteCollection(), 206 | Request::create('http://www.foo.com/') 207 | ); 208 | 209 | /* 210 | * Empty Named Route 211 | */ 212 | $route = new Route(['GET'], '/', ['as' => 'plain']); 213 | $routes->add($route); 214 | 215 | /* 216 | * Named Routes 217 | */ 218 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo']); 219 | $routes->add($route); 220 | 221 | /* 222 | * Parameters... 223 | */ 224 | $route = new Route(['GET'], 'foo/bar/{baz}/breeze/{boom}/', ['as' => 'bar']); 225 | $routes->add($route); 226 | 227 | /* 228 | * Single Parameter... 229 | */ 230 | $route = new Route(['GET'], 'foo/bar/{baz}/', ['as' => 'foobar']); 231 | $routes->add($route); 232 | 233 | /* 234 | * Optional parameter 235 | */ 236 | $route = new Route(['GET'], 'foo/bar/{baz?}', ['as' => 'optional']); 237 | $routes->add($route); 238 | 239 | /* 240 | * HTTPS... 241 | */ 242 | $route = new Route(['GET'], 'foo/baz/', ['as' => 'baz', 'https']); 243 | $routes->add($route); 244 | 245 | /* 246 | * Controller Route Route 247 | */ 248 | $route = new Route(['GET'], 'foo/bam/', ['controller' => 'foo@bar']); 249 | $routes->add($route); 250 | 251 | /* 252 | * Non ASCII routes 253 | */ 254 | $route = new Route(['GET'], 'foo/bar/åαф/{baz}/', ['as' => 'foobarbaz']); 255 | $routes->add($route); 256 | 257 | /* 258 | * Fragments 259 | */ 260 | $route = new Route(['GET'], 'foo/bar/#derp', ['as' => 'fragment']); 261 | $routes->add($route); 262 | 263 | /* 264 | * Invoke action 265 | */ 266 | $route = new Route(['GET'], 'foo/invoke/', ['controller' => 'InvokableActionStub']); 267 | $routes->add($route); 268 | 269 | /* 270 | * With Default Parameter 271 | */ 272 | $url->defaults(['locale' => 'en']); 273 | $route = new Route(['GET'], 'foo/', ['as' => 'defaults', 'domain' => '{locale}.example.com', function () { 274 | // 275 | }]); 276 | $routes->add($route); 277 | 278 | /* 279 | * With backed enum name and domain 280 | */ 281 | $route = (new Route(['GET'], 'backed-enum', ['as' => 'prefixed.']))->name(RouteNameEnum::UserIndex)->domain(RouteDomainEnum::DashboardDomain); 282 | $routes->add($route); 283 | 284 | $this->assertSame('/', $url->route('plain', [], false)); 285 | $this->assertSame('/?foo=bar', $url->route('plain', ['foo' => 'bar'], false)); 286 | $this->assertSame('http://www.foo.com/foo/bar/', $url->route('foo')); 287 | $this->assertSame('/foo/bar/', $url->route('foo', [], false)); 288 | $this->assertSame('/foo/bar/?foo=bar', $url->route('foo', ['foo' => 'bar'], false)); 289 | $this->assertSame('http://www.foo.com/foo/bar/taylor/breeze/otwell/?fly=wall', $url->route('bar', ['taylor', 'otwell', 'fly' => 'wall'])); 290 | $this->assertSame('http://www.foo.com/foo/bar/otwell/breeze/taylor/?fly=wall', $url->route('bar', ['boom' => 'taylor', 'baz' => 'otwell', 'fly' => 'wall'])); 291 | $this->assertSame('http://www.foo.com/foo/bar/0/', $url->route('foobar', 0)); 292 | $this->assertSame('http://www.foo.com/foo/bar/2/', $url->route('foobar', 2)); 293 | $this->assertSame('http://www.foo.com/foo/bar/taylor/', $url->route('foobar', 'taylor')); 294 | $this->assertSame('/foo/bar/taylor/breeze/otwell/?fly=wall', $url->route('bar', ['taylor', 'otwell', 'fly' => 'wall'], false)); 295 | $this->assertSame('https://www.foo.com/foo/baz/', $url->route('baz')); 296 | $this->assertSame('http://www.foo.com/foo/bam/', $url->action('foo@bar')); 297 | $this->assertSame('http://www.foo.com/foo/bam/', $url->action(['foo', 'bar'])); 298 | $this->assertSame('http://www.foo.com/foo/invoke/', $url->action('InvokableActionStub')); 299 | $this->assertSame('http://www.foo.com/foo/bar/taylor/breeze/otwell/?wall&woz', $url->route('bar', ['wall', 'woz', 'boom' => 'otwell', 'baz' => 'taylor'])); 300 | $this->assertSame('http://www.foo.com/foo/bar/taylor/breeze/otwell/?wall&woz', $url->route('bar', ['taylor', 'otwell', 'wall', 'woz'])); 301 | $this->assertSame('http://www.foo.com/foo/bar/', $url->route('optional')); 302 | $this->assertSame('http://www.foo.com/foo/bar/', $url->route('optional', ['baz' => null])); 303 | $this->assertSame('http://www.foo.com/foo/bar/', $url->route('optional', ['baz' => ''])); 304 | $this->assertSame('http://www.foo.com/foo/bar/0/', $url->route('optional', ['baz' => 0])); 305 | $this->assertSame('http://www.foo.com/foo/bar/taylor/', $url->route('optional', 'taylor')); 306 | $this->assertSame('http://www.foo.com/foo/bar/taylor/', $url->route('optional', ['taylor'])); 307 | $this->assertSame('http://www.foo.com/foo/bar/taylor/?breeze', $url->route('optional', ['taylor', 'breeze'])); 308 | $this->assertSame('http://www.foo.com/foo/bar/taylor/?wall=woz', $url->route('optional', ['wall' => 'woz', 'taylor'])); 309 | $this->assertSame('http://www.foo.com/foo/bar/taylor/?wall=woz&breeze', $url->route('optional', ['wall' => 'woz', 'breeze', 'baz' => 'taylor'])); 310 | $this->assertSame('http://www.foo.com/foo/bar/?wall=woz', $url->route('optional', ['wall' => 'woz'])); 311 | $this->assertSame('http://www.foo.com/foo/bar/%C3%A5%CE%B1%D1%84/%C3%A5%CE%B1%D1%84/', $url->route('foobarbaz', ['baz' => 'åαф'])); 312 | $this->assertSame('/foo/bar/#derp', $url->route('fragment', [], false)); 313 | $this->assertSame('/foo/bar/?foo=bar#derp', $url->route('fragment', ['foo' => 'bar'], false)); 314 | $this->assertSame('/foo/bar/?baz=%C3%A5%CE%B1%D1%84#derp', $url->route('fragment', ['baz' => 'åαф'], false)); 315 | $this->assertSame('http://en.example.com/foo/', $url->route('defaults')); 316 | $this->assertSame('http://dashboard.myapp.com/backed-enum/', $url->route('prefixed.users.index')); 317 | } 318 | 319 | public function testFluentRouteNameDefinitions() 320 | { 321 | $url = new UrlGenerator( 322 | $routes = new RouteCollection(), 323 | Request::create('http://www.foo.com/') 324 | ); 325 | 326 | /* 327 | * Named Routes 328 | */ 329 | $route = new Route(['GET'], 'foo/bar/', []); 330 | $route->name('foo'); 331 | $routes->add($route); 332 | $routes->refreshNameLookups(); 333 | 334 | $this->assertSame('http://www.foo.com/foo/bar/', $url->route('foo')); 335 | } 336 | 337 | public function testControllerRoutesWithADefaultNamespace() 338 | { 339 | $url = new UrlGenerator( 340 | $routes = new RouteCollection(), 341 | Request::create('http://www.foo.com/') 342 | ); 343 | 344 | $url->setRootControllerNamespace('namespace'); 345 | 346 | /* 347 | * Controller Route Route 348 | */ 349 | $route = new Route(['GET'], 'foo/bar/', ['controller' => 'namespace\foo@bar']); 350 | $routes->add($route); 351 | 352 | $route = new Route(['GET'], 'something/else/', ['controller' => 'something\foo@bar']); 353 | $routes->add($route); 354 | 355 | $route = new Route(['GET'], 'foo/invoke/', ['controller' => 'namespace\InvokableActionStub']); 356 | $routes->add($route); 357 | 358 | $this->assertSame('http://www.foo.com/foo/bar/', $url->action('foo@bar')); 359 | $this->assertSame('http://www.foo.com/something/else/', $url->action('\something\foo@bar')); 360 | $this->assertSame('http://www.foo.com/foo/invoke/', $url->action('InvokableActionStub')); 361 | } 362 | 363 | public function testControllerRoutesOutsideOfDefaultNamespace() 364 | { 365 | $url = new UrlGenerator( 366 | $routes = new RouteCollection(), 367 | Request::create('http://www.foo.com/') 368 | ); 369 | 370 | $url->setRootControllerNamespace('namespace'); 371 | 372 | $route = new Route(['GET'], 'root/namespace/', ['controller' => '\root\namespace@foo']); 373 | $routes->add($route); 374 | 375 | $route = new Route(['GET'], 'invokable/namespace/', ['controller' => '\root\namespace\InvokableActionStub']); 376 | $routes->add($route); 377 | 378 | $this->assertSame('http://www.foo.com/root/namespace/', $url->action('\root\namespace@foo')); 379 | $this->assertSame('http://www.foo.com/invokable/namespace/', $url->action('\root\namespace\InvokableActionStub')); 380 | } 381 | 382 | public function testRoutableInterfaceRouting() 383 | { 384 | $url = new UrlGenerator( 385 | $routes = new RouteCollection(), 386 | Request::create('http://www.foo.com/') 387 | ); 388 | 389 | $route = new Route(['GET'], 'foo/{bar}/', ['as' => 'routable']); 390 | $routes->add($route); 391 | 392 | $model = new RoutableInterfaceStub(); 393 | $model->key = 'routable'; 394 | 395 | $this->assertSame('/foo/routable/', $url->route('routable', [$model], false)); 396 | } 397 | 398 | public function testRoutableInterfaceRoutingWithCustomBindingField() 399 | { 400 | $url = new UrlGenerator( 401 | $routes = new RouteCollection(), 402 | Request::create('http://www.foo.com/') 403 | ); 404 | 405 | $route = new Route(['GET'], 'foo/{bar:slug}/', ['as' => 'routable']); 406 | $routes->add($route); 407 | 408 | $model = new RoutableInterfaceStub(); 409 | $model->key = 'routable'; 410 | 411 | $this->assertSame('/foo/test-slug/', $url->route('routable', ['bar' => $model], false)); 412 | $this->assertSame('/foo/test-slug/', $url->route('routable', [$model], false)); 413 | } 414 | 415 | public function testRoutableInterfaceRoutingAsQueryString() 416 | { 417 | $url = new UrlGenerator( 418 | $routes = new RouteCollection(), 419 | Request::create('http://www.foo.com/') 420 | ); 421 | 422 | $route = new Route(['GET'], 'foo/', ['as' => 'query-string']); 423 | $routes->add($route); 424 | 425 | $model = new RoutableInterfaceStub(); 426 | $model->key = 'routable'; 427 | 428 | $this->assertSame('/foo/?routable', $url->route('query-string', $model, false)); 429 | $this->assertSame('/foo/?routable', $url->route('query-string', [$model], false)); 430 | $this->assertSame('/foo/?foo=routable', $url->route('query-string', ['foo' => $model], false)); 431 | } 432 | 433 | /** 434 | * @todo Fix bug related to route keys 435 | * 436 | * @link https://github.com/laravel/framework/pull/42425 437 | */ 438 | public function testRoutableInterfaceRoutingWithSeparateBindingFieldOnlyForSecondParameter() 439 | { 440 | $this->markTestSkipped('See https://github.com/laravel/framework/pull/43255'); 441 | 442 | $url = new UrlGenerator( 443 | $routes = new RouteCollection(), 444 | Request::create('http://www.foo.com/') 445 | ); 446 | 447 | $route = new Route(['GET'], 'foo/{bar}/{baz:slug}/', ['as' => 'routable']); 448 | $routes->add($route); 449 | 450 | $model1 = new RoutableInterfaceStub(); 451 | $model1->key = 'routable-1'; 452 | 453 | $model2 = new RoutableInterfaceStub(); 454 | $model2->key = 'routable-2'; 455 | 456 | $this->assertSame('/foo/routable-1/test-slug/', $url->route('routable', ['bar' => $model1, 'baz' => $model2], false)); 457 | $this->assertSame('/foo/routable-1/test-slug/', $url->route('routable', [$model1, $model2], false)); 458 | } 459 | 460 | public function testRoutableInterfaceRoutingWithSingleParameter() 461 | { 462 | $url = new UrlGenerator( 463 | $routes = new RouteCollection(), 464 | Request::create('http://www.foo.com/') 465 | ); 466 | 467 | $route = new Route(['GET'], 'foo/{bar}/', ['as' => 'routable']); 468 | $routes->add($route); 469 | 470 | $model = new RoutableInterfaceStub(); 471 | $model->key = 'routable'; 472 | 473 | $this->assertSame('/foo/routable/', $url->route('routable', $model, false)); 474 | } 475 | 476 | public function testRoutesMaintainRequestScheme() 477 | { 478 | $url = new UrlGenerator( 479 | $routes = new RouteCollection(), 480 | Request::create('https://www.foo.com/') 481 | ); 482 | 483 | /* 484 | * Named Routes 485 | */ 486 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo']); 487 | $routes->add($route); 488 | 489 | $this->assertSame('https://www.foo.com/foo/bar/', $url->route('foo')); 490 | } 491 | 492 | public function testHttpOnlyRoutes() 493 | { 494 | $url = new UrlGenerator( 495 | $routes = new RouteCollection(), 496 | Request::create('https://www.foo.com/') 497 | ); 498 | 499 | /* 500 | * Named Routes 501 | */ 502 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo', 'http']); 503 | $routes->add($route); 504 | 505 | $this->assertSame('http://www.foo.com/foo/bar/', $url->route('foo')); 506 | } 507 | 508 | public function testRoutesWithDomains() 509 | { 510 | $url = new UrlGenerator( 511 | $routes = new RouteCollection(), 512 | Request::create('http://www.foo.com/') 513 | ); 514 | 515 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo', 'domain' => 'sub.foo.com']); 516 | $routes->add($route); 517 | 518 | /* 519 | * Wildcards & Domains... 520 | */ 521 | $route = new Route(['GET'], 'foo/bar/{baz}/', ['as' => 'bar', 'domain' => 'sub.{foo}.com']); 522 | $routes->add($route); 523 | 524 | $this->assertSame('http://sub.foo.com/foo/bar/', $url->route('foo')); 525 | $this->assertSame('http://sub.taylor.com/foo/bar/otwell/', $url->route('bar', ['taylor', 'otwell'])); 526 | $this->assertSame('/foo/bar/otwell/', $url->route('bar', ['taylor', 'otwell'], false)); 527 | } 528 | 529 | public function testRoutesWithDomainsAndPorts() 530 | { 531 | $url = new UrlGenerator( 532 | $routes = new RouteCollection(), 533 | Request::create('http://www.foo.com:8080/') 534 | ); 535 | 536 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo', 'domain' => 'sub.foo.com']); 537 | $routes->add($route); 538 | 539 | /* 540 | * Wildcards & Domains... 541 | */ 542 | $route = new Route(['GET'], 'foo/bar/{baz}/', ['as' => 'bar', 'domain' => 'sub.{foo}.com']); 543 | $routes->add($route); 544 | 545 | $this->assertSame('http://sub.foo.com:8080/foo/bar/', $url->route('foo')); 546 | $this->assertSame('http://sub.taylor.com:8080/foo/bar/otwell/', $url->route('bar', ['taylor', 'otwell'])); 547 | } 548 | 549 | public function testRoutesWithDomainsStripsProtocols() 550 | { 551 | /* 552 | * http:// Route 553 | */ 554 | $url = new UrlGenerator( 555 | $routes = new RouteCollection(), 556 | Request::create('http://www.foo.com/') 557 | ); 558 | 559 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo', 'domain' => 'http://sub.foo.com']); 560 | $routes->add($route); 561 | 562 | $this->assertSame('http://sub.foo.com/foo/bar/', $url->route('foo')); 563 | 564 | /* 565 | * https:// Route 566 | */ 567 | $url = new UrlGenerator( 568 | $routes = new RouteCollection(), 569 | Request::create('https://www.foo.com/') 570 | ); 571 | 572 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo', 'domain' => 'https://sub.foo.com']); 573 | $routes->add($route); 574 | 575 | $this->assertSame('https://sub.foo.com/foo/bar/', $url->route('foo')); 576 | } 577 | 578 | public function testHttpsRoutesWithDomains() 579 | { 580 | $url = new UrlGenerator( 581 | $routes = new RouteCollection(), 582 | Request::create('https://foo.com/') 583 | ); 584 | 585 | /* 586 | * When on HTTPS, no need to specify 443 587 | */ 588 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'baz', 'domain' => 'sub.foo.com']); 589 | $routes->add($route); 590 | 591 | $this->assertSame('https://sub.foo.com/foo/bar/', $url->route('baz')); 592 | } 593 | 594 | public function testRoutesWithDomainsThroughProxy() 595 | { 596 | Request::setTrustedProxies(['10.0.0.1'], SymfonyRequest::HEADER_X_FORWARDED_FOR | SymfonyRequest::HEADER_X_FORWARDED_HOST | SymfonyRequest::HEADER_X_FORWARDED_PORT | SymfonyRequest::HEADER_X_FORWARDED_PROTO); 597 | 598 | $url = new UrlGenerator( 599 | $routes = new RouteCollection(), 600 | Request::create('http://www.foo.com/', 'GET', [], [], [], ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PORT' => '80']) 601 | ); 602 | 603 | $route = new Route(['GET'], 'foo/bar/', ['as' => 'foo', 'domain' => 'sub.foo.com']); 604 | $routes->add($route); 605 | 606 | $this->assertSame('http://sub.foo.com/foo/bar/', $url->route('foo')); 607 | } 608 | 609 | public static function providerRouteParameters() 610 | { 611 | return [ 612 | [['test' => 123]], 613 | [['one' => null, 'test' => 123]], 614 | [['one' => '', 'test' => 123]], 615 | ]; 616 | } 617 | 618 | #[DataProvider('providerRouteParameters')] 619 | public function testUrlGenerationForControllersRequiresPassingOfRequiredParameters($parameters) 620 | { 621 | $this->expectException(UrlGenerationException::class); 622 | 623 | $url = new UrlGenerator( 624 | $routes = new RouteCollection(), 625 | Request::create('http://www.foo.com:8080/') 626 | ); 627 | 628 | $route = new Route(['GET'], 'foo/{one}/{two?}/{three?}', ['as' => 'foo', function () { 629 | // 630 | }]); 631 | $routes->add($route); 632 | 633 | $this->assertSame('http://www.foo.com:8080/foo/?test=123', $url->route('foo', $parameters)); 634 | } 635 | 636 | public static function provideParametersAndExpectedMeaningfulExceptionMessages() 637 | { 638 | return [ 639 | 'Missing parameters "one", "two" and "three"' => [ 640 | [], 641 | 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: one, two, three].', 642 | ], 643 | 'Missing parameters "two" and "three"' => [ 644 | ['one' => '123'], 645 | 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: two, three].', 646 | ], 647 | 'Missing parameters "one" and "three"' => [ 648 | ['two' => '123'], 649 | 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: one, three].', 650 | ], 651 | 'Missing parameters "one" and "two"' => [ 652 | ['three' => '123'], 653 | 'Missing required parameters for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameters: one, two].', 654 | ], 655 | 'Missing parameter "three"' => [ 656 | ['one' => '123', 'two' => '123'], 657 | 'Missing required parameter for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameter: three].', 658 | ], 659 | 'Missing parameter "two"' => [ 660 | ['one' => '123', 'three' => '123'], 661 | 'Missing required parameter for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameter: two].', 662 | ], 663 | 'Missing parameter "one"' => [ 664 | ['two' => '123', 'three' => '123'], 665 | 'Missing required parameter for [Route: foo] [URI: foo/{one}/{two}/{three}/{four?}] [Missing parameter: one].', 666 | ], 667 | ]; 668 | } 669 | 670 | #[DataProvider('provideParametersAndExpectedMeaningfulExceptionMessages')] 671 | public function testUrlGenerationThrowsExceptionForMissingParametersWithMeaningfulMessage($parameters, $expectedMeaningfulExceptionMessage) 672 | { 673 | $this->expectException(UrlGenerationException::class); 674 | $this->expectExceptionMessage($expectedMeaningfulExceptionMessage); 675 | 676 | $url = new UrlGenerator( 677 | $routes = new RouteCollection(), 678 | Request::create('http://www.foo.com:8080/') 679 | ); 680 | 681 | $route = new Route(['GET'], 'foo/{one}/{two}/{three}/{four?}', ['as' => 'foo', function () { 682 | // 683 | }]); 684 | $routes->add($route); 685 | 686 | $url->route('foo', $parameters); 687 | } 688 | 689 | public function testForceRootUrl() 690 | { 691 | $url = new UrlGenerator( 692 | $routes = new RouteCollection(), 693 | Request::create('http://www.foo.com/') 694 | ); 695 | 696 | $url->forceRootUrl('https://www.bar.com'); 697 | $this->assertSame('http://www.bar.com/foo/bar/', $url->to('foo/bar/')); 698 | 699 | // Ensure trailing slash is trimmed from root URL as UrlGenerator already handles this 700 | $url->forceRootUrl('http://www.foo.com/'); 701 | $this->assertSame('http://www.foo.com/bar/', $url->to('/bar/')); 702 | 703 | /* 704 | * Route Based... 705 | */ 706 | $url = new UrlGenerator( 707 | $routes = new RouteCollection(), 708 | Request::create('http://www.foo.com/') 709 | ); 710 | 711 | $url->forceScheme('https'); 712 | $route = new Route(['GET'], '/foo/', ['as' => 'plain']); 713 | $routes->add($route); 714 | 715 | $this->assertSame('https://www.foo.com/foo/', $url->route('plain')); 716 | 717 | $url->forceRootUrl('https://www.bar.com'); 718 | $this->assertSame('https://www.bar.com/foo/', $url->route('plain')); 719 | } 720 | 721 | public function testForceHttps() 722 | { 723 | $url = new UrlGenerator( 724 | $routes = new RouteCollection(), 725 | Request::create('http://www.foo.com/') 726 | ); 727 | 728 | $url->forceHttps(); 729 | $route = new Route(['GET'], '/foo/', ['as' => 'plain']); 730 | $routes->add($route); 731 | 732 | $this->assertSame('https://www.foo.com/foo/', $url->route('plain')); 733 | } 734 | 735 | public function testPrevious() 736 | { 737 | $url = new UrlGenerator( 738 | new RouteCollection(), 739 | Request::create('http://www.foo.com/') 740 | ); 741 | 742 | $url->getRequest()->headers->set('referer', 'http://www.bar.com/'); 743 | $this->assertSame('http://www.bar.com/', $url->previous()); 744 | 745 | $url->getRequest()->headers->remove('referer'); 746 | $this->assertEquals($url->to('/'), $url->previous()); 747 | 748 | $this->assertEquals($url->to('/foo/'), $url->previous('/foo/')); 749 | } 750 | 751 | public function testPreviousPath() 752 | { 753 | $url = new UrlGenerator( 754 | new RouteCollection(), 755 | Request::create('http://www.foo.com/') 756 | ); 757 | 758 | $url->getRequest()->headers->set('referer', 'http://www.foo.com?baz=bah'); 759 | $this->assertSame('/', $url->previousPath()); 760 | 761 | $url->getRequest()->headers->set('referer', 'http://www.foo.com/?baz=bah'); 762 | $this->assertSame('/', $url->previousPath()); 763 | 764 | $url->getRequest()->headers->set('referer', 'http://www.foo.com/bar/?baz=bah'); 765 | $this->assertSame('/bar/', $url->previousPath()); 766 | 767 | $url->getRequest()->headers->remove('referer'); 768 | $this->assertSame('/', $url->previousPath()); 769 | 770 | $this->assertSame('/bar/', $url->previousPath('/bar/')); 771 | } 772 | 773 | public function testRouteNotDefinedException() 774 | { 775 | $this->expectException(RouteNotFoundException::class); 776 | $this->expectExceptionMessage('Route [not_exists_route] not defined.'); 777 | 778 | $url = new UrlGenerator( 779 | new RouteCollection(), 780 | Request::create('http://www.foo.com/') 781 | ); 782 | 783 | $url->route('not_exists_route'); 784 | } 785 | 786 | public function testSignedUrl() 787 | { 788 | $url = new UrlGenerator( 789 | $routes = new RouteCollection(), 790 | $request = Request::create('http://www.foo.com/') 791 | ); 792 | $url->setKeyResolver(function () { 793 | return 'secret'; 794 | }); 795 | 796 | $route = new Route(['GET'], 'foo/', ['as' => 'foo', function () { 797 | // 798 | }]); 799 | $routes->add($route); 800 | 801 | $request = Request::create($url->signedRoute('foo')); 802 | 803 | $this->assertTrue($url->hasValidSignature($request)); 804 | 805 | $request = Request::create($url->signedRoute('foo').'?tampered=true'); 806 | 807 | $this->assertFalse($url->hasValidSignature($request)); 808 | } 809 | 810 | public function testSignedUrlImplicitModelBinding() 811 | { 812 | $url = new UrlGenerator( 813 | $routes = new RouteCollection(), 814 | $request = Request::create('http://www.foo.com/') 815 | ); 816 | $url->setKeyResolver(function () { 817 | return 'secret'; 818 | }); 819 | 820 | $route = new Route(['GET'], 'foo/{user:uuid}', ['as' => 'foo', function () { 821 | // 822 | }]); 823 | $routes->add($route); 824 | 825 | $user = new RoutingUrlGeneratorTestUser(['uuid' => '0231d4ac-e9e3-4452-a89a-4427cfb23c3e']); 826 | 827 | $request = Request::create($url->signedRoute('foo', $user)); 828 | 829 | $this->assertTrue($url->hasValidSignature($request)); 830 | } 831 | 832 | public function testSignedRelativeUrl() 833 | { 834 | $url = new UrlGenerator( 835 | $routes = new RouteCollection(), 836 | $request = Request::create('http://www.foo.com/') 837 | ); 838 | $url->setKeyResolver(function () { 839 | return 'secret'; 840 | }); 841 | 842 | $route = new Route(['GET'], 'foo/', ['as' => 'foo', function () { 843 | // 844 | }]); 845 | $routes->add($route); 846 | 847 | $result = $url->signedRoute('foo', [], null, false); 848 | 849 | $request = Request::create($result); 850 | 851 | $this->assertTrue($url->hasValidSignature($request, false)); 852 | 853 | $request = Request::create($url->signedRoute('foo', [], null, false).'?tampered=true'); 854 | 855 | $this->assertFalse($url->hasValidSignature($request, false)); 856 | } 857 | 858 | public function testSignedUrlParameterCannotBeNamedSignature() 859 | { 860 | $url = new UrlGenerator( 861 | $routes = new RouteCollection(), 862 | $request = Request::create('http://www.foo.com/') 863 | ); 864 | $url->setKeyResolver(function () { 865 | return 'secret'; 866 | }); 867 | 868 | $route = new Route(['GET'], 'foo/{signature}', ['as' => 'foo', function () { 869 | // 870 | }]); 871 | $routes->add($route); 872 | 873 | $this->expectException(InvalidArgumentException::class); 874 | $this->expectExceptionMessage('reserved'); 875 | 876 | Request::create($url->signedRoute('foo', ['signature' => 'bar'])); 877 | } 878 | 879 | public function testSignedUrlParameterCannotBeNamedExpires() 880 | { 881 | $url = new UrlGenerator( 882 | $routes = new RouteCollection(), 883 | $request = Request::create('http://www.foo.com/') 884 | ); 885 | $url->setKeyResolver(function () { 886 | return 'secret'; 887 | }); 888 | 889 | $route = new Route(['GET'], 'foo/{expires}', ['as' => 'foo', function () { 890 | // 891 | }]); 892 | $routes->add($route); 893 | 894 | $this->expectException(InvalidArgumentException::class); 895 | $this->expectExceptionMessage('reserved'); 896 | 897 | Request::create($url->signedRoute('foo', ['expires' => 253402300799])); 898 | } 899 | 900 | public function testRouteGenerationWithBackedEnums() 901 | { 902 | $url = new UrlGenerator( 903 | $routes = new RouteCollection(), 904 | Request::create('http://www.foo.com/') 905 | ); 906 | 907 | $namedRoute = new Route(['GET'], '/foo/{bar}/', ['as' => 'foo.bar']); 908 | $routes->add($namedRoute); 909 | 910 | $this->assertSame('http://www.foo.com/foo/fruits/', $url->route('foo.bar', CategoryBackedEnum::Fruits)); 911 | } 912 | 913 | public function testRouteGenerationWithNestedBackedEnums() 914 | { 915 | $url = new UrlGenerator( 916 | $routes = new RouteCollection(), 917 | Request::create('http://www.foo.com/') 918 | ); 919 | 920 | $namedRoute = new Route(['GET'], '/foo/', ['as' => 'foo']); 921 | $routes->add($namedRoute); 922 | 923 | $this->assertSame( 924 | 'http://www.foo.com/foo/?filter%5B0%5D=people&filter%5B1%5D=fruits', 925 | $url->route('foo', ['filter' => [CategoryBackedEnum::People, CategoryBackedEnum::Fruits]]), 926 | ); 927 | } 928 | 929 | public function testSignedUrlWithKeyResolver() 930 | { 931 | $url = new UrlGenerator( 932 | $routes = new RouteCollection(), 933 | $request = Request::create('http://www.foo.com/') 934 | ); 935 | $url->setKeyResolver(function () { 936 | return 'first-secret'; 937 | }); 938 | 939 | $route = new Route(['GET'], 'foo', ['as' => 'foo', function () { 940 | // 941 | }]); 942 | $routes->add($route); 943 | 944 | $firstRequest = Request::create($url->signedRoute('foo')); 945 | 946 | $this->assertTrue($url->hasValidSignature($firstRequest)); 947 | 948 | $request = Request::create($url->signedRoute('foo').'?tampered=true'); 949 | 950 | $this->assertFalse($url->hasValidSignature($request)); 951 | 952 | $url2 = $url->withKeyResolver(function () { 953 | return 'second-secret'; 954 | }); 955 | 956 | $this->assertFalse($url2->hasValidSignature($firstRequest)); 957 | 958 | $secondRequest = Request::create($url2->signedRoute('foo')); 959 | 960 | $this->assertTrue($url2->hasValidSignature($secondRequest)); 961 | $this->assertFalse($url->hasValidSignature($secondRequest)); 962 | 963 | // Key resolver also supports multiple keys, for app key rotation via the config "app.previous_keys" 964 | $url3 = $url->withKeyResolver(function () { 965 | return ['first-secret', 'second-secret']; 966 | }); 967 | 968 | $this->assertTrue($url3->hasValidSignature($firstRequest)); 969 | $this->assertTrue($url3->hasValidSignature($secondRequest)); 970 | } 971 | 972 | public function testMissingNamedRouteResolution() 973 | { 974 | $url = new UrlGenerator( 975 | new RouteCollection(), 976 | Request::create('http://www.foo.com/') 977 | ); 978 | 979 | $url->resolveMissingNamedRoutesUsing(fn ($name, $parameters, $absolute) => 'test-url'); 980 | 981 | $this->assertSame('test-url', $url->route('foo')); 982 | } 983 | } 984 | 985 | class RoutableInterfaceStub implements UrlRoutable 986 | { 987 | public $key; 988 | public $slug = 'test-slug'; 989 | 990 | public function getRouteKey() 991 | { 992 | return $this->{$this->getRouteKeyName()}; 993 | } 994 | 995 | public function getRouteKeyName() 996 | { 997 | return 'key'; 998 | } 999 | 1000 | public function resolveRouteBinding($routeKey, $field = null) 1001 | { 1002 | // 1003 | } 1004 | 1005 | public function resolveChildRouteBinding($childType, $routeKey, $field = null) 1006 | { 1007 | // 1008 | } 1009 | } 1010 | 1011 | class InvokableActionStub 1012 | { 1013 | public function __invoke() 1014 | { 1015 | return 'hello'; 1016 | } 1017 | } 1018 | 1019 | class RoutingUrlGeneratorTestUser extends Model 1020 | { 1021 | protected $fillable = ['uuid']; 1022 | } 1023 | --------------------------------------------------------------------------------