Clean URLs with Sluggable
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeUsing a database ID in your URL is... kind of lame. It's more common to use slugs. A slug is a URL-safe version of the name or title of an item. In this case, the title of our mix.
To make this possible, we only need to do one thing: give our VinylMix
class a slug
property that holds this URL-safe string. Then, it'll be super easy to query for it. The only trick is that... something needs to look at the mix's title and set that slug
property whenever a mix is saved. And, ideally that could happen automatically... cause I'm feeling kinda lazy... and I don't really want to do that work manually everywhere. Whelp, that is the job of the sluggable behavior from Doctrine Extensions.
Activating the Sluggable Listener
Head back to config/packages/stof_doctrine_extensions.yaml
and add sluggable: true
.
// ... lines 1 - 2 | |
stof_doctrine_extensions: | |
// ... line 4 | |
orm: | |
default: | |
// ... line 7 | |
sluggable: true |
Once again, this enables a listener that will be looking at each entity, whenever one is saved, to see if the sluggable behavior is activated on it. How do we do that?
Adding the Slug Property
First, we need a slug
property on our entity. To add it, at your terminal, run:
php bin/console make:entity
Update VinylMix
to add a new slug
field. This will be a string, and let's limit it to a 100 characters. Also make this not nullable: it should be required in the database. And that's it! Hit "enter" one more time to finish.
That, not surprisingly, added a slug
property.. plus getSlug()
and setSlug()
methods at the bottom.
// ... lines 1 - 10 | |
class VinylMix | |
{ | |
// ... lines 13 - 34 | |
#[ORM\Column(length: 100)] | |
private ?string $slug = null; | |
// ... lines 37 - 128 | |
public function getSlug(): ?string | |
{ | |
return $this->slug; | |
} | |
public function setSlug(string $slug): self | |
{ | |
$this->slug = $slug; | |
return $this; | |
} | |
} |
One thing the make:entity
command doesn't ask you is whether or not you want a property to be unique in the database. In slug
's case, we do want it to be unique, so add unique: true
. That will add a unique
constraint in the database to make sure that we never get duplicates.
// ... lines 1 - 10 | |
class VinylMix | |
{ | |
// ... lines 13 - 34 | |
#[ORM\Column(length: 100, unique: true)] | |
private ?string $slug = null; | |
// ... lines 37 - 139 | |
} |
Before we think about any sluggable magic, generate a migration for the new property:
symfony console make:migration
As usual, I'll open up that new file to make sure it looks okay. And... it does! It adds slug
including a UNIQUE INDEX
for slug
. And when we run it with
symfony console doctrine:migrations:migrate
it explodes... for the same reason as last time: Not null violation
. We're adding a new slug
column to our table that is not null... which means that any existing records won't work. As I said in the previous chapter, if your database is already on production, you would need to fix this. But since ours is not, we can cheat and reset the database like we did before:
symfony console doctrine:database:drop --force
Then:
symfony console doctrine:database:create
Finally re-run all of the migrations from the very beginning:
symfony console doctrine:migrations:migrate
And... yes! 4 migrations executed.
Adding the Sluggable Attribute
At this point, we've activated the sluggable listener and added a slug
column. But we're still missing a step. I'll prove it by going to /mix/new
and... error:
[...] column "slug" of relation "vinyl_mix" violates not-null constraint.
Yup! Nothing is setting the slug
property yet. To tell the extensions library that this is a slug
property that it should set automatically, we need to add - surprise - an attribute! It's called #[Slug]
. Hit "tab" to autocomplete that, which will add the use
statement that you need on top. Then, say fields
, which is set to an array, and inside, just title
.
// ... lines 1 - 7 | |
use Gedmo\Mapping\Annotation\Slug; | |
// ... lines 9 - 11 | |
class VinylMix | |
{ | |
// ... lines 14 - 36 | |
fields: ['title']) | (|
private ?string $slug = null; | |
// ... lines 39 - 141 | |
} |
This says:
use the "title" field to generate this slug.
And now... it looks like it's working! If we check the database...
symfony console doctrine:query:sql 'SELECT * FROM vinyl_mix'
Woohoo! The slug
is down here... and you can see the library is also smart enough to add a little -1
, -2
, -3
to keep it unique.
Updating our Route to use {slug}
Now that we have this slug
column, over in MixController
, let's make our route trendier by using {slug}
.
// ... lines 1 - 12 | |
class MixController extends AbstractController | |
{ | |
// ... lines 15 - 35 | |
'/mix/{slug}', name: 'app_mix_show') | (|
public function show(VinylMix $mix): Response | |
// ... lines 38 - 60 | |
} |
What else do we need to change here? Nothing! Because the route wildcard is now called {slug}
, Doctrine will use this value to query from the slug
property. Genius!
Updating Links to the Route
Though, we do need to update any links that we generate to this route. Watch: copy the route name - app_mix_show
- and search inside this file. Yup! We use it down here to redirect after we vote. Now, instead of passing the id
wildcard, pass slug
set to $mix->getSlug()
.
// ... lines 1 - 12 | |
class MixController extends AbstractController | |
{ | |
// ... lines 15 - 44 | |
public function vote(VinylMix $mix, Request $request, EntityManagerInterface $entityManager): Response | |
{ | |
// ... lines 47 - 56 | |
return $this->redirectToRoute('app_mix_show', [ | |
'slug' => $mix->getSlug(), | |
]); | |
} | |
} |
And if you searched, there's one other place we generate a URL to this route: templates/vinyl/browse.html.twig
. Right here, we need to change the link on the "Browse" page to slug: mix.slug
.
// ... lines 1 - 2 | |
{% block body %} | |
// ... lines 4 - 28 | |
{% for mix in mixes %} | |
<div class="col col-md-4"> | |
<a href="{{ path('app_mix_show', { | |
slug: mix.slug | |
}) }}" class="mixed-vinyl-container p-3 text-center"> | |
// ... lines 34 - 42 | |
</a> | |
</div> | |
{% endfor %} | |
// ... lines 46 - 48 | |
{% endblock %} |
Testing time! Let me refresh a few times... then head back to the homepage... click "Browse Mixes", and... there's our list! If we click one of these mixes... beautiful! It used the slug and it queried via the slug. Life is good.
Ok, right now, to add dummy data so we can use the site, we've created this new
action. But that's a pretty poor way to handle dummy data: it's manual, requires refreshing the page and, though we have some randomness, it creates boring data!
So next, let's add a proper data fixture system to remedy this.
Slug behavior didn't work when I experimented in PHP 7, below is my code:
The config is:
`stof_doctrine_extensions:
Services.yaml config is:
`parameters:
services:
`
Error:
Controller "App\Controller\ProductController::show" requires the "$product" argument that could not be resolved. Cannot autowire argument $product of "App\Controller\ProductController::show()": it needs an instance of "App\Entity\Product" but this type has been excluded in "config/services.yaml".