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 SubscribeThe whole point of the slug
is to be a URL-safe version of the name
. And, ideally, this wouldn't be something we need to set manually... or even think about! In a perfect world, we would be able to set the name of a Question
, save and something else would automatically calculate a unique slug
from the name
right before the INSERT query.
We accomplished this in our fixtures, but only there. Let's accomplish this everywhere.
To do that, we're going to install another bundle. Google for StofDoctrineExtensionsBundle and find its GitHub page. And then click over to its documentation, which lives on Symfony.com. This bundle gives you a bunch of superpowers for entities, including one called Sluggable. And actually, the bundle is just a tiny layer around another library called doctrine extensions.
This is where the majority of the documentation lives. Anyways, let's get the bundle installed. Find your terminal and run:
composer require stof/doctrine-extensions-bundle
You can find this command in the bundle's documentation.
And, oh, interesting! The install stops and says:
The recipe for this package comes from the contrib repository, which is open to community contributions. Review the recipe at this URL. Do you want to execute this recipe?
When you install a package, Symfony Flex looks for a recipe for that package... and recipes can live in one of two different repositories. The first is symfony/recipes, which is the main recipe repository and is closely guarded: it's hard to get recipes accepted here.
The other repository is called symfony/recipes-contrib. This is still guarded for quality... but it's much easier to get recipe accepted here. For that reason, the first time you install a recipe from recipes-contrib
, Flex asks you to make sure that you want to do that. So you can say yes or I'm actually going to say P for yes, permanently.
I committed my changes before recording recording, so when this finishes I'll run,
git status
to see what the recipe did! Ok: it enabled the bundle - of course - and it also created a new config file stof_doctrine_extensions.yaml
. Let's go check that out: config/packages/stof_doctrine_extensions.yaml
.
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html | |
# See the official DoctrineExtensions documentation for more details: https://github.com/Atlantic18/DoctrineExtensions/tree/master/doc/ | |
stof_doctrine_extensions: | |
default_locale: en_US |
Ok... nothing too interesting yet.
As we saw, this bundle comes with a bunch of special features for entities. And each time you want to use a feature, you need to enable it in this config file. The first behavior we want is sluggable. To enable it add orm:
- because we're using the Doctrine ORM:
... lines 1 - 2 | |
stof_doctrine_extensions: | |
default_locale: en_US | |
orm: | |
... lines 6 - 8 |
and then default:
, because we want to enable this on our default entity manager. That's... really not important except in edge cases where you have multiple database connections.
... lines 1 - 2 | |
stof_doctrine_extensions: | |
default_locale: en_US | |
orm: | |
default: | |
... lines 7 - 8 |
Then, sluggable: true
.
... lines 1 - 2 | |
stof_doctrine_extensions: | |
default_locale: en_US | |
orm: | |
default: | |
sluggable: true |
That's it! Well... sort of. This won't make any real difference in our app yet. But, internally, the sluggable feature is now active.
Before we start using it, in QuestionFactory
, remove the code that sets the slug. I'll delete this logic, but keep an example function for later.
... lines 1 - 20 | |
final class QuestionFactory extends ModelFactory | |
{ | |
... lines 23 - 40 | |
protected function initialize(): self | |
{ | |
// see https://github.com/zenstruck/foundry#initialization | |
return $this | |
//->afterInstantiate(function(Question $question) { }); | |
; | |
} | |
... lines 48 - 52 | |
} |
Now, temporarily, if we reload our fixtures with:
symfony console doctrine:fixtures:load
Yep! A huge error because slug
is not being set.
@Gedmo\Slug
AnnotationSo how do we tell the Doctrine extensions library that we want the slug
property to be set automatically? The library works via annotations. In the Question
entity, above the slug
property, add @Gedmo\Slug()
- making sure to autocomplete this so that PhpStorm adds the use
statement for this annotation.
The @Gedmo\Slug
annotation has one required option called fields={}
. Set it to name
.
... lines 1 - 6 | |
use Gedmo\Mapping\Annotation as Gedmo; | |
... lines 8 - 11 | |
class Question | |
{ | |
... lines 14 - 25 | |
/** | |
* @ORM\Column(type="string", length=100, unique=true) | |
* @Gedmo\Slug(fields={"name"}) | |
*/ | |
private $slug; | |
... lines 31 - 131 | |
} |
Done! The slug
will now be automatically set right before saving to a URL-safe version of the name
property.
Back at the terminal, try the fixtures now:
symfony console doctrine:fixtures:load
No errors! And on the homepage... yes! The slug looks perfect. We now never need to worry about setting the slug manually.
Internally, this magic works by leveraging Doctrine's event or "hook" system. The event system makes it possible to run custom code at almost any point during the "lifecycle" of an entity. Basically, you can run custom code right before or after an entity is inserted or updated, right after an entity is queried for or other times. You do this by creating an event subscriber or entity listener. We do have an example of an entity listener in our API Platform tutorial if you're interested.
Next, let's add two more handy fields to our entity: createdAt
and updatedAt
. The trick will be to have something automatically set createdAt
when the entity is first inserted and updatedAt
whenever it's updated. Thanks to Doctrine extensions, you're going to love how easy this is.
// composer.json
{
"require": {
"php": "^7.4.1",
"ext-ctype": "*",
"ext-iconv": "*",
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/doctrine-bundle": "^2.1", // 2.1.1
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
"doctrine/orm": "^2.7", // 2.8.2
"knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
"knplabs/knp-time-bundle": "^1.11", // v1.16.0
"nyholm/psr7": "^1.4", // 1.4.0
"sensio/framework-extra-bundle": "^5.5", // v5.6.1
"sentry/sentry-symfony": "^4.0", // 4.0.3
"stof/doctrine-extensions-bundle": "^1.4", // v1.5.0
"symfony/asset": "5.1.*", // v5.1.2
"symfony/console": "5.1.*", // v5.1.2
"symfony/dotenv": "5.1.*", // v5.1.2
"symfony/flex": "^1.3.1", // v1.9.10
"symfony/framework-bundle": "5.1.*", // v5.1.2
"symfony/monolog-bundle": "^3.0", // v3.5.0
"symfony/stopwatch": "5.1.*", // v5.1.2
"symfony/twig-bundle": "5.1.*", // v5.1.2
"symfony/webpack-encore-bundle": "^1.7", // v1.8.0
"symfony/yaml": "5.1.*", // v5.1.2
"twig/extra-bundle": "^2.12|^3.0", // v3.0.4
"twig/twig": "^2.12|^3.0" // v3.0.4
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
"symfony/debug-bundle": "5.1.*", // v5.1.2
"symfony/maker-bundle": "^1.15", // v1.23.0
"symfony/var-dumper": "5.1.*", // v5.1.2
"symfony/web-profiler-bundle": "5.1.*", // v5.1.2
"zenstruck/foundry": "^1.1" // v1.5.0
}
}