Self-healing URLs | laravel-sluggable | Spatie

 SPATIE

  Laravel Sluggable
====================

spatie.be/open-source

  [Docs](https://spatie.be/docs)  [Laravel-sluggable](https://spatie.be/docs/laravel-sluggable/v4)  Basic-usage  Self-healing URLs

 Version   v4

 Other versions for crawler [v4](https://spatie.be/docs/laravel-sluggable/v4)

  Self-healing URLs
- [ Introduction ](https://spatie.be/docs/laravel-sluggable/v4/introduction)
- [ Requirements ](https://spatie.be/docs/laravel-sluggable/v4/requirements)
- [ Installation &amp; setup ](https://spatie.be/docs/laravel-sluggable/v4/installation-setup)
- [ Translatable slugs ](https://spatie.be/docs/laravel-sluggable/v4/translatable-slugs)
- [ Laravel Boost skill ](https://spatie.be/docs/laravel-sluggable/v4/laravel-boost-skill)
- [ Changelog ](https://spatie.be/docs/laravel-sluggable/v4/changelog)
- [ Questions and issues ](https://spatie.be/docs/laravel-sluggable/v4/questions-issues)
- [ Support us ](https://spatie.be/docs/laravel-sluggable/v4/support-us)
- [ Upgrading ](https://spatie.be/docs/laravel-sluggable/v4/upgrading)

Basic usage
-----------

- [ Generating your first slug ](https://spatie.be/docs/laravel-sluggable/v4/basic-usage/getting-started)
- [ Using the Sluggable attribute ](https://spatie.be/docs/laravel-sluggable/v4/basic-usage/using-the-attribute)
- [ Using the HasSlug trait ](https://spatie.be/docs/laravel-sluggable/v4/basic-usage/using-the-has-slug-trait)
- [ Finding models by slug ](https://spatie.be/docs/laravel-sluggable/v4/basic-usage/finding-models-by-slug)
- [ Self-healing URLs ](https://spatie.be/docs/laravel-sluggable/v4/basic-usage/self-healing-urls)

Advanced usage
--------------

- [ Combining multiple source columns ](https://spatie.be/docs/laravel-sluggable/v4/advanced-usage/source-fields)
- [ Tuning the uniqueness suffix ](https://spatie.be/docs/laravel-sluggable/v4/advanced-usage/uniqueness)
- [ Overriding the underlying actions ](https://spatie.be/docs/laravel-sluggable/v4/advanced-usage/overriding-actions)

 Self-healing URLs
=================

###  On this page

1. [ Enabling ](#content-enabling)
2. [ Request behavior ](#content-request-behavior)
3. [ Translatable slugs ](#content-translatable-slugs)
4. [ Choosing a separator ](#content-choosing-a-separator)
5. [ Customizing the redirect ](#content-customizing-the-redirect)
6. [ Changing the URL format ](#content-changing-the-url-format)
7. [ Under the hood ](#content-under-the-hood)
8. [ When you don't need self-healing ](#content-when-you-dont-need-self-healing)

Say you publish a blog post titled "Hello World". Its URL is `/posts/hello-world`. A few days later you realise the title should have been "Hello Universe", so you update it. The slug regenerates to `hello-universe` and the URL becomes `/posts/hello-universe`. Every search engine result, every shared link, every bookmark pointing at `/posts/hello-world` now returns `404`.

Self-healing URLs fix this. The route key becomes `-{id}`. The slug part can change without breaking lookups, because the primary key still resolves the model. Stale slugs trigger a `308` redirect to the canonical URL.

Enabling
--------------------------------------------------------------------------------

Self-healing requires the `HasSlug` trait, because the feature overrides `getRouteKey()` and `resolveRouteBinding()`. Setting `selfHealing: true` on the attribute without the trait throws a `SelfHealingRequiresTrait` exception.

```
use Spatie\Sluggable\Attributes\Sluggable;
use Spatie\Sluggable\HasSlug;

#[Sluggable(
    from: 'title',
    to: 'slug',
    selfHealing: true,
)]
class Post extends Model
{
    use HasSlug;
}
```

Or with the trait alone:

```
public function getSlugOptions(): SlugOptions
{
    return SlugOptions::create()
        ->generateSlugsFrom('title')
        ->saveSlugsTo('slug')
        ->selfHealing();
}
```

With default options, a `Post` with id `5` and title "Hello World" exposes a route key of `hello-world-5`:

```
$post = Post::create(['title' => 'Hello World']);
$post->getRouteKey(); // "hello-world-5"
```

Request behavior
--------------------------------------------------------------------------------------------------------

Bind the model to a route the usual way and the package handles the slug-and-id route key for you.

```
// routes/web.php
Route::get('/posts/{post}', fn (Post $post) => $post);
```

Incoming pathResult`/posts/hello-world-5``200 OK` with the resolved model.`/posts/outdated-slug-5``308 Permanent Redirect` to `/posts/hello-world-5`.`/posts/hello-world-99``404 Not Found` when id `99` does not exist.`/posts/hello-world``404 Not Found`, no identifier in the URL.The redirect uses `308` rather than the more familiar `301` because `308` preserves the request method when followed. A stale `PUT`, `PATCH`, or `DELETE` to a self-healing URL still arrives at the canonical route as the same verb. With `301`, clients are allowed to rewrite the method to `GET`, which would turn the request into a `405 Method Not Allowed` on resource routes. Search engines treat both statuses the same for ranking, so SEO is unaffected. To return something else, see [Customizing the redirect](#customizing-the-redirect) below.

Translatable slugs
--------------------------------------------------------------------------------------------------------------

`HasTranslatableSlug` supports self-healing as well. The route key uses the slug for the current locale.

```
$post->setLocale('en');
$post->getRouteKey(); // "english-title-5"

$post->setLocale('nl');
$post->getRouteKey(); // "nederlandse-titel-5"
```

Choosing a separator
--------------------------------------------------------------------------------------------------------------------

The default separator is `-`. If your slugs can legitimately end with a number followed by a hyphen, use a separator that cannot collide with slug values.

```
#[Sluggable(
    from: 'title',
    to: 'slug',
    selfHealing: true,
    selfHealingSeparator: '--',
)]
```

Or via the trait:

```
SlugOptions::create()
    ->generateSlugsFrom('title')
    ->saveSlugsTo('slug')
    ->selfHealing(separator: '--');

// route key: "hello-world--5"
```

Customizing the redirect
--------------------------------------------------------------------------------------------------------------------------------

When an incoming URL's slug is stale, the package throws a `Spatie\Sluggable\Exceptions\StaleSelfHealingUrl` exception. Its `render()` method delegates to the `SelfHealingManager`, which by default returns a `308` redirect to the canonical URL.

Register a closure through the `SelfHealing` facade in a service provider's `boot()` method. The closure receives the resolved model, the stale route key, and the incoming request, and returns whatever response you want.

```
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Spatie\Sluggable\Facades\SelfHealing;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        SelfHealing::onStaleSelfHealingUrl(function (Model $model, string $staleRouteKey, Request $request) {
            return redirect()->route('posts.show', $model, status: 307);
        });
    }
}
```

Use cases include:

- Returning a `307` temporary redirect instead of the permanent `308`.
- Rendering an "old link" notification before redirecting.
- Logging the stale access for analytics.
- Refusing to redirect based on request state.

Changing the URL format
-----------------------------------------------------------------------------------------------------------------------------

The default route key looks like `-{id}`. To use a different layout, for example `-{slug}`, swap the two actions that build and parse the route key. They are configurable on `config/sluggable.php` and the package ships a worked example for the id-first format. See [Putting the identifier first](/docs/laravel-sluggable/v4/advanced-usage/overriding-actions#example-putting-the-identifier-first) on the overriding actions page.

Under the hood
--------------------------------------------------------------------------------------------------

### A fresh request

When someone visits `/posts/hello-world-5`, the package splits the URL at the last separator. The right side is the primary key, so it goes looking for the post with id `5`. It finds the post, confirms that its current slug is still `hello-world`, and hands the model to your controller. Nothing special happens. The request is served normally.

### A stale link

Now imagine you rename the post to "Hello Universe". The slug in the database becomes `hello-universe`, but the old link `/posts/hello-world-5` is still floating around on Twitter, in Google's index, and in somebody's bookmarks.

When that old link hits your app, the package again pulls `5` off the end and loads the post. This time the slug in the URL does not match the post's current slug, so the package sends back a `308` redirect to `/posts/hello-universe-5`. The visitor (or the search engine crawler) follows the redirect and lands on the canonical URL.

### No database writes

The database is never touched by this process. The package only reads. Your slug column is updated the usual way, through Eloquent, when you save the model. Visiting a stale URL doesn't regenerate a slug, doesn't store the old one anywhere, and doesn't leave any trace.

Because the lookup is always by primary key, the slug column doesn't need to be unique, and changing a title never orphans an existing link.

When you don't need self-healing
------------------------------------------------------------------------------------------------------------------------------------------------------

If your slug truly never changes after creation (taxonomy slugs, immutable reference data, short-lived resources), Laravel's built-in [implicit route model binding](https://laravel.com/docs/routing#implicit-binding) is enough. Point the route parameter at the slug column with `{post:slug}`, or override `getRouteKeyName()` on the model to return `'slug'` and drop the explicit hint. Be aware that any future change to the slug column breaks every existing link, which is exactly the situation self-healing exists to prevent.

```
// routes/web.php
Route::get('/posts/{post:slug}', fn (Post $post) => $post);
```

 A good
match?
-------------

### What we do best

- All things Laravel
- Custom frontend components
- Building APIs
- AI-powered features
- Simplifying things
- Clean solutions
- Integrating services

### Not our cup of tea

- WordPress themes
- Cutting corners
- Free mockups to win a job
- "Just execute the briefing"

 In short: we'd like to be a **substantial part** of your project.

 [ Get in touch via email ](mailto:info@spatie.be?subject=A%20good%20match%21&body=Tell%20us%20as%20much%20as%20you%20can%20about%0A-%20your%20online%20project%0A-%20your%20planning%0A-%20your%20budget%0A-%20%E2%80%A6%0A%0AAnything%20that%20helps%20us%20to%20start%20straightforward%21)
