Slug SEO strategies0:00
Let's talk slugs. Now, if we take any of the posts in our index here, and we click in, you can see we go to /posts, and then the ID of the post, which works perfectly. But if we want to improve our SEO score, so if we want search engines to be able to better surface individual results on our forum, which for a forum is your goal, then we need to include some form of slug, an indication as to the content of the post in the URL. There are a few different ways to do this. One way would be to completely switch out the ID for the slug. So if the slug was hello-world, well then instead of having that integer for our ID, we would have a URL more like this, /post/hello-world. In order to accomplish that, we would have to add some form of slug column to our post table, and we need to make it unique because obviously we're now using this to identify an individual post. And that itself has ramifications because consider the fact that if two posts are created with the same title, which is allowed, we would have to detect that when storing a post and generating the slug, and then add some form of auto incrementing integer or identifier to the end of the slug. So absolutely, we can go ahead and do this. Laravel allows us to use a slug column for the identifier, but there are so many little edge cases we need to think about.
and then add some form of auto incrementing integer or identifier to the end of the slug. So absolutely, we can go ahead and do this. Laravel allows us to use a slug column for the identifier, but there are so many little edge cases we need to think about. Think about this as well. What if you update your post and you change the title? Do we regenerate the slug? Well, if we don't regenerate the slug, then the slug is now out of date with the actual content of the post. But if we do update the slug, then anyone who's posted an old link will hit a 404 when they click that link. Do you see the problem? We have to think about a way of tracking changes to that link over time, performing redirects, all the rest of it. It's a little bit of a minefield. It can work, but it's a bit of a minefield. I'd like to propose something in between. Let's take a look. Stack Overflow actually has a really great example of this. So their URL is /questions and then /ID.
Adopting Stack Overflow URLs2:14
Stack Overflow actually has a really great example of this. So their URL is /questions and then /ID. So here's the ID of this question. So imagine this is /posts instead of /questions, and then you have the post ID. That's exactly what we have. But then after that, they have a segment, which is the slug, or in other words, basically this title without the question mark of the question. Now, this is actually incredibly easy to implement in Laravel. So let's go ahead and alter our post.show page to use the same logic that Stack Overflow uses for questions. Let's start at the root level, and then we'll work our way in to actually making all the necessary changes. So if I list out the root with post.show, this is its current format.
Updating show route2:54
Let's start at the root level, and then we'll work our way in to actually making all the necessary changes. So if I list out the root with post.show, this is its current format. Let's see if we can change that. I'm going to come into the web.php file, and we're using resource at the moment, but that's not really going to work. We can't edit the resource enough. So I'm going to just remove show from the list of things that we're generating, and I'm going to declare the show root above instead. So Route::get, and we want posts/{postId}, and then we're also going to support that slug. And for now, although this will change, I'm going to make that an optional parameter using a ?, just so not everything in the application breaks. This is going to point to the PostController, and we're looking for the show method, and we'll give it the same name of posts.show. So now if we go back to our terminal, rerun php artisan route:list, yeah, we've updated the format of this URL.
This is going to point to the PostsController, and we're looking for the show method, and we'll give it the same name of posts.show. So now if we go back to our terminal, rerun php to some root list, yeah, we've updated the format of this URL. It can now include that optional slug. Because it's optional, I'm guessing if we actually go back to our Post index, and we click into one of these posts, all of these URLs still work, even though they don't include the slug, because it is, in fact, optional. Actually, we should be able to go to, say, /foobar, and it still loads the post, because, again, that slug isn't being used to identify the post in any way. It's purely there for SEO purposes. So the first thing we need to do is be able to generate the correct URL based on the title of a post. So what we could do is we could handle all of this manually, so where we do post.show, say, in the index page here,
So the first thing we need to do is be able to generate the correct URL based on the title of a post. So what we could do is we could handle all of this manually, so where we do post.show, say, in the index page here, I could say that, well, the post parameter is still the post ID, but we're also going to include a slug parameter, and just for a moment, I'll set that to post.title. I'm not quite sure how Ziggy, which is the library that handles the front-end routing, is going to handle just outputting the post title as part of the route. Let's find out. So I'll click on one of these posts. Yeah, it kind of worked. So it did output the title, but the URL encodes it.
Yeah, it kind of worked. So it did output the title, but the URL encodes it. It's using %20 instead of a space. What we actually want is a hyphen there instead, okay, and it should all be lowercase rather than this mix of uppercase and lowercase, but the basic principle already works. The problem is I don't want to have to do this manually because it's too easy to make mistakes. It's too easy to format the slug differently on the front-end as opposed to the back-end. So what I'm going to do is I'm going to hand that responsibility over to the Post model itself, and you can get worried about the fact that the Post model now knows about routing.
Generating URLs in model5:31
So what I'm going to do is I'm going to hand that responsibility over to the Post model itself, and you can get worried about the fact that the Post model now knows about routing. It doesn't really know about routing. It just understands how to generate a URL to its show route, but it's a great place to put the logic so that we have a single location where we can generate this slightly more complex URL, and then we can send that to the front-end, we can use it in the back-end, wherever we see fit in order to make this feature unified across our code base. So here we are inside our Post model, and I have the title attribute above. I'm going to add a function underneath or a method called showRoute,
So here we are inside our Post model, and I have the title attribute above. I'm going to add a function underneath or a method called showRoute, and the reason I'm doing it as a method rather than creating another attribute is that I want to be able to pass additional parameters because we have, say, our page parameter for paginated redirects. So we need to keep that in mind. So I'll say that you can pass an array of parameters, but by default that's going to be null, and this will return the route to post.show, and then what else do we want to pass in here? Well, we want to first of all pass $this, which is going to be the ID,
and then what else do we want to pass in here? Well, we want to first of all pass this, which is going to be the id, and then we want to pass string, and we can use the slug method on this title, and the string slug helper is going to do all the hard work for us of correctly formatting that slug with hyphens, getting rid of spaces, removing invalid characters, and all the rest of it. So we'll pass that in, and then I'm also going to spread out any parameters that you've passed to the method. That should work absolutely fine. We need to inform our frontend about it so we can update our Index component.
That should work absolutely fine. We need to inform our frontend about it so we can update our Index component. I'll do that in the PostResource. So here's the current output, but maybe we'll have a routes array, and inside this routes array we could have show, which is just going to be equal to this, showRoute, and we're not going to pass in any parameters because we can just pass the default route down there. Once that's in place, we should be able to see that coming through Vue DevTools.
Once that's in place, we should be able to see that coming through Vue DevTools. So if we go to Vue DevTools and we take a look at the data coming our way, here's the first post, here's routes, and there we have show. It includes the ID, but it also includes this formatted slug. Awesome. So if we go to our index view file now, we can get rid of this hard-coded route, and instead we can say, well, give me post.routes.show, and that should work without any issues. Let's test it.
and that should work without any issues. Let's test it. So we'll click on this URL here. Yep, 245, and then we have the decorated slug, the SEO-friendly slug. If we come back out, try a different one. Perfect. It is working without issues. And as I said, we should be able to get rid of any of this, and it should just work. It doesn't affect the endpoint that loads because it's the ID that's being used to determine which Post to load.
Redirecting to canonical slug8:18
It doesn't affect the endpoint that loads because it's the id that's being used to determine which post to load. However, if we take a look at Stack Overflow once again, if you remove part of the slug on this site, you actually get redirected, taken back to the correct endpoint, and that would be very useful because if someone posts an old url in another forum and people click on it, it would be nice to give them the correct url so that if they share it again, well, then people have the updated version. So we can do that quite easily with a simple redirect in our controller.
so that if they share it again, well, then people have the updated version. So we can do that quite easily with a simple redirect in our controller. So here we are, show method, PostController. I'm going to ask for the current request along with the post, and what do we want to check for? Well, we want to check if the path of the URL is identical or contains the correct information from the show route method on the Post model. So something like, look, if the request path, and there is a URL method,
So something like, look, if the request path, and there is a URL method, but imagine if we have some query parameters on the end. So you'd have like posts forward slash post ID, right, and then you have forward slash slug, but what if after that you have ?page=1? URL is going to return that, but the post show route method won't have that information. So instead I'm going to reach for path, which is just this part here,
So instead I'm going to reach for path, which is just this part here, and what we'll say is, well, if the post show route with no parameters doesn't contain this path, I think there's a string contains method. Yeah, the haystack is the show route, and the needle is going to be that path there, and we should be able to get rid of that and import the class properly, Illuminate\Support\Str. Yeah, so if that's not the case,
and import the class properly, Illuminate\Support\Str. Yeah, so if that's not the case, if this show route does not contain this path, then what we actually want to do is return an early redirect to the post.show route, and as parameters, well, we can pass in request query, and that should give us any additional query parameters such as question mark page equals two, for example. Hopefully that works, but don't worry, in the next episode we're going to test this
Hopefully that works, but don't worry, in the next episode we're going to test this to make sure all these edge cases actually work as expected. Let's see what happens. We'll come back to our Post, and let's go ahead and remove a word. Okay, that didn't quite work, did it? Let's dd the post show route, and refresh this page. So that is as expected.
and refresh this page. So that is as expected. Let's dd the request path instead. All right, let's check str_contains again. This is haystack, this is the needle. Oh, hold on, I've done completely the wrong thing here. I need to say if we don't have this particular piece of information inside the show route, maybe I could, is there a missing? No, there's just contains.
maybe I could, is there a missing? No, there's just contains. Tell me if I'm wrong there. Maybe there's a method on the Str helper that's the opposite, so we don't have to have this negation operator, but let's go with this for now. So I'll refresh. Yeah, that already works. Look, so if I put in, say, foobar, because this is a really old link, hit enter,
Look, so if I put in, say, foobar, because this is a really old link, hit enter, I'm taken immediately to the new correct URL. And this is up to you, but you could add a status code here of 301 instead of 302, which is the default. 301 is a permanent redirect, so you're telling the browser, look, don't ever go to the old route again because the old route is outdated.
look, don't ever go to the old route again because the old route is outdated. It no longer exists. So it is very permanent, and it can be a little annoying when developing, so you have to weigh up the pros and cons, but I think in this case 301 is the correct choice because the old URL shouldn't exist anymore. It's outdated. It's old.
Updating redirects and benchmarking12:07
It's outdated. It's old. So keep that in mind. Let's go ahead and search the project for posts.show, and I'll click open in find window so that we can easily see the results as we change things. So here we return to root, passing the post. Instead, I'm going to return a redirect passing post.showRoot like so. Let's move to the next check.
passing post.showRoot like so. Let's move to the next check. We do the same thing here, so I'm going to return redirect, and we want to redirect to post.showRoot, although we don't have the post, do we? So comment post.showRoot, and yeah, we're going to pass this page parameter as a parameter to showRoot like so, and I think that should work.
as a parameter to showRoot like so, and I think that should work. So what is this? This is on updating a Comment. Let's see if it works. So I'll come here. Let's first of all post a Comment, so hello world, click enter, and then we'll go ahead and edit this, add an exclamation mark, update the Comment, confirm,
and then we'll go ahead and edit this, add an exclamation mark, update the comment, confirm, and that did work correctly. We are redirected, and we have the correct page in the URL. So that's exactly what we want. Perfect. I could probably copy this because we're going to need it for the next route as well, this one here, so I'll get rid of this. We obviously note, and you've probably seen it yourself,
this one here, so I'll get rid of this. We obviously note, and you've probably seen it yourself, that we're now performing one extra database query to load the Post model. Previously, we had that postId. I referenced the slight performance difference. I tell you what, just for interest, I'm going to use the benchmark tool, so Illuminate\Support\Benchmark, and I'll use this little dd functionality,
so Illuminate\Support\Benchmark, and I'll use this little dd functionality, which is dump and die, and it will just tell me the time it takes to actually grab the post ID directly from the comment when we destroy a comment. I'm going to come here, click delete, confirm, 0.031 milliseconds. Okay, 0.031 milliseconds. Let's say hello world, and add the comment again,
Okay, 0.031 milliseconds. Let's say hello world, and add the comment again, and now I'm going to switch to that, the other way of doing things that we talked about, so redirecting, loading this comment here, and again, I'll say benchmark dd, and we'll add a closure, and that closure can wrap it, so when we delete this one, we should see the new time. Yeah, still under one millisecond. I'm really not bothered.
Yeah, still under one millisecond. I'm really not bothered. That is not going to impact the user at all, and our server will easily be able to handle that change. It's under a millisecond. It's not going to be a problem, but it's pretty cool that you can use that Benchmark class to very quickly be able to decide if a change is going to cause a big impact in your application, and where you can make little micro-optimizations.
is going to cause a big impact in your application, and where you can make little micro-optimizations. Keep the Benchmark class in mind. Pretty cool. So returning our code to what it was before, we can go to the PostController, and again here, I'll return a redirect to the post show root instead. What else do we have? We have this post here,
What else do we have? We have this Post here, and that is the show root itself. Don't need to touch that. I don't need to touch the web.php file. We have our tests, so when I assert a redirect, I imagine these tests are now going to fail. Yeah, they are. I'll tell you what. Let's handle our test suite in the next episode,
I'll tell you what. Let's handle our test suite in the next episode, because we need to add tests for the functionality we've just created, but we also need to fix our current tests, and I would like, after making the changes, to remove this optional check on the root. I want it to be mandatory that the post show root has that slug, so once we've updated our tests,
that the Post show root has that slug, so once we've updated our tests, we should actually be able to easily remove that question mark to finalize this slug functionality. See you in the next episode.
