Laravel Magazine
Building a Slug-and-Redirect System for Renamed Content in Laravel

Building a Slug-and-Redirect System for Renamed Content in Laravel

Eric Van Johnson ·

Rename a blog post's title, and its slug usually changes with it. Every inbound link, bookmark, and search engine result pointing at the old URL now 404s — which is bad for users and worse for SEO, since a 404 tells search engines the content is simply gone rather than "moved here." This tutorial builds a slug history table so old URLs keep resolving with a proper redirect, for a documentation site at PHP Architect.

Step 1: Track slug history in its own table

Resist the urge to solve this by keeping an old_slug column on the articles table — a single column can't handle an article renamed three times. A dedicated history table can:

Schema::create('article_slugs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('article_id')->constrained()->cascadeOnDelete();
    $table->string('slug')->unique();
    $table->timestamps();
});

Every slug an article has ever had — including its current one — gets a row here.

Step 2: Write the slug on every save, keep history automatically

namespace PhpArchitect\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class Article extends Model
{
    protected static function booted(): void
    {
        static::saving(function (Article $article) {
            if ($article->isDirty('title') || ! $article->slug) {
                $article->slug = Str::slug($article->title);
            }
        });

        static::saved(function (Article $article) {
            $article->slugs()->firstOrCreate(['slug' => $article->slug]);
        });
    }

    public function slugs()
    {
        return $this->hasMany(ArticleSlug::class);
    }
}

Every time the title changes and produces a new slug, saved() records it in the history table via firstOrCreate — so the current slug and every prior one all end up represented, with no duplicate rows if a save happens without a slug change.

Step 3: Resolve the current article by any historical slug

namespace PhpArchitect\Http\Controllers;

use PhpArchitect\Models\Article;
use PhpArchitect\Models\ArticleSlug;

class ArticleController
{
    public function show(string $slug)
    {
        $article = Article::where('slug', $slug)->first();

        if ($article) {
            return view('articles.show', compact('article'));
        }

        $historical = ArticleSlug::where('slug', $slug)->first();

        abort_unless($historical, 404);

        return redirect()->route('articles.show', [
            'slug' => $historical->article->slug,
        ], 301);
    }
}

The lookup order matters: check the current slug first since that's the common case, and only fall back to the history table — one extra query — when the direct lookup misses. Most requests never touch the history table at all.

Step 4: Use a permanent redirect, deliberately

The 301 status matters here, not just as a nicety. A 301 Moved Permanently tells search engines to transfer the old URL's ranking signal to the new one, which is the entire point of this feature — without it, you've built a system that avoids showing users a 404, but search engines still treat the old and new URLs as unrelated pages.

Step 5: Guard against slug collisions

If two different articles could independently produce the same slugged title, appending a disambiguator at creation time avoids collisions in both the main table and the history table:

static::saving(function (Article $article) {
    if ($article->isDirty('title') || ! $article->slug) {
        $base = Str::slug($article->title);
        $slug = $base;
        $suffix = 1;

        while (
            Article::where('slug', $slug)->where('id', '!=', $article->id ?? 0)->exists()
            || ArticleSlug::where('slug', $slug)->exists()
        ) {
            $slug = "{$base}-" . ++$suffix;
        }

        $article->slug = $slug;
    }
});

Checking against both the live articles.slug column and the article_slugs history table prevents a new article from accidentally claiming a slug that used to belong to something else — which would otherwise send old links to the wrong content instead of a 404, a worse outcome than the one this feature exists to prevent.

The payoff

Old links keep working, search rankings transfer instead of resetting, and none of it requires manually maintaining redirect rules in a config file every time someone renames something. The history table grows by one row per rename, which for almost any content volume is a trivial amount of data for a meaningful SEO and UX improvement.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.