├── .gitignore ├── .styleci.yml ├── phpstan.neon ├── tests ├── Stubs │ ├── TestModelFactory.php │ └── TestModel.php ├── TestCase.php ├── migrations │ └── 2024_08_16_083011_create_test_models_table.php └── Feature │ └── SelfHealingUrlTest.php ├── src ├── Middleware │ ├── DisableSelfHealingUrls.php │ └── EnableSelfHealingUrls.php └── HasSelfHealingUrls.php ├── .scrutinizer.yml ├── LICENSE.md ├── .github └── workflows │ └── main.yml ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .phpunit.result.cache 3 | phpunit.xml 4 | .idea 5 | composer.lock -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - single_class_element_per_statement 5 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src/ 8 | 9 | # Level 9 is the highest level 10 | level: 5 -------------------------------------------------------------------------------- /tests/Stubs/TestModelFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 15 | ]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Middleware/DisableSelfHealingUrls.php: -------------------------------------------------------------------------------- 1 | attributes->set('disable_self_healing_urls', true); 13 | return $next($request); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Middleware/EnableSelfHealingUrls.php: -------------------------------------------------------------------------------- 1 | attributes->set('disable_self_healing_urls', false); 13 | return $next($request); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: [ tests/* ] 3 | 4 | checks: 5 | php: 6 | remove_extra_empty_lines: true 7 | remove_php_closing_tag: true 8 | remove_trailing_whitespace: true 9 | fix_use_statements: 10 | remove_unused: true 11 | preserve_multiple: false 12 | preserve_blanklines: true 13 | order_alphabetically: true 14 | fix_php_opening_tag: true 15 | fix_linefeed: true 16 | fix_line_ending: true 17 | fix_identation_4spaces: true 18 | fix_doc_comments: true -------------------------------------------------------------------------------- /tests/Stubs/TestModel.php: -------------------------------------------------------------------------------- 1 | name)->slug(); 20 | } 21 | 22 | protected static function newFactory(): TestModelFactory 23 | { 24 | return new TestModelFactory(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/migrations'); 12 | } 13 | 14 | protected function defineEnvironment($app) 15 | { 16 | $app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32))); 17 | $app['config']->set('database.default', 'sqlite'); 18 | $app['config']->set('database.connections.sqlite', [ 19 | 'driver' => 'sqlite', 20 | 'database' => ':memory:', 21 | 'prefix' => '', 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/migrations/2024_08_16_083011_create_test_models_table.php: -------------------------------------------------------------------------------- 1 | id(); 17 | 18 | TestModel::selfHealingUrlMigration($table); 19 | 20 | $table->string('name'); 21 | 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | */ 29 | public function down(): void 30 | { 31 | Schema::dropIfExists('test_models'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Chris Page 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. -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | os: [ubuntu-latest] 14 | php: [8.1,8.2,8.3,8.4] 15 | laravel: [9.*,10.*,11.*] 16 | stability: [prefer-stable] 17 | exclude: 18 | - laravel: 11.* 19 | php: 8.1 20 | 21 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v2 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 32 | coverage: none 33 | 34 | - name: Install dependencies 35 | run: | 36 | composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update 37 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 38 | - name: Execute tests 39 | run: ./vendor/bin/phpunit tests -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "motomedialab/laravel-self-healing-urls", 3 | "description": "Generate self-healing URLs for models", 4 | "keywords": [ 5 | "motomedialab", 6 | "laravel-self-healing-urls" 7 | ], 8 | "homepage": "https://github.com/motomedialab/laravel-self-healing-urls", 9 | "license": "MIT", 10 | "type": "library", 11 | "authors": [ 12 | { 13 | "name": "Chris Page", 14 | "email": "hello@motocom.co.uk", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "illuminate/database": "^9.0|^10.0|^11.0" 21 | }, 22 | "require-dev": { 23 | "laravel/pint": "^1.13", 24 | "nunomaduro/larastan": "^2.0", 25 | "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0", 26 | "phpunit/phpunit": "^8.5.8|^9.5.21|^10.0.7|^10.5|^11.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Motomedialab\\LaravelSelfHealingUrls\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Motomedialab\\LaravelSelfHealingUrls\\Tests\\": "tests" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "vendor/bin/phpunit", 40 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 41 | }, 42 | "config": { 43 | "sort-packages": true 44 | }, 45 | "extra": { 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Feature/SelfHealingUrlTest.php: -------------------------------------------------------------------------------- 1 | middleware('web') 18 | ->get('/test-path/{model}', fn (TestModel $model) => $model->name) 19 | ->name('test-model-show'); 20 | 21 | $router 22 | ->middleware([DisableSelfHealingUrls::class, 'web']) 23 | ->get('/test-path-unbound/{model}', fn (TestModel $model) => route('test-model-show-unbound', $model)) 24 | ->name('test-model-show-unbound'); 25 | } 26 | 27 | public function test_it_can_build_a_route_url() 28 | { 29 | $model = TestModel::factory()->create(); 30 | 31 | $value = route('test-model-show', $model); 32 | 33 | $this->assertStringEndsWith( 34 | '/test-path/'.Str::slug($model->name).'-'.$model->route_binding_id, 35 | $value 36 | ); 37 | } 38 | 39 | public function test_it_redirects_to_correct_url_with_invalid_slug() 40 | { 41 | $model = TestModel::factory()->create(); 42 | 43 | $wrongRoute = str_replace(Str::slug($model->name), 'wrong-slug', route('test-model-show', compact('model'))); 44 | 45 | $this->get($wrongRoute)->assertRedirect($model->getModelUrl()); 46 | } 47 | 48 | public function test_it_returns_model_with_correct_slug() 49 | { 50 | $model = TestModel::factory()->create(); 51 | 52 | $response = $this->get(route('test-model-show', compact('model'))); 53 | 54 | $response->assertOk(); 55 | $response->assertSee($model->name); 56 | } 57 | 58 | public function test_it_disables_self_healing_when_middleware_is_set() 59 | { 60 | $model = TestModel::factory()->create(); 61 | 62 | $this->get('/test-path-unbound/'.$model->getKey()) 63 | ->assertStatus(200) 64 | ->assertSee('/test-path-unbound/'.$model->getKey()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Self-healing URLs in Laravel 2 | 3 | This package was inspired [Aaron Francis](https://www.youtube.com/watch?v=a6lnfyES-LA) on YouTube - thanks Aaron! 4 | This lightweight package allows you to create URLs that are able to self-heal, regardless of the slug provided. 5 | 6 | This is great for SEO purposes, allowing you to change slugs without worrying, and will force a 301 redirect to the 7 | correct URL. 8 | 9 | This technique is commonly used on well known websites such as Amazon and Medium to allow slugs to change without 10 | breaking the actual URL. 11 | 12 | An example of this would be visiting `https://your-site.com/posts/old-slug-12345` automatically redirecting you to 13 | `https://your-site.com/posts/new-slug-12345`. It does this based on the persisted unique ID at the end of the slug. 14 | 15 | This makes use of Laravel's pre-existing route model binding. 16 | 17 | ## Installation 18 | 19 | You can install the package via composer: 20 | 21 | ```bash 22 | composer require motomedialab/laravel-self-healing-urls 23 | ``` 24 | 25 | ## Usage 26 | 27 | To use this package, simply install it, apply the provided trait to your model and tell 28 | the trait where the models slug can be found. 29 | 30 | In the below examples I've used a `Post` model, but this really can apply to any model you like. 31 | 32 | ```php 33 | title); 48 | } 49 | 50 | } 51 | ``` 52 | 53 | Once you've done this, you'll also need to add another column to your migrations. 54 | This column will store the unique value that should be used within the URL. 55 | 56 | I've added a helper to do this: 57 | 58 | ```php 59 | getRouteBindingKeyName(); 28 | 29 | if ($rollback) { 30 | $table->dropColumn($column); 31 | 32 | return null; 33 | } 34 | 35 | $migration = $table->string($column); 36 | 37 | if (! $table->creating()) { 38 | $migration->after($model->getKeyName()); 39 | } 40 | 41 | return $migration->unique(); 42 | } 43 | 44 | /** 45 | * When creating our model, ensure it has an entirely unique 46 | * route binding ID. 47 | */ 48 | protected static function bootHasSelfHealingUrls(): void 49 | { 50 | $attempts = 0; 51 | $exists = function (self $model) use ($attempts) { 52 | if ($attempts > 3) { 53 | throw new \Exception( 54 | class_basename($model).'::generateHealingUniqueId does not have enough '. 55 | 'entropy and failed URL generation. This method should generate a very random ID.' 56 | ); 57 | } 58 | 59 | return $model 60 | ->newQuery() 61 | ->where($model->getRouteBindingKeyName(), $model->getRouteBindingKey()) 62 | ->exists(); 63 | }; 64 | 65 | static::creating(function (self $model) use ($exists, &$attempts) { 66 | do { 67 | // enforce a unique ID for our model 68 | $model->setAttribute($model->getRouteBindingKeyName(), $model->generateHealingUniqueId()); 69 | $attempts++; 70 | } while ($exists($model)); 71 | }); 72 | } 73 | 74 | /** 75 | * Override base method. 76 | * Resolve our model from the given parameters. 77 | */ 78 | public function resolveRouteBinding($value, $field = null): ?Model 79 | { 80 | $model = parent::resolveRouteBinding($value, $field); 81 | 82 | // allow disabling via middleware 83 | if (!$this->selfHealingUrlActive()) { 84 | return $model; 85 | } 86 | 87 | $slug = $this->resolveRouteBindingParameters($value)[1] ?? null; 88 | 89 | if ($model && ($model->getRouteBindingSlug() !== $slug)) { 90 | abort(301, 'Moved Permanently', ['Location' => $model->getModelUrl()]); 91 | } 92 | 93 | return $model; 94 | } 95 | 96 | /** 97 | * Override base method. 98 | * Generate our query to resolve our model from the database 99 | * using the route binding key. 100 | */ 101 | public function resolveRouteBindingQuery($query, $value, $field = null) 102 | { 103 | // allow disabling via middleware 104 | if (!$this->selfHealingUrlActive()) { 105 | return parent::resolveRouteBindingQuery($query, $value, $field); 106 | } 107 | 108 | $uniqId = $this->resolveRouteBindingParameters($value)[2] ?? null; 109 | 110 | return $query->where($this->getRouteBindingKeyName(), $uniqId); 111 | } 112 | 113 | /** 114 | * Override base method. 115 | * Determine our absolute URL to this post. 116 | */ 117 | public function getRouteKey(): string 118 | { 119 | // allow disabling via middleware 120 | if (!$this->selfHealingUrlActive()) { 121 | return parent::getRouteKey(); 122 | } 123 | 124 | return $this->getRouteBindingSlug() . '-' . $this->getRouteBindingKey(); 125 | } 126 | 127 | /** 128 | * Determine the current key/unique ID for our route binding. 129 | */ 130 | public function getRouteBindingKey(): string 131 | { 132 | return $this->getAttribute($this->getRouteBindingKeyName()); 133 | } 134 | 135 | /** 136 | * Generate a unique ID. 137 | */ 138 | public function generateHealingUniqueId(): string 139 | { 140 | return substr(uniqid(), -8); 141 | } 142 | 143 | /** 144 | * Extract our binding parameters. 145 | */ 146 | public function resolveRouteBindingParameters(string $value): ?array 147 | { 148 | preg_match('/^(.*)-(.*)$/', $value, $matches); 149 | 150 | if (count($matches) !== 3) { 151 | return null; 152 | } 153 | 154 | return $matches; 155 | } 156 | 157 | /** 158 | * Determine the database key that should be used for our 159 | * route binding. 160 | */ 161 | public function getRouteBindingKeyName(): string 162 | { 163 | return 'route_binding_id'; 164 | } 165 | 166 | /** 167 | * Automatically determine the URI for this model. It's recommended 168 | * to extend this method. 169 | */ 170 | public function getModelUrl(): string 171 | { 172 | if (request()?->route()) { 173 | return route(request()->route()->getName(), $this); 174 | } 175 | 176 | throw new \Exception('Unable to determine self-healing URL. Extend the getModelUrl() method or make sure you are using the route() helper.'); 177 | } 178 | 179 | /** 180 | * Determine whether the selfHealingUrl should be used or has been disabled via Middleware. 181 | */ 182 | protected function selfHealingUrlActive() 183 | { 184 | $activeMiddleware = request()->route()?->middleware(); 185 | 186 | if ($activeMiddleware && in_array(EnableSelfHealingUrls::class, $activeMiddleware)) { 187 | return $this->selfHealingUrlActive = true; 188 | } 189 | 190 | if ($activeMiddleware && in_array(DisableSelfHealingUrls::class, $activeMiddleware)) { 191 | return $this->selfHealingUrlActive = false; 192 | } 193 | 194 | if (request()->attributes->has('disable_self_healing_urls')) { 195 | return $this->selfHealingUrlActive = request()->attributes->get('disable_self_healing_urls'); 196 | } 197 | 198 | return $this->selfHealingUrlActive; 199 | } 200 | 201 | /** 202 | * Determine the slug that our self-healing URL should use. 203 | */ 204 | abstract public function getRouteBindingSlug(): string; 205 | } 206 | --------------------------------------------------------------------------------