Self-healing URLs in Laravel

November 20, 2023

Generating resilient, SEO-friendly URLs in Laravel

The other day, I was looking at socks on Amazon (like I do) and I noticed that the URLs were nice and SEO friendly, but also had a unique identifier at the end.

The main URL looked like this:

But, in fact, any of these URLs would work:

I notice the same thing on Medium. All of these URLs go to the same place:

The Medium URLs are a little bit better, because they redirect back to the canonical URL, which is better for SEO. Their URLs are self-healing!

Let's figure out how to do this in Laravel.

Setting up the models

I started by creating a simple table with a title and a body, along with a primary key for the blog posts.

return new class extends Migration {
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->timestamps();
});
}
};
Code highlighting powered by torchlight.dev (A service I created!)

Then I set up Laravel's traditional route-model binding in my routes file:

Route::get('posts/{post}', function (App\Post $post) {
return $post->id . ': ' . $post->title;
})->name('post.show');

Now I can hit example.com/posts/1 and see the post ID and title in my browser.

The logic behind self-healing URLs

The primary principle behind implementing self-healing URLs is being able to segregate the unique identifier (the ID) from the other components of the URL (the SEO-friendly bits). For instance, if we were to use the title of a blog post in the URL, regardless of what changes happen to the title, the post can still be reached using the ID.

Laravel provides two ways to hook into route-model binding:

  1. From the inside out: Model → URL
  2. From the outside in: URL → model

We'll start with the first approach, where we use the model to generate a friendly URL.

Creating the SEO friendly URLs

Laravel provides a few ways to control the URL that's generated for your model. The first is the getRouteKeyName, which is where you give Laravel a column that contains the value you want to put in the URL.

Instead of that, we're going to use the getRouteKey method, where we can just make up the value ourselves instead of tying it to a database column.

In our case, we'll slug the title and append the id.

class Post extends Model
{
public function getRouteKey()
{
return Str::slug($this->title) . '-' . $this->id;
}
}

Now, instead of our posts URLs being /post/1, they'll be /post/title-of-the-post-1, which is more SEO friendly.

Extracting the unique identifier

Now that we have the inside out part (model → URL), we need to figure out the outside in part (URL → model.)

This logic lives in the Eloquent model where Laravel provides a resolveRouteBinding() method. Laravel calls this method with the fragment of the URL.

Laravel's default implementation looks like this:

public function resolveRouteBinding($value, $field = null)
{
return $this->resolveRouteBindingQuery($this, $value, $field)->first();
}

It receives the value from the URL and then passes it on to the resolveRouteBindingQuery method to query the database for the value. We're going to be hooking into this method.

Following our new URL scheme, the unique ID can be extracted using the native PHP explode function combined with Laravel's last helper to select the last segment of the URL as the ID.

public function resolveRouteBinding($value, $field = null)
{
// Get the unique ID
$id = last(explode('-', $value));
 
// Defer to the parent implementation
return parent::resolveRouteBinding($id, $field);
}

This way, regardless of any changes to the SEO-friendly part of the URL, the final segment (the ID) still leads us to the correct resource.

Improving on the concept

So far, we have copied the URL structure of Amazon, where it does not redirect with any changes to the URL. But what if we wanted to implement Medium's self-healing nature, where any changes to the URL redirect back to the correct URL?

To achieve this, we compare the URL of the model that we generate using Laravel with the one provided by the user. If they match, we render the resource, otherwise we generate a redirect response.

We'll use Laravel's HttpResponseException to redirect us to the correct url.

public function resolveRouteBinding($value, $field = null)
{
$id = last(explode('-', $value));
$model = parent::resolveRouteBinding($id, $field);
 
// Check to see if the model's route key matches
// the URL value, or if the model wasn't found.
// (Laravel will handle throwing the 404.)
if (!$model || $model->getRouteKey() === $value) {
// If so, return to Laravel.
return $model;
}
 
// If not, redirect to the right URL.
throw new HttpResponseException(
redirect()->route('posts.show', $model)
);
}
Code highlighting powered by torchlight.dev (A service I created!)

With this in place, any changes to the URL that don't match the correct one result in a redirect to the original URL.

Moving away from primary keys for unique identifiers

Most websites, including Medium, use non-autoincrementing character strings as identifiers to make the URL prettier, and to not expose their primary keys.

It's a personal choice whether to implement this or not. If you do wish to switch to this style, Laravel offers an easy transition with the help of the getRouteKeyName() method in the model.

A sample method to generate URLs using a generated hash instead of an auto incrementing ID might look like this:

class Post extends Model
{
public function getRouteKeyName()
{
// You'd need to populate this!
// Probably using an observer.
return 'public_id';
}
 
public function getRouteKey()
{
return Str::slug($this->title) . '-' . $this->getAttribute($this->getRouteKeyName());
}
 
public function resolveRouteBinding($value, $field = null)
{
$id = last(explode('-', $value));
$model = parent::resolveRouteBinding($id, $field);
 
if (!$model || $model->getRouteKey() === $value) {
return $model;
}
 
throw new HttpResponseException(
redirect()->route('posts.show', $model)
);
}
}

This is pretty much the whole magic behind self-healing SEO-friendly URLs. If you want a nice way to generate a public_id, you might look into nanoids.

As a YouTube video

I hope you enjoyed this! If you want to watch it as a YouTube video, it's available below.

Me

Thanks for reading! My name is Aaron and I'm a Developer Educator at PlanetScale — a MySQL platform.

If you ever have any questions or want to chat, I'm always on Twitter.

You can find me on YouTube on my personal channel or my work channel, or my behind the scenes channel.