The spatie/laravel-sluggable package has been around for close to a decade. A slug is the readable part of a URL that identifies a record, like announcing-laravel-sluggable-v4-with-self-healing-urls in this post’s URL. The package generates one for any Eloquent model when you save it, derived from a title or another text field, and most of the time you don’t have to think about it.

We just released v4, which adds a few things worth talking about. Let me walk you through them.

Generating slugs with an attribute

For most models, slug generation is mechanical. Pick a source field, pick a destination field, done. In v4, you can express that with an attribute on the model.

use SpatieSluggableAttributesSluggable;
use IlluminateDatabaseEloquentModel;

#[Sluggable(from: 'title', to: 'slug')]
class Post extends Model
{
}

That’s it. No trait, no getSlugOptions() method. Save a Post and the package fills in the slug:

$post = Post::create(['title' => 'ActiveRecord is awesome']);

$post->slug; // "activerecord-is-awesome"

Self-healing URLs

Picture this. You publish a new post titled “Anouncing v4 of laravel-sluggable”. The slug becomes anouncing-v4-of-laravel-sluggable, you share the link, a few people bookmark it. Five minutes later you notice the missing n and fix the title. The slug regenerates to announcing-v4-of-laravel-sluggable, and now everyone who clicked the first version hits a 404.

The usual answer is a redirects table: store the old slug, forward it to the new one. It works, but every rename adds a row, and after a while it’s another moving piece to maintain.

Self-healing URLs solve this differently. The route key combines the slug with the primary key, so the slug is decorative and the id is what actually identifies the record.

use SpatieSluggableAttributesSluggable;
use SpatieSluggableHasSlug;
use IlluminateDatabaseEloquentModel;

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

If your post has slug hello-world and id 5, the URL becomes /posts/hello-world-5. Rename the post to Greetings, World and the canonical URL becomes /posts/greetings-world-5. Old links keep working because the package can still find the record by id, and it sends a permanent redirect to the new canonical URL.

The redirect status code is 308 Permanent Redirect. In v3 it was 301. The reason for the change is that 301 is allowed to silently downgrade PUT, PATCH, and DELETE to GET when followed, which gets you a 405 Method Not Allowed if the new URL doesn’t accept GET. 308 keeps the method intact.

Overridable actions

Slug generation and the self-healing URL format are now expressed as three actions: one for generating the slug, one for building the self-healing route key, and one for parsing it back out. All three can be swapped through config/sluggable.php. The example below customizes the two self-healing actions; the slug generator stays on its default behavior.

The use case I had in mind was self-healing URLs that put the id first. Say you want /posts/5-hello-world instead of /posts/hello-world-5. Extend the default builder action and put the identifier in front:

namespace AppSluggable;

use SpatieSluggableActionsBuildSelfHealingRouteKeyAction;

class IdFirstBuildAction extends BuildSelfHealingRouteKeyAction
{
    public function execute(string $slug, int|string $identifier, string $separator): string
    {
        if ($slug === '') {
            return (string) $identifier;
        }

        return "{$identifier}{$separator}{$slug}";
    }
}

The extractor needs the inverse logic. The default action looks for the last separator, because slugs can contain the separator themselves, so swap it for one that reads the identifier from the front:

namespace AppSluggable;

use IlluminateSupportStr;
use SpatieSluggableActionsExtractIdentifierFromSelfHealingRouteKeyAction;

class IdFirstExtractAction extends ExtractIdentifierFromSelfHealingRouteKeyAction
{
    public function execute(string $value, string $separator): array
    {
        $identifier = Str::before($value, $separator);

        if ($identifier === $value || ! ctype_digit($identifier)) {
            return ['slug' => $value, 'identifier' => null];
        }

        return [
            'slug' => Str::after($value, $separator),
            'identifier' => $identifier,
        ];
    }
}

Str::before returns the original string when the separator isn’t present, which is how the no-separator case is detected. The ctype_digit check rejects values where the prefix isn’t numeric, so models with non-numeric primary keys like ULIDs need to swap that for a regex.

Wire both into the config and you’re done:

// config/sluggable.php
'build_self_healing_route_key' => AppSluggableIdFirstBuildAction::class,
'extract_identifier_from_self_healing_route_key' => AppSluggableIdFirstExtractAction::class,

The full reference, including a simpler uppercase variant and a custom slug generator, lives in the overriding actions docs.

A bundled Boost skill

Laravel Boost is an MCP server from the Laravel team that exposes your application’s installed packages and versions to your AI assistant, so prompts produce code that fits your stack instead of generic Laravel snippets. Packages can bundle their own Boost skills, and Boost discovers them automatically.

This package ships with a sluggable-development skill that walks your assistant through adding a sluggable model: picking the source field, deciding whether you need self-healing URLs, generating the migration, and dropping the attribute or trait on the model. If you have Boost installed, this is the easiest way to add a new sluggable model to your project.

In closing

You can find the package on GitHub and the full documentation on our docs site. Coming from v3? The upgrade guide covers the breaking changes (mostly minimum versions and a callable to Closure migration).

This is one of many packages we’ve built at Spatie. If you want to support our open source work, consider picking up one of our paid products.

Categories: PHP