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:
- amazon.com/Mottee-Zconia-Athletic/dp/B0752BFRV6
- amazon.com/Mottee-Zconia/dp/B0752BFRV6
- amazon.com/M/dp/B0752BFRV6
- amazon.com/hi-there-thanks-for-reading/dp/B0752BFRV6
I notice the same thing on Medium. All of these URLs go to the same place:
- medium.com/@yegg/mental-models-i-find-repeatedly-useful-936f1cc405d
- medium.com/@yegg/mental-models-i-find-repeatedly-936f1cc405d
- medium.com/@yegg/mental-models-i-find-936f1cc405d
- medium.com/@yegg/mental-models-i-936f1cc405d
- medium.com/@yegg/mental-models-936f1cc405d
- medium.com/@yegg/mental-936f1cc405d
- medium.com/@yegg/hi-there-thanks-for-reading-936f1cc405d
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(); }); }};
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:
- From the inside out: Model → URL
- 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) );}
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.