Non-breaking, SEO friendly urls in Laravel

When admins create or update a news item—or any other entity—in our homegrown CMS, a url slug is generated based on it's title. The downside here is that when the title changes, the old url would break. If we wouldn't regenerate the url on updates, edited titles would still have an old slug in the url, which isn't an ideal situation either.

Our solution: add a unique identifier to the url that will never change, while keeping the slug intact. This creates links that are both readable and unbreakable.

Stack Overflow does this with it's question pages. Let's inspect the url structure:

http://stackoverflow.com/questions/<id>/<slug>

Note that when we visit /questions/79923/foo-bar-baz, we'd get redirected to /questions/79923/what-and-where-are-the-stack-and-heap.

Our gameplan:

  • Determine our identifier
  • Retrieve models via their identifier—ignoring the slug
  • Redirect invariant urls

Determining the Identifier

Assuming we're using a relational database like MySQL, the simplest form of an identifier is something we already have: the model's ID.

https://thelaraveltimes.com/articles/24/laravel-5-4-new-features

An incrementing ID can expose a lot though. It makes it easy for someone or something to crawl through an entire dataset, and it provides an indication of it's size.

This doesn't matter for public data like blog posts, but we probably don't want any malicious crawlers scraping our user profiles. Phil Sturgeon has a nice writeup about the importance of obfuscated ID's on his blog.

https://laravelbuddies.com/user/12dj4om7/sebastiandedeyne

We'll use the model's ID for this article. If we'd want to obfuscate our ID's, we could either use a library to hash the existing ID like Jens Segers' Optimus, or we could generate a random string when the model's created (in that case, don't forget to make ensure it's unique!).

We'll be working with a simple Article model, which has an id and title field. The article's slug will be a sluggified version of it's title, which we'll generate via an accessor.

Since the concept of an article maps to a single url, we'll also add a computed url attribute which returns an url to the article's detail page.

<?php
 
use Illuminate\Database\Eloquent\Model;
 
class Article extends Model
{
public function getSlugAttribute(): string
{
return str_slug($this->title);
}
}

Setting up the Router and the Controller

So here's the url structure we want:

https://thelaraveltimes.com/articles/<id>/<slug>

As a Laravel route, that would translate to:

<?php
 
Route::get('/article/{id}/{slug}', 'ArticleController@detail')

In our controller, we need the ID to retrieve the article item, the slug only exists to make the url human-readable (and in turn, SEO-friendlier).

<?php
 
use App\Models\Article;
 
class ArticleController
{
public function detail($id)
{
return view('article.detail')
->withArticle(Article::findOrFail($id));
}
}

When generating a url, we do care about the slug though:

<?php
 
action('ArticleController@detail', [$article->id, str_slug($article->title)]);

Since an article maps to a single url in this context, let's create a computed url attribute which returns an url to the article's detail page so we don't have to repeat the action call throughout the application.

<?php
 
class Article extends Model
{
// ...
 
public function getUrlAttribute(): string
{
return action('ArticleController@detail', [$this->id, $this->slug]);
}
}

Neat! Now we can link to our article using $article->url.

One more thing, since we don't care about the slug, we might as well make it optional.

<?php
 
// routes/web.php
 
Route::get('/article/{id}/{slug?}', 'ArticleController@detail')

Avoiding Duplicate Content

Links to your old pages won't break anymore, but having multiple urls pointing to the same piece of content isn't a good idea either since that creates duplicate content. To prevent this, old links should respond with a redirect to the correct url.

Let's revisit our controller's detail method. This time, we'll need to pull in the slug to find out if it represent's the latest revision of the article's title.

Since we're going to compare the request slug with the article slug, we'll need to inject the route segment in our controller. The slug segment is optional so we'll assign an empty string by default.

<?php
 
use App\Models\Article;
 
class ArticleController
{
public function detail($id, $slug = '')
{
$article = Article::findOrFail($id);
 
// Magic slug validation stuff...
 
return view('article.detail')
->withArticle($article);
}
}

Validating the slug should be easy, all we need to do is a simple string comparison! If the article slug doesn't match the request slug, we'll redirect the visitor to the correct url.

<?php
 
use App\Models\Article;
 
class ArticleController
{
public function detail($id, $slug = '')
{
$article = Article::findOrFail($id);
 
if ($slug !== $article->slug) {
return redirect()->to($article->url);
}
 
return view('article.detail')
->withArticle($article);
}
}

An Alternative to Redirects: Canonical Links

If we wouldn't want an actual redirect—or if we don't want that pesky conditional logic in our controller—we could use a canonical link tag insteaddd.

Let's add a link tag in our layout file if we've explicitly provided one.

<html>
<head>
{{-- ... --}} @if(isset($canonical))
<link rel="canonical" href="{{ $canonical }}" />
@endif
</head>
<body>
@yield('content'))
</body>
</html>

Then we don't have to handle redirects in our controller anymore, but we need to share the canonical link (which is the article's url) with the view.

<?php
 
use App\Models\Article;
 
class ArticleController
{
public function detail($id, $slug = '')
{
$article = Article::findOrFail($id);
 
return view('article.detail')
->withArticle($article)
->withCanonical($article->url);
}
}

That's it!

We've achieved our two goals!

We can change the article's title without worrying about breaking old links and the urls have a human readable slug.

With different ways to achieve a similar setup, like storing slugs in the database, it's up to you to decide on the best fit for your application.