Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Using 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
... line 4
... 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


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
#[Slug(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
#[Route('/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!

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
{% 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.

Leave a comment!

Login or Register to join the conversation
Benoit-L Avatar
Benoit-L Avatar Benoit-L | posted 10 months ago | edited

I have removed the db, recreated it but now I got the following error when

An exception occurred while executing a query: SQLSTATE[23000]: Integrity constraint violation: 1048 Le champ 'slug' ne peut être vide (null)

Here is the content of the VinylMix for the slug file:

    #[ORM\Column(length: 100,unique:true)]
    private ?string $slug = null;

Ok. I found the problem, the library was not automatically uploaded

use Gedmo\Mapping\Annotation\Slug;


Hey Benoit-L,

It seems like the Sluggable behavior does not work for that field. It might be for many reasons. First of all, check that you have used the correct namespace above the entity class, there should be use Gedmo\Mapping\Annotation\Slug; line in the use statements. Also, make sure you enabled the sluggable extension, see this code block: https://symfonycasts.com/screencast/symfony-doctrine/sluggable#codeblock-35894f3389 . And finally, make sure you clear the cache just in case, it might be a simple cache issue :)


1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.7", // v3.7.0
        "doctrine/doctrine-bundle": "^2.7", // 2.7.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.12", // 2.12.3
        "knplabs/knp-time-bundle": "^1.18", // v1.19.0
        "pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
        "pagerfanta/twig": "^3.6", // v3.6.1
        "sensio/framework-extra-bundle": "^6.2", // v6.2.6
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
        "symfony/asset": "6.1.*", // v6.1.0
        "symfony/console": "6.1.*", // v6.1.2
        "symfony/dotenv": "6.1.*", // v6.1.0
        "symfony/flex": "^2", // v2.2.2
        "symfony/framework-bundle": "6.1.*", // v6.1.2
        "symfony/http-client": "6.1.*", // v6.1.2
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
        "symfony/runtime": "6.1.*", // v6.1.1
        "symfony/twig-bundle": "6.1.*", // v6.1.1
        "symfony/ux-turbo": "^2.0", // v2.3.0
        "symfony/webpack-encore-bundle": "^1.13", // v1.15.1
        "symfony/yaml": "6.1.*", // v6.1.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.4.0
        "twig/twig": "^2.12|^3.0" // v3.4.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.1.*", // v6.1.0
        "symfony/maker-bundle": "^1.41", // v1.44.0
        "symfony/stopwatch": "6.1.*", // v6.1.0
        "symfony/web-profiler-bundle": "6.1.*", // v6.1.2
        "zenstruck/foundry": "^1.21" // v1.21.0