symfony-Doctrine & the Database
symfony-Doctrine & the Database
Friends! Welcome back to the third episode in our starting in Symfony 4 series! We've done some really cool
stuff already, but it's time to explore deeper in an epic search for intelligent life... and also.. and database! Yea
- what good is our cool interstellar space news site... without being able to insert and query for data?
But... actually... Symfony does not have a database layer. Nope, for this challenge, we're going to rely one of
Symfony's BFF's: an external library called Doctrine. Doctrine has great integration with Symfony and is crazy
powerful. It also has a reputation for being a little bit hard to learn. But, a lot has improved over the last few
years.
to start the built in web server. Then, float over to your browser, and open http://localhost:8000 to discover...
The Space Bar! Our inter-planetary, and extraterrestrial news site that spreads light on dark matters
everywhere.
In the first two episodes, we already added some pretty cool stuff! But, these articles are still just hard-coded.
Time to change that.
Installing Doctrine
Because Doctrine is an external library, before we do anything else, we need to install it! Thanks to Symfony
flex, this is super easy. Open a new terminal tab and just run:
This will download a "pack" that contains a few libraries, including doctrine itself and also a migrations library
to help manage database changes on production. More on that soon.
And... done! Hey! That's a nice message. Because we're going to be talking to a database, obviously, we will
need to configure our database details somewhere. The message tells us that - no surprise - this is done in the
.env file.
22 lines .env.dist
... lines 1 - 15
16 ###> doctrine/doctrine-bundle ###
17 # Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-u
18 # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
19 # Configure your db driver and server_version in config/packages/doctrine.yaml
20 DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name
21 ###
Let's set this up: I use a root user with no password locally. Call the database the_spacebar :
22 lines .env.dist
... lines 1 - 15
16 ###> doctrine/doctrine-bundle ###
17 # Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-u
18 # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
19 # Configure your db driver and server_version in config/packages/doctrine.yaml
20 DATABASE_URL=mysql://root:@127.0.0.1:3306/symfony4_space_bar
21 ###
Of course, this sets a DATABASE_URL environment variable. And it is used in a new config/packages/doctrine.yaml
file that was installed by the recipe. If you scroll down a bit... you can see the environment variable being used:
31 lines config/packages/doctrine.yaml
1 parameters:
2 # Adds a fallback DATABASE_URL if the env var is not set.
3 # This allows you to run cache:warmup even if your
4 # environment variables are not available yet.
5 # You should not need to change this value.
6 env(DATABASE_URL): ''
7
8 doctrine:
9 dbal:
... lines 10 - 17
18 # With Symfony 3.3, remove the `resolve:` prefix
19 url: '%env(resolve:DATABASE_URL)%'
... lines 20 - 31
There are actually a lot of options in here, but you probably won't need to change any of them. These give you
nice defaults, like using UTF8 tables:
31 lines config/packages/doctrine.yaml
... lines 1 - 7
8 doctrine:
9 dbal:
... lines 10 - 12
13 charset: utf8mb4
... lines 14 - 31
31 lines config/packages/doctrine.yaml
... lines 1 - 7
8 doctrine:
... lines 9 - 19
20 orm:
... line 21
22 naming_strategy: doctrine.orm.naming_strategy.underscore
... lines 23 - 31
If you want to use something other than MySQL, you can easily change that. Oh, and you should set your
server_version to the server version of MySQL that you're using on production:
31 lines config/packages/doctrine.yaml
... lines 1 - 7
8 doctrine:
9 dbal:
10 # configure these for your database server
11 driver: 'pdo_mysql'
12 server_version: '5.7'
... lines 13 - 31
Say hello to your new database! Well, it's not that interesting: it's completely empty.
So let's add a table, by creating an entity class.
Chapter 2: Creating an Entity Class
Doctrine is an ORM, or object relational mapper. A fancy term for a pretty cool idea. It means that each table in
the database will have a corresponding class in our code. So if we want to create an article table, it means that
we need to create an Article class. You can totally make this class by hand - it's just a normal PHP class.
Stop! That word "entity": that's important. This is the word that Doctrine gives to the classes that are saved to
the database. As you'll see in a second, these are just normal PHP classes. So, when you hear "entity", think:
Let's call our class Article , and then, cool! We can start giving it fields right here. We need a title field. For
field "type", hmm, hit "?" to see what all the different types are.
Notice, these are not MySQL types, like varchar. Doctrine has its own types that map to MySQL types. For
example, let's use "string" and let the length be 255. Ultimately, that'll create a varchar column. Oh, and
because we probably want this column to be required in the database, answer "no" for nullable.
Next, create a field called slug , use the string type again, and let's make it's length be 100, and no for
nullable.
Next, content , set this to text and "yes" to nullable: maybe we allow articles to be drafted without content at
first. And finally, a publishedAt field with a type set to datetime and yes to nullable. If this field is null, we'll
know that the article has not been published.
When you're done, hit enter to finish. And don't worry if you make a mistake. You can always update things
later, or delete the new entity class and start over.
93 lines src/Entity/Article.php
... lines 1 - 2
3 namespace App\Entity;
4
5 use Doctrine\ORM\Mapping as ORM;
6
7 /**
8 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
9 */
10 class Article
11 {
12 /**
13 * @ORM\Id()
14 * @ORM\GeneratedValue()
15 * @ORM\Column(type="integer")
16 */
17 private $id;
17 private $id;
18
19 /**
20 * @ORM\Column(type="string", length=255)
21 */
22 private $title;
23
24 /**
25 * @ORM\Column(type="string", length=100)
26 */
27 private $slug;
28
29 /**
30 * @ORM\Column(type="text", nullable=true)
31 */
32 private $content;
33
34 /**
35 * @ORM\Column(type="datetime", nullable=true)
36 */
37 private $publishedAt;
38
39 public function getId()
40 {
41 return $this->id;
42 }
43
44 public function getTitle(): ?string
45 {
46 return $this->title;
47 }
48
49 public function setTitle(string $title): self
50 {
51 $this->title = $title;
52
53 return $this;
54 }
55
56
57 public function getSlug(): ?string
58 {
59 return $this->slug;
60 }
61
62 public function setSlug(string $slug): self
63 {
64 $this->slug = $slug;
65
66 return $this;
67 }
68
69 public function getContent(): ?string
70 {
71 return $this->content;
72 }
73
74 public function setContent(?string $content): self
75 {
76 $this->content = $content;
77
78 return $this;
79 }
80
81 public function getPublishedAt(): ?\DateTimeInterface
82 {
83 return $this->publishedAt;
84 }
85
86 public function setPublishedAt(?\DateTimeInterface $publishedAt): self
87 {
88 $this->publishedAt = $publishedAt;
89
90 return $this;
91 }
92 }
Well... to be fully honest, there is also a new ArticleRepository class, but I want you to ignore that for now. It's
not important yet.
Anyways, this Article class is your entity. And, check it out! It's a normal, boring PHP class with a property for
each column: id , title , slug , content , and publishedAt :
93 lines src/Entity/Article.php
... lines 1 - 9
10 class Article
11 {
... lines 12 - 16
17 private $id;
... lines 18 - 21
22 private $title;
... lines 23 - 26
27 private $slug;
... lines 28 - 31
32 private $content;
... lines 33 - 36
37 private $publishedAt;
... lines 38 - 91
92 }
What makes this class special are the annotations! The @ORM\Entity above the class tells Doctrine that this is
an entity that should be mapped to the database:
93 lines src/Entity/Article.php
... lines 1 - 4
5 use Doctrine\ORM\Mapping as ORM;
6
7 /**
8 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
9 */
10 class Article
11 {
... lines 12 - 91
92 }
Then, above each property, we have some annotations that help doctrine know how to store that exact
column:
93 lines src/Entity/Article.php
... lines 1 - 4
5 use Doctrine\ORM\Mapping as ORM;
6
7 /**
8 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
9 */
10 class Article
11 {
12 /**
13 * @ORM\Id()
14 * @ORM\GeneratedValue()
15 * @ORM\Column(type="integer")
16 */
17 private $id;
18
19 /**
20 * @ORM\Column(type="string", length=255)
21 */
22 private $title;
23
24 /**
25 * @ORM\Column(type="string", length=100)
26 */
27 private $slug;
28
29 /**
30 * @ORM\Column(type="text", nullable=true)
31 */
32 private $content;
33
34 /**
35 * @ORM\Column(type="datetime", nullable=true)
36 */
37 private $publishedAt;
... lines 38 - 91
92 }
Actually, find your browser and Google for "doctrine annotations reference" to find a cool page. This shows you
every annotation in Doctrine and every option for each one.
Back at the code, the properties are private . So, at the bottom of the class, the command generated getter and
setter methods for each one:
93 lines src/Entity/Article.php
... lines 1 - 9
10 class Article
11 {
... lines 12 - 38
39 public function getId()
40 {
41 return $this->id;
42 }
43
44 public function getTitle(): ?string
45 {
46 return $this->title;
47 }
47 }
48
49 public function setTitle(string $title): self
50 {
51 $this->title = $title;
52
53 return $this;
54 }
55
56
57 public function getSlug(): ?string
58 {
59 return $this->slug;
60 }
61
62 public function setSlug(string $slug): self
63 {
64 $this->slug = $slug;
65
66 return $this;
67 }
68
69 public function getContent(): ?string
70 {
71 return $this->content;
72 }
73
74 public function setContent(?string $content): self
75 {
76 $this->content = $content;
77
78 return $this;
79 }
80
81 public function getPublishedAt(): ?\DateTimeInterface
82 {
83 return $this->publishedAt;
84 }
85
86 public function setPublishedAt(?\DateTimeInterface $publishedAt): self
87 {
88 $this->publishedAt = $publishedAt;
89
90 return $this;
91 }
92 }
There's one really important thing to realize: this class is 100% your class. Feel free to add, remove or rename
any properties or methods you want.
And... yea! With one command, our entity is ready! But, the database is still empty! We need to tell Doctrine to
create the corresponding article table in the database. We do this with migrations.
Chapter 3: Database Migrations
The Article entity is ready, and Doctrine already knows to save its data to an article table in the database.
But... that table doesn't exist yet! So... how can we create it?
Generating a Migration
Ah, this is one of Doctrine's superpowers. Go back to your terminal. At the bottom of the make:entity command,
it has a suggestion: run the make:migration command.
I love this! Try it:
The output says that it created a new src/Migrations/Version* class that we should review. Ok, find your code,
open the Migrations directory and, there it is! One migration file:
29 lines src/Migrations/Version20180413174059.php
... lines 1 - 2
3 namespace DoctrineMigrations;
4
5 use Doctrine\DBAL\Migrations\AbstractMigration;
6 use Doctrine\DBAL\Schema\Schema;
7
8 /**
9 * Auto-generated Migration: Please modify to your needs!
10 */
11 class Version20180413174059 extends AbstractMigration
12 {
13 public function up(Schema $schema)
14 {
15 // this up() migration is auto-generated, please modify it to your needs
16 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
17
18 $this->addSql('CREATE TABLE article (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(100
19 }
20
21 public function down(Schema $schema)
22 {
23 // this down() migration is auto-generated, please modify it to your needs
24 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
25
26 $this->addSql('DROP TABLE article');
27 }
28 }
This is amazing. No, seriously - it's way more awesome than you might think. The make:migration command
actually looked at our database, looked at all of our entity classes - which is just one entity right now - and
generated the SQL needed to update the database to match our entities. I'll show you an even better example
in a few minutes.
This command was also suggested above. Answer yes to run the migrations and... done!
But now, run that same command again:
Ok, this tells us a bit more about how the migration system works. Inside the database, the migration system
automatically creates a new table called migration_versions . Then, the first time we ran
doctrine:migrations:migrate , it executed the migration, and inserted a new row in that table with that migration's
version number, which is the date in the class name. When we ran doctrine:migrations:migrate a second time, it
opened the migration class, then looked up that version in the migration_versions table. Because it was already
there, it knew that this migration had already been executed and did not try to run it again.
This is brilliant! Whenever we need to make a database change, we follow this simple two-step process: (1)
Generate the migration with make:migration and (2) run that migration with doctrine:migrations:migrate . We will
commit the migrations to our git repository. Then, on deploy, just make sure to run doctrine:migrations:migrate .
The production database will have its own migration_versions table, so this will automatically run all migrations
that have not been run yet on production. It's perfect.
93 lines src/Entity/Article.php
... lines 1 - 6
7 /**
8 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
9 */
10 class Article
11 {
... lines 12 - 23
24 /**
25 * @ORM\Column(type="string", length=100)
26 */
27 private $slug;
... lines 28 - 91
92 }
This will eventually be used to identify the article in the URL. And so, this must be unique across every article in
the table.
To guarantee that this is unique in the database, add unique=true :
93 lines src/Entity/Article.php
... lines 1 - 6
7 /**
8 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
9 */
10 class Article
11 {
... lines 12 - 23
24 /**
25 * @ORM\Column(type="string", length=100, unique=true)
26 */
27 private $slug;
... lines 28 - 91
92 }
This option does only one thing: it tells Doctrine that it should create a unique index in the database for this
column.
But of course, the database didn't just magically update to have this index. We need a migration. No problem!
Find your terminal and do step 1: run:
Ha! I even misspelled the command: Symfony figured out what I meant. This created a second migration class:
the first creates the table and the second... awesome! It creates the unique index:
29 lines src/Migrations/Version20180413174154.php
... lines 1 - 2
3 namespace DoctrineMigrations;
4
5 use Doctrine\DBAL\Migrations\AbstractMigration;
6 use Doctrine\DBAL\Schema\Schema;
7
8 /**
9 * Auto-generated Migration: Please modify to your needs!
10 */
11 class Version20180413174154 extends AbstractMigration
12 {
13 public function up(Schema $schema)
14 {
15 // this up() migration is auto-generated, please modify it to your needs
16 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
17
18 $this->addSql('CREATE UNIQUE INDEX UNIQ_23A0E66989D9B62 ON article (slug)');
19 }
20
21 public function down(Schema $schema)
22 {
23 // this down() migration is auto-generated, please modify it to your needs
24 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
25
26 $this->addSql('DROP INDEX UNIQ_23A0E66989D9B62 ON article');
27 }
28 }
This is the Doctrine magic I mentioned earlier: the make:migration command looked at the entity, looked at the
database, determined the difference between the two, then generated the SQL necessary to update the
database.
Now, for step (2), run:
It sees the two migration classes, notices that the first has already been executed, and only runs the second.
Ok! Our database is setup, our Article entity is ready, and we already have a killer migration system. So let's
talk about how to save articles to the table.
Chapter 4: Saving Entities
Put on your publishing hat, because it's time to write some thoughtful space articles and insert some rows into
our article table! And, good news! This is probably one of the easiest things to do in Doctrine.
Let's create a new controller called ArticleAdminController . We'll use this as a place to add new articles. Make it
extend the normal AbstractController :
19 lines src/Controller/ArticleAdminController.php
... lines 1 - 2
3 namespace App\Controller;
4
5 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
... lines 6 - 8
9 class ArticleAdminController extends AbstractController
10 {
... lines 11 - 17
18 }
19 lines src/Controller/ArticleAdminController.php
... lines 1 - 8
9 class ArticleAdminController extends AbstractController
10 {
... lines 11 - 13
14 public function new()
15 {
... line 16
17 }
18 }
Above, add the @Route() - make sure to auto-complete the one from Symfony Components so that PhpStorm
adds the use statement. For the URL, how about /admin/article/new :
19 lines src/Controller/ArticleAdminController.php
... lines 1 - 6
7 use Symfony\Component\Routing\Annotation\Route;
8
9 class ArticleAdminController extends AbstractController
10 {
11 /**
12 * @Route("/admin/article/new")
13 */
14 public function new()
15 {
... line 16
17 }
18 }
We're not actually going to build a real page with a form here right now. Instead, I just want to write some code
that saves a dummy article to the database.
But first, to make sure I haven't screwed anything up, return a new Response : the one from HttpFoundation with
a message:
Now, we should be able to find the browser and head to /admin/article/new . Great!
48 lines src/Controller/ArticleAdminController.php
... lines 1 - 4
5 use App\Entity\Article;
... lines 6 - 9
10 class ArticleAdminController extends AbstractController
11 {
... lines 12 - 14
15 public function new()
16 {
17 $article = new Article();
... lines 18 - 45
46 }
47 }
For this article's data, go back to the "Why Asteroids Taste like Bacon" article: we'll use this as our dummy
news story. Copy the article's title, then call $article->setTitle() and paste:
48 lines src/Controller/ArticleAdminController.php
... lines 1 - 9
10 class ArticleAdminController extends AbstractController
11 {
... lines 12 - 14
15 public function new()
16 {
17 $article = new Article();
18 $article->setTitle('Why Asteroids Taste Like Bacon')
... lines 19 - 45
46 }
47 }
This is one of the setter methods that was automatically generated into our entity:
93 lines src/Entity/Article.php
... lines 1 - 9
10 class Article
11 {
... lines 12 - 18
19 /**
20 * @ORM\Column(type="string", length=255)
21 */
22 private $title;
... lines 23 - 48
49 public function setTitle(string $title): self
50 {
51 $this->title = $title;
52
53 return $this;
54 }
... lines 55 - 91
92 }
Oh, and the generator also made all the setter methods return $this , which means you can chain your calls,
like: ->setSlug() , then copy the last part of the URL, and paste here. Oh, but we need to make sure this is
unique... so just add a little random number at the end:
48 lines src/Controller/ArticleAdminController.php
... lines 1 - 9
10 class ArticleAdminController extends AbstractController
11 {
... lines 12 - 14
15 public function new()
16 {
17 $article = new Article();
18 $article->setTitle('Why Asteroids Taste Like Bacon')
19 ->setSlug('why-asteroids-taste-like-bacon-'.rand(100, 999))
... lines 20 - 45
46 }
47 }
Then, ->setContent() . And to get this, go back to ArticleController , copy all of that meaty markdown and paste
here. Ah, make sure the content is completely not indented so the multi-line text works:
48 lines src/Controller/ArticleAdminController.php
... lines 1 - 9
10 class ArticleAdminController extends AbstractController
11 {
... lines 12 - 14
15 public function new()
16 {
17 $article = new Article();
18 $article->setTitle('Why Asteroids Taste Like Bacon')
19 ->setSlug('why-asteroids-taste-like-bacon-'.rand(100, 999))
20 ->setContent(<<<EOF
21 Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
22 lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
23 labore minim pork belly spare ribs cupim short loin in. Elit exercitation eiusmod dolore cow
24 **turkey** shank eu pork belly meatball non cupim.
25
26 Laboris beef ribs fatback fugiat eiusmod jowl kielbasa alcatra dolore velit ea ball tip. Pariatur
27 laboris sunt venison, et laborum dolore minim non meatball. Shankle eu flank aliqua shoulder,
28 capicola biltong frankfurter boudin cupim officia. Exercitation fugiat consectetur ham. Adipisicing
29 picanha shank et filet mignon pork belly ut ullamco. Irure velit turducken ground round doner incididunt
30 occaecat lorem meatball prosciutto quis strip steak.
31
32 Meatball adipisicing ribeye bacon strip steak eu. Consectetur ham hock pork hamburger enim strip steak
33 mollit quis officia meatloaf tri-tip swine. Cow ut reprehenderit, buffalo incididunt in filet mignon
34 strip steak pork belly aliquip capicola officia. Labore deserunt esse chicken lorem shoulder tail consectetur
35 cow est ribeye adipisicing. Pig hamburger pork belly enim. Do porchetta minim capicola irure pancetta chuck
36 fugiat.
37 EOF
38 );
... lines 39 - 45
46 }
47 }
Much better! The last field is publishedAt . To have more interesting data, let's only publish some articles. So, if
a random number between 1 to 10 is greater than 2, publish the article: $article->setPublishedAt() with
new \DateTime() and sprintf('-%d days') with a bit more randomness: 1 to 100 days old:
48 lines src/Controller/ArticleAdminController.php
... lines 1 - 9
10 class ArticleAdminController extends AbstractController
11 {
... lines 12 - 14
15 public function new()
16 {
17 $article = new Article();
18 $article->setTitle('Why Asteroids Taste Like Bacon')
19 ->setSlug('why-asteroids-taste-like-bacon-'.rand(100, 999))
20 ->setContent(<<<EOF
21 Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
22 lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
23 labore minim pork belly spare ribs cupim short loin in. Elit exercitation eiusmod dolore cow
24 **turkey** shank eu pork belly meatball non cupim.
25
26 Laboris beef ribs fatback fugiat eiusmod jowl kielbasa alcatra dolore velit ea ball tip. Pariatur
27 laboris sunt venison, et laborum dolore minim non meatball. Shankle eu flank aliqua shoulder,
28 capicola biltong frankfurter boudin cupim officia. Exercitation fugiat consectetur ham. Adipisicing
29 picanha shank et filet mignon pork belly ut ullamco. Irure velit turducken ground round doner incididunt
30 occaecat lorem meatball prosciutto quis strip steak.
31
32 Meatball adipisicing ribeye bacon strip steak eu. Consectetur ham hock pork hamburger enim strip steak
33 mollit quis officia meatloaf tri-tip swine. Cow ut reprehenderit, buffalo incididunt in filet mignon
34 strip steak pork belly aliquip capicola officia. Labore deserunt esse chicken lorem shoulder tail consectetur
35 cow est ribeye adipisicing. Pig hamburger pork belly enim. Do porchetta minim capicola irure pancetta chuck
36 fugiat.
37 EOF
38 );
39
40 // publish most articles
41 if (rand(1, 10) > 2) {
42 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
43 }
44
45 return new Response('space rocks... include comets, asteroids & meteoroids');
46 }
47 }
Perfect! Now... stop. I want you to notice that all we've done is create an Article object and set data on it. This
is normal, boring, PHP code: we're not using Doctrine at all yet. That's really cool.
Hey Doctrine! Say hi to Jon Wage for us! Also, can you please save this article to the database. You're the
best!
How do we do this? In the last Symfony tutorial, we talked about how the main thing that a bundle gives us is
more services. DoctrineBundle gives us one, very important service that's used for both saving to and fetching
from the database. It's called the DeathStar. No, no, it's the EntityManager. But, missed opportunity...
Find your terminal and run:
56 lines src/Controller/ArticleAdminController.php
... lines 1 - 5
6 use Doctrine\ORM\EntityManagerInterface;
... lines 7 - 10
11 class ArticleAdminController extends AbstractController
12 {
... lines 13 - 15
16 public function new(EntityManagerInterface $em)
17 {
... lines 18 - 53
54 }
55 }
Now that we have the all-important entity manager, saving is a two-step process... and it may look a bit weird
initially. First, $em->persist($article) , then $em->flush() :
56 lines src/Controller/ArticleAdminController.php
... lines 1 - 5
6 use Doctrine\ORM\EntityManagerInterface;
... lines 7 - 10
11 class ArticleAdminController extends AbstractController
12 {
... lines 13 - 15
16 public function new(EntityManagerInterface $em)
17 {
... lines 18 - 40
41 // publish most articles
42 if (rand(1, 10) > 2) {
43 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
44 }
45
46 $em->persist($article);
47 $em->flush();
... lines 48 - 53
54 }
55 }
It's always these two lines. Persist simply says that you would like to save this article, but Doctrine does not
make the INSERT query yet. That happens when you call $em->flush() . Why two separate steps? Well, it gives
you a bit more flexibility: you could create ten Article objects, called persist() on each, then flush() just one
time at the end. This helps Doctrine optimize saving those ten articles.
At the bottom, let's make our message a bit more helpful, though, I thought my message about space rocks
was at least educational. Set the article id to some number and the slug to some string. Pass: $article->getId()
and $article->getSlug() :
56 lines src/Controller/ArticleAdminController.php
... lines 1 - 5
6 use Doctrine\ORM\EntityManagerInterface;
... lines 7 - 10
11 class ArticleAdminController extends AbstractController
12 {
... lines 13 - 15
16 public function new(EntityManagerInterface $em)
17 {
... lines 18 - 45
46 $em->persist($article);
47 $em->flush();
48
49 return new Response(sprintf(
50 'Hiya! New Article id: #%d slug: %s',
51 $article->getId(),
52 $article->getSlug()
53 ));
54 }
55 }
Oh, and this is important: we never set the id. But when we call flush() , Doctrine will insert the new row, get
the new id, and put that onto the Article for us. By the time we print this message, the Article will have its new,
fancy id.
Ok, are you ready? Let's try it: go back to /admin/article/new and... ha! Article id 1, then 2, 3, 4, 5, 6! Our news
site is alive!
If you want to be more sure, you can check this in your favorite database tool like phpMyAdmin or whatever the
cool kids are using these days. Or, you can use a helpful console command:
This is article with a lowercase "a", because, thanks to the default configuration, Doctrine creates snake case
table and column names.
And... yes! There are the new, 6 results.
We have successfully put stuff into the database! Now it's time to run some queries to fetch it back out.
Chapter 5: Querying for Data!
Hey! There are rows in our article table! So let's update the news page to not show this hard-coded article, but
instead to query the database and print real, dynamic data.
90 lines src/Controller/ArticleController.php
... lines 1 - 13
14 class ArticleController extends AbstractController
15 {
... lines 16 - 33
34 /**
35 * @Route("/news/{slug}", name="article_show")
36 */
37 public function show($slug, MarkdownHelper $markdownHelper, SlackClient $slack)
38 {
39 if ($slug === 'khaaaaaan') {
40 $slack->sendMessage('Kahn', 'Ah, Kirk, my old friend...');
41 }
42
43 $comments = [
44 'I ate a normal rock once. It did NOT taste like bacon!',
45 'Woohoo! I\'m going on an all-asteroid diet!',
46 'I like bacon too! Buy some from my site! bakinsomebacon.com',
47 ];
48
49 $articleContent = <<<EOF
50 Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
51 lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
52 labore minim pork belly spare ribs cupim short loin in. Elit exercitation eiusmod dolore cow
53 **turkey** shank eu pork belly meatball non cupim.
54
55 Laboris beef ribs fatback fugiat eiusmod jowl kielbasa alcatra dolore velit ea ball tip. Pariatur
56 laboris sunt venison, et laborum dolore minim non meatball. Shankle eu flank aliqua shoulder,
57 capicola biltong frankfurter boudin cupim officia. Exercitation fugiat consectetur ham. Adipisicing
58 picanha shank et filet mignon pork belly ut ullamco. Irure velit turducken ground round doner incididunt
59 occaecat lorem meatball prosciutto quis strip steak.
60
61 Meatball adipisicing ribeye bacon strip steak eu. Consectetur ham hock pork hamburger enim strip steak
62 mollit quis officia meatloaf tri-tip swine. Cow ut reprehenderit, buffalo incididunt in filet mignon
63 strip steak pork belly aliquip capicola officia. Labore deserunt esse chicken lorem shoulder tail consectetur
64 cow est ribeye adipisicing. Pig hamburger pork belly enim. Do porchetta minim capicola irure pancetta chuck
65 fugiat.
66 EOF;
67
68 $articleContent = $markdownHelper->parse($articleContent);
69
70 return $this->render('article/show.html.twig', [
71 'title' => ucwords(str_replace('-', ' ', $slug)),
72 'slug' => $slug,
73 'comments' => $comments,
74 'articleContent' => $articleContent,
75 ]);
76 }
... lines 77 - 88
89 }
This renders that page. As I mentioned earlier, DoctrineBundle gives us one service - the EntityManager - that
has the power to save and fetch data. Let's get it here: add another argument: EntityManagerInterface $em :
When you want to query for data, the first step is always the same: we need to get the repository for the entity:
$repository = $em->getRepository() and then pass the entity class name: Article::class :
This repository object knows everything about how to query from the article table. We can use it to say
$article = $repository-> . Oh, nice! It has some built-in methods, like find() where you can pass the $id to fetch
a single article. Or, findAll() to fetch all articles. With the findBy() method, you can fetch all articles where a
field matches some value. And findOneBy() is the same, but only returns one Article. Let's use that:
->findOneBy() and pass it an array with 'slug' => $slug :
This will fetch one row where the slug field matches this value. These built-in find methods are nice... but they
can't do much more than this. But, don't worry! We will of course learn how to write custom queries soon.
Above this line, just to help my editor, I'll tell it that this is an Article object:
And... hold on, that's important! When you query for something, Doctrine returns objects, not just an
associative arrays with data. That's really the whole point of Doctrine! You need to stop thinking about
inserting and selecting rows in a database. Instead, think about saving and fetching objects... almost as if you
didn't know that a database was behind-the-scenes.
Handling 404's
At this point, it's possible that there is no article in the database with this slug. In that case, $article will be
null . How should we handle that? Well, in the real world, this should trigger a 404 page. To do that, say if
!$article , then, throw $this->createNotFoundException() . Pass a descriptive message, like: No article for slug "%s"
and pass $slug :
I want to dig a little bit deeper to see how this work. Hold Command on a Mac - or Ctrl otherwise - and click this
method. Ah, it comes from a trait that's used by the base AbstractController . Fascinating! It just throws an
exception!
In Symfony, to trigger a 404, you just need to throw this very special exception class. That's why, in the
controller, we throw $this->createNotFoundException() . The message can be as descriptive as possible because it
will only be shown to you: the developer.
After all of this, let's dump() the $article to see what it looks like and die :
Head back to your browser and first, refresh. Ok! This is the 404 page: there's nothing in the database that
matches this slug: all the real slugs have a random number at the end. We see the helpful error message
because this is what the 404 page looks like for developers. But of course, when you switch into the prod
environment, your users will see a different page that you can customize.
We're not going to talk about how to customize error pages... because it's super friendly and easy. Just Google
for "Symfony customize error pages" and... have fun! You can create separate pages for 404 errors, 403 errors,
500 errors, or whatever your heart desires.
To find a real slug, go back to /admin/article/new . Copy that slug, go back, paste it and... it works! There is our
full, beautiful, well-written, inspiring, Article object... with fake content about meat. Having an object is
awesome! We are now... dangerous.
Rendering the Article Data: Twig Magic
Back in the controller, remove the dump() :
76 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 38
39 public function show($slug, SlackClient $slack, EntityManagerInterface $em)
40 {
... lines 41 - 44
45 $repository = $em->getRepository(Article::class);
46 /** @var Article $article */
47 $article = $repository->findOneBy(['slug' => $slug]);
48 if (!$article) {
49 throw $this->createNotFoundException(sprintf('No article for slug "%s"', $slug));
50 }
... lines 51 - 61
62 }
... lines 63 - 74
75 }
Keep the hardcoded comments for now. But, remove the $articleContent :
76 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 38
39 public function show($slug, SlackClient $slack, EntityManagerInterface $em)
40 {
... lines 41 - 44
45 $repository = $em->getRepository(Article::class);
46 /** @var Article $article */
47 $article = $repository->findOneBy(['slug' => $slug]);
48 if (!$article) {
49 throw $this->createNotFoundException(sprintf('No article for slug "%s"', $slug));
50 }
51
52 $comments = [
53 'I ate a normal rock once. It did NOT taste like bacon!',
54 'Woohoo! I\'m going on an all-asteroid diet!',
55 'I like bacon too! Buy some from my site! bakinsomebacon.com',
56 ];
57
58 return $this->render('article/show.html.twig', [
... lines 59 - 60
61 ]);
62 }
... lines 63 - 74
75 }
Let's also remove the markdown parsing code and the now-unused argument:
76 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 38
39 public function show($slug, SlackClient $slack, EntityManagerInterface $em)
40 {
... lines 41 - 61
62 }
... lines 63 - 74
75 }
We'll process the markdown in the template in a minute: Back down at render() , instead of passing title ,
articleContent and slug , just pass article :
76 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 38
39 public function show($slug, SlackClient $slack, EntityManagerInterface $em)
40 {
... lines 41 - 57
58 return $this->render('article/show.html.twig', [
59 'article' => $article,
60 'comments' => $comments,
61 ]);
62 }
... lines 63 - 74
75 }
Now, open that template! With the Symfony plugin, you can cheat and hold Command or Ctrl and click to open
it. Or, it's just in templates/article .
Updating the template is a dream. Instead of title , print article.title :
83 lines templates/article/show.html.twig
1 {% extends 'base.html.twig' %}
2
3 {% block title %}Read: {{ article.title }}{% endblock %}
... lines 4 - 83
Oh, and in many cases... but not always... you'll get auto-completion based on the methods on your entity
class!
But look closely: it's auto-completing getTitle() . But when I hit tab, it just prints article.title . Behind the scenes,
there is some serious Twig magic happening. When you say article.title , Twig first looks to see if the class has a
title property:
93 lines src/Entity/Article.php
... lines 1 - 9
10 class Article
11 {
... lines 12 - 21
22 private $title;
... lines 23 - 91
92 }
It does! But since that property is private , it can't use it. No worries! It then looks for a getTitle() method. And
because that exists:
93 lines src/Entity/Article.php
... lines 1 - 9
10 class Article
11 {
... lines 12 - 21
22 private $title;
... lines 23 - 43
44 public function getTitle(): ?string
45 {
46 return $this->title;
47 }
... lines 48 - 91
92 }
83 lines templates/article/show.html.twig
1 {% extends 'base.html.twig' %}
2
3 {% block title %}Read: {{ article.title }}{% endblock %}
4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
... line 13
14 <div class="show-article-title-container d-inline-block pl-3 align-middle">
15 <span class="show-article-title ">{{ article.title }}</span>
... lines 16 - 18
19 <span class="pl-2 article-details">
... line 20
21 <a href="{{ path('article_toggle_heart', {slug: article.slug}) }}" class="fa fa-heart-o like-article js-like-article"
22 </span>
23 </div>
24 </div>
25 </div>
26 <div class="row">
27 <div class="col-sm-12">
28 <div class="article-text">
29 {{ article.content|markdown }}
30 </div>
31 </div>
32 </div>
... lines 33 - 71
72 </div>
73 </div>
74 </div>
75 </div>
76
77 {% endblock %}
... lines 78 - 83
The KnpMarkdownBundle gives us a markdown filter, so that we can just process it right here in the template.
Ready to try it? Move over, deep breath, refresh. Yes! It works! Hello dynamic title! Hello dynamic bacon
content!
So, um, yea. This is awesome. Next, let's take a quick detour and have some fun by creating a custom Twig
filter with a Twig extension. We need to do this, because our markdown processing is no longer being cached.
Boo.
Chapter 6: Fun with Twig Extensions!
Head back to the article show page because... there's a little, bitty problem that I just introduced. Using the
markdown filter from KnpMarkdownBundle works... but the process is not being cached anymore. In the
previous tutorial, we created a cool MarkdownHelper that used the markdown object from KnpMarkdownBundle,
but added caching so that we don't need to re-parse the same markdown content over and over again:
44 lines src/Service/MarkdownHelper.php
... lines 1 - 2
3 namespace App\Service;
4
5 use Michelf\MarkdownInterface;
6 use Psr\Log\LoggerInterface;
7 use Symfony\Component\Cache\Adapter\AdapterInterface;
8
9 class MarkdownHelper
10 {
11 private $cache;
12 private $markdown;
13 private $logger;
14 private $isDebug;
15
16 public function __construct(AdapterInterface $cache, MarkdownInterface $markdown, LoggerInterface $markdownLogger, bool
17 {
18 $this->cache = $cache;
19 $this->markdown = $markdown;
20 $this->logger = $markdownLogger;
21 $this->isDebug = $isDebug;
22 }
23
24 public function parse(string $source): string
25 {
26 if (stripos($source, 'bacon') !== false) {
27 $this->logger->info('They are talking about bacon again!');
28 }
29
30 // skip caching entirely in debug
31 if ($this->isDebug) {
32 return $this->markdown->transform($source);
33 }
34
35 $item = $this->cache->getItem('markdown_'.md5($source));
36 if (!$item->isHit()) {
37 $item->set($this->markdown->transform($source));
38 $this->cache->save($item);
39 }
40
41 return $item->get();
42 }
43 }
Basically, we want to be able to use a markdown filter in Twig, but we want it to use our MarkdownHelper
service, instead of the uncached service from the bundle.
So... how can we do this? Let's create our own Twig filter, and make it do exactly what we want. We'll call it,
cached_markdown .
It suggests the name AppExtension , which I'm actually going to use. I'll call it AppExtension because I typically
create just one extension class that will hold all of the custom Twig functions and filters that I need for my
entire project. I do this instead of having multiple Twig extensions... because it's easier.
30 lines src/Twig/AppExtension.php
... lines 1 - 2
3 namespace App\Twig;
4
5 use Twig\Extension\AbstractExtension;
6 use Twig\TwigFilter;
7 use Twig\TwigFunction;
8
9 class AppExtension extends AbstractExtension
10 {
11 public function getFilters(): array
12 {
13 return [
14 new TwigFilter('filter_name', [$this, 'doSomething'], ['is_safe' => ['html']]),
15 ];
16 }
17
18 public function getFunctions(): array
19 {
20 return [
21 new TwigFunction('function_name', [$this, 'doSomething']),
22 ];
23 }
24
25 public function doSomething($value)
26 {
27 // ...
28 }
29 }
Hello Twig extension! It's a normal PHP class that extends a base class, then specifies any custom functions or
filters in these two methods:
30 lines src/Twig/AppExtension.php
... lines 1 - 8
9 class AppExtension extends AbstractExtension
10 {
11 public function getFilters(): array
12 {
... lines 13 - 15
16 }
17
18 public function getFunctions(): array
19 {
... lines 20 - 22
23 }
... lines 24 - 28
29 }
Twig Extensions can add other stuff too, like custom operators or tests.
We need a custom filter, so delete getFunctions() and then change the filter name to cached_markdown . Over on
the right, this is the method that will be called when the user uses the filter. Let's call our method
processMarkdown . Point to that from the filter:
23 lines src/Twig/AppExtension.php
... lines 1 - 5
6 use Twig\TwigFilter;
... lines 7 - 8
9 class AppExtension extends AbstractExtension
10 {
11 public function getFilters(): array
12 {
13 return [
14 new TwigFilter('cached_markdown', [$this, 'processMarkdown'], ['is_safe' => ['html']]),
15 ];
16 }
17
18 public function processMarkdown($value)
19 {
... line 20
21 }
22 }
To make sure things are working, for now, in processMarkdown() , just return strtoupper($value) :
23 lines src/Twig/AppExtension.php
... lines 1 - 8
9 class AppExtension extends AbstractExtension
10 {
... lines 11 - 17
18 public function processMarkdown($value)
19 {
20 return strtoupper($value);
21 }
22 }
83 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
... lines 11 - 25
26 <div class="row">
27 <div class="col-sm-12">
28 <div class="article-text">
29 {{ article.content|cached_markdown }}
30 </div>
31 </div>
32 </div>
... lines 33 - 71
72 </div>
73 </div>
74 </div>
75 </div>
76
77 {% endblock %}
... lines 78 - 83
Oh, and two important things. One, when you use a filter, the value to the left of the filter will become the first
argument to your filter function. So, $value will be the article content in this case:
23 lines src/Twig/AppExtension.php
... lines 1 - 8
9 class AppExtension extends AbstractExtension
10 {
... lines 11 - 17
18 public function processMarkdown($value)
19 {
... line 20
21 }
22 }
Second, check out this options array when we added the filter. This is optional. But when you say is_safe set to
html :
23 lines src/Twig/AppExtension.php
... lines 1 - 8
9 class AppExtension extends AbstractExtension
10 {
11 public function getFilters(): array
12 {
13 return [
14 new TwigFilter('cached_markdown', [$this, 'processMarkdown'], ['is_safe' => ['html']]),
15 ];
16 }
... lines 17 - 21
22 }
It tells Twig that the result of this filter should not be escaped through htmlentities() . And... that's perfect!
Markdown gives HTML code, and so we definitely do not want that to be escaped. You won't need this option
on most filters, but we do want it here.
And... yea. We're done! Thanks to Symfony's autoconfiguration system, our Twig extension should already be
registered with the Twig. So, find your browser, high-five your dog or cat, and refresh!
It works! I mean, it's super ugly and angry-looking... but it works!
31 lines src/Twig/AppExtension.php
... lines 1 - 4
5 use App\Service\MarkdownHelper;
... lines 6 - 9
10 class AppExtension extends AbstractExtension
11 {
... lines 12 - 13
14 public function __construct(MarkdownHelper $markdownHelper)
15 {
... line 16
17 }
... lines 18 - 29
30 }
Then, I'll press Alt + Enter and select "Initialize fields" so that PhpStorm creates that $helper property and sets
it:
31 lines src/Twig/AppExtension.php
... lines 1 - 4
5 use App\Service\MarkdownHelper;
... lines 6 - 9
10 class AppExtension extends AbstractExtension
11 {
12 private $markdownHelper;
13
14 public function __construct(MarkdownHelper $markdownHelper)
15 {
16 $this->markdownHelper = $markdownHelper;
17 }
... lines 18 - 29
30 }
Down below, celebrate! Just return $this->helper->parse() and pass it the $value :
31 lines src/Twig/AppExtension.php
... lines 1 - 4
5 use App\Service\MarkdownHelper;
... lines 6 - 9
10 class AppExtension extends AbstractExtension
11 {
... lines 12 - 25
26 public function processMarkdown($value)
27 {
28 return $this->markdownHelper->parse($value);
29 }
30 }
That's it! Go back, refresh and... brilliant! We once again have markdown, but now it's being cached.
Chapter 7: ago Filter with KnpTimeBundle
Ok, I just need to show you something fun - it deals with Twig filters. See this 4 hours ago? That's still hard
coded! Find the show template and scroll up a bit to find it:
83 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
... line 13
14 <div class="show-article-title-container d-inline-block pl-3 align-middle">
... lines 15 - 17
18 <span class="pl-2 article-details"> 4 hours ago</span>
... lines 19 - 22
23 </div>
24 </div>
25 </div>
... lines 26 - 71
72 </div>
73 </div>
74 </div>
75 </div>
76
77 {% endblock %}
... lines 78 - 83
There!
85 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
... line 13
14 <div class="show-article-title-container d-inline-block pl-3 align-middle">
... lines 15 - 17
18 <span class="pl-2 article-details">
19 {{ article.publishedAt ? article.publishedAt|date('Y-m-d') : 'unpublished' }}
20 </span>
... lines 21 - 24
25 </div>
26 </div>
27 </div>
... lines 28 - 73
74 </div>
75 </div>
76 </div>
77 </div>
78
79 {% endblock %}
... lines 80 - 85
Most filters do not have any arguments - most are like cached_markdown . But filters are allowed to have
arguments. If the article is not published, just say that: unpublished:
85 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
... line 13
14 <div class="show-article-title-container d-inline-block pl-3 align-middle">
... lines 15 - 17
18 <span class="pl-2 article-details">
19 {{ article.publishedAt ? article.publishedAt|date('Y-m-d') : 'unpublished' }}
20 </span>
... lines 21 - 24
25 </div>
26 </div>
27 </div>
... lines 28 - 73
74 </div>
75 </div>
76 </div>
77 </div>
78
79 {% endblock %}
... lines 80 - 85
Installing KnpTimeBundle
Cool... but it looked better when it said something like "five minutes ago" or "two weeks ago" - that was way
more hipster. The date... it's ugly!
Fortunately, there's a really simple bundle that can convert your dates into this cute "ago" format. Search for
KnpTimeBundle. Despite seeing my little face there, I did not create this bundle, so I take no credit for it. I just
think it's great.
Scroll down to the "composer require" line, copy that, find your terminal and, paste!
This installs the bundle and... interesting! It also installs symfony/translation . Behind the scenes, KnpTimeBundle
uses the translator to translate the "ago" wording into other languages.
But what's really cool is that symfony/translation has a Flex recipe. Before I recorded this chapter, I committed
our changes so far. So now I can run:
$ git status
to see what that sneaky translation recipe did. Interesting: we have a new config/packages/translation.yaml file
and a new translations/ directory where any translation files should live... if we need any.
At a high level, the recipe system, like always, is making sure that everything is setup for us, automatically.
85 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
... line 13
14 <div class="show-article-title-container d-inline-block pl-3 align-middle">
... lines 15 - 17
18 <span class="pl-2 article-details">
19 {{ article.publishedAt ? article.publishedAt|ago : 'unpublished' }}
20 </span>
... lines 21 - 24
25 </div>
26 </div>
27 </div>
... lines 28 - 73
74 </div>
75 </div>
76 </div>
77 </div>
78
79 {% endblock %}
... lines 80 - 85
That's it. Find the page, refresh and... perfect! 27 days ago. So much nicer!
Next, I want to talk a little bit more about the AppExtension Twig extension because, for a very subtle but
important reason, it has a performance problem.
Chapter 8: Service Subscriber: Lazy Performance
Our nice little Twig extension has a not-so-nice problem! And... it's subtle.
Normally, if you have a service like MarkdownHelper :
31 lines src/Twig/AppExtension.php
... lines 1 - 4
5 use App\Service\MarkdownHelper;
... lines 6 - 9
10 class AppExtension extends AbstractExtension
11 {
12 private $markdownHelper;
13
14 public function __construct(MarkdownHelper $markdownHelper)
15 {
16 $this->markdownHelper = $markdownHelper;
17 }
... lines 18 - 29
30 }
Symfony's container does not instantiate this service until and unless you actually use it during a request. For
example, if we try to use MarkdownHelper in a controller, the container will, of course, instantiate
MarkdownHelper and pass it to us.
But, in a different controller, if we don't use it, then that object will never be instantiated. And... that's perfect!
Instantiating objects that we don't need would be a performance killer!
42 lines src/Twig/AppExtension.php
... lines 1 - 6
7 use Symfony\Component\DependencyInjection\ServiceSubscriberInterface;
... lines 8 - 11
12 class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
13 {
... lines 14 - 40
41 }
This will force us to have one new method. At the bottom of the class, I'll go to the "Code"->"Generate" menu -
or Command + N on a Mac - and implement getSubscribedServices() . Return an array from this... but leave it
empty for now:
42 lines src/Twig/AppExtension.php
... lines 1 - 6
7 use Symfony\Component\DependencyInjection\ServiceSubscriberInterface;
... lines 8 - 11
12 class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
13 {
... lines 14 - 34
35 public static function getSubscribedServices()
36 {
37 return [
... line 38
39 ];
40 }
41 }
Next, up on your constructor, remove the first argument and replace it with ContainerInterface - the one from
Psr - $container :
42 lines src/Twig/AppExtension.php
... lines 1 - 5
6 use Psr\Container\ContainerInterface;
... lines 7 - 11
12 class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
13 {
... lines 14 - 15
16 public function __construct(ContainerInterface $container)
17 {
... line 18
19 }
... lines 20 - 40
41 }
42 lines src/Twig/AppExtension.php
... lines 1 - 5
6 use Psr\Container\ContainerInterface;
... lines 7 - 11
12 class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
13 {
14 private $container;
15
16 public function __construct(ContainerInterface $container)
17 {
18 $this->container = $container;
19 }
... lines 20 - 40
41 }
42 lines src/Twig/AppExtension.php
... lines 1 - 4
5 use App\Service\MarkdownHelper;
... lines 6 - 11
12 class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
13 {
... lines 14 - 34
35 public static function getSubscribedServices()
36 {
37 return [
38 MarkdownHelper::class,
39 ];
40 }
41 }
When we do this, Symfony will basically autowire that service into the mini container, and make it public so
that we can fetch it directly. In other words, down in processMarkdown() , we can use it with
$this->container->get(MarkdownHelper::class) and then ->parse($value) :
42 lines src/Twig/AppExtension.php
... lines 1 - 4
5 use App\Service\MarkdownHelper;
... lines 6 - 11
12 class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
13 {
... lines 14 - 27
28 public function processMarkdown($value)
29 {
30 return $this->container
31 ->get(MarkdownHelper::class)
32 ->parse($value);
33 }
... lines 34 - 40
41 }
At this point, this might feel like just a more complex version of dependency injection. And yea... it kinda is!
Instead of passing us the MarkdownHelper directly, Symfony is passing us a container that holds the
MarkdownHelper . But, the key difference is that, thanks to this trick, the MarkdownHelper service is not
instantiated until and unless we fetch it out of this container.
Understanding getSubscribedEvents()
Oh, and to hopefully make things a bit more clear, you can actually return a key-value pair from
getSubscribedEvents() , like 'foo' => MarkdownHelper::class :
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
// ...
public static function getSubscribedServices()
{
return [
'foo' => MarkdownHelper::class,
];
}
}
If we did this, it would still mean that the MarkdownHelper service is autowired into the mini-container, but we
would reference it internally with the id foo .
If you just pass MarkdownHelper::class as the value, then that's also used as the key.
The end result is exactly the same as before, except MarkdownHelper is lazy! To prove it, put a die statement at
the top of the MarkdownHelper constructor.
Now, go back to the article page and refresh. Not surprising: it hits the die statement when rendering the Twig
template. But now, go back to the homepage. Yes! The whole page prints: MarkdownHelper is never
instantiated.
Go back and remove that die statement.
Here's the super-duper-important takeaway: I want you to use normal dependency injection everywhere - just
pass each service you need through the constructor, without all this fancy service-subscriber stuff.
But then, in just a couple of places in Symfony, the main ones being Twig extensions, event subscribers and
security voters - a few topics we'll talk about in the future - you should consider using a service subscriber
instead to avoid a performance hit.
Chapter 9: All about Entity Repositories
With the article show page now dynamic, let's turn to the homepage... cause these news stories are totally still
hardcoded. Open ArticleController and find the homepage() action:
76 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 30
31 public function homepage()
32 {
33 return $this->render('article/homepage.html.twig');
34 }
... lines 35 - 74
75 }
Perfect. Just like before, we need to query for the articles. This means that we need an
EntityManagerInterface $em argument:
81 lines src/Controller/ArticleController.php
... lines 1 - 7
8 use Doctrine\ORM\EntityManagerInterface;
... lines 9 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 30
31 public function homepage(EntityManagerInterface $em)
32 {
... lines 33 - 38
39 }
... lines 40 - 79
80 }
Next, we get the repository for the class: $repository = $em->getRepository(Article::class) . And then we can say,
$articles = $repository->findAll() :
81 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 30
31 public function homepage(EntityManagerInterface $em)
32 {
33 $repository = $em->getRepository(Article::class);
34 $articles = $repository->findAll();
... lines 35 - 38
39 }
... lines 40 - 79
80 }
Nice! With this array of Article objects in hand, let's pass those into the template as a new articles variable:
81 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 30
31 public function homepage(EntityManagerInterface $em)
32 {
33 $repository = $em->getRepository(Article::class);
34 $articles = $repository->findAll();
35
36 return $this->render('article/homepage.html.twig', [
37 'articles' => $articles,
38 ]);
39 }
... lines 40 - 79
80 }
Now, to the template! Open homepage.html.twig and scroll down just a little bit. Yes: here is the article list:
81 lines templates/article/homepage.html.twig
... lines 1 - 2
3 {% block body %}
4 <div class="container">
5 <div class="row">
6
7 <!-- Article List -->
8
9 <div class="col-sm-12 col-md-8">
... lines 10 - 18
19 <!-- Supporting Articles -->
20
21 <div class="article-container my-1">
22 <a href="{{ path('article_show', {slug: 'why-asteroids-taste-like-bacon'}) }}">
23 <img class="article-img" src="{{ asset('images/asteroid.jpeg') }}">
24 <div class="article-title d-inline-block pl-3 align-middle">
25 <span>Why do Asteroids Taste Like Bacon?</span>
26 <br>
27 <span class="align-left article-details"><img class="article-author-img rounded-circle" src="{{ asset('images/a
28 <span class="pl-5 article-details float-right"> 3 hours ago</span>
29 </div>
30 </a>
31 </div>
... lines 32 - 56
57 </div>
... lines 58 - 77
78 </div>
79 </div>
80 {% endblock %}
Well, there's a "main" article on top, but I'm going to ignore that for now. Down below, add for article in articles
with, at the bottom, endfor :
58 lines templates/article/homepage.html.twig
... lines 1 - 2
3 {% block body %}
4 <div class="container">
5 <div class="row">
6
7 <!-- Article List -->
8
9 <div class="col-sm-12 col-md-8">
... lines 10 - 18
19 <!-- Supporting Articles -->
20
21 {% for article in articles %}
22 <div class="article-container my-1">
... lines 23 - 31
32 </div>
33 {% endfor %}
34 </div>
... lines 35 - 54
55 </div>
56 </div>
57 {% endblock %}
Then... just make things dynamic: article.slug , article.title , and for the three hours ago, if article.publishedAt is
not null, print article.publishedAt|ago . If it's not published, do nothing. With this in place, delete the last two
hardcoded articles:
58 lines templates/article/homepage.html.twig
... lines 1 - 2
3 {% block body %}
4 <div class="container">
5 <div class="row">
6
7 <!-- Article List -->
8
9 <div class="col-sm-12 col-md-8">
... lines 10 - 18
19 <!-- Supporting Articles -->
20
21 {% for article in articles %}
22 <div class="article-container my-1">
23 <a href="{{ path('article_show', {slug: article.slug}) }}">
24 <img class="article-img" src="{{ asset('images/asteroid.jpeg') }}">
25 <div class="article-title d-inline-block pl-3 align-middle">
26 <span>{{ article.title }}</span>
27 <br>
28 <span class="align-left article-details"><img class="article-author-img rounded-circle" src="{{ asset('images/a
29 <span class="pl-5 article-details float-right"> {{ article.publishedAt ? article.publishedAt|ago }}</span>
30 </div>
31 </a>
32 </div>
33 {% endfor %}
34 </div>
... lines 35 - 54
55 </div>
56 </div>
57 {% endblock %}
Controlling the ORDER BY
Let's give it a try: find your browser and, refresh! Nice! You can see a mixture of published and unpublished
articles. But... you can also see that the articles just printed out in whatever order they were created,
regardless of the publish date. Space travellers demand fresh content! So let's print the newest articles first.
Head back to ArticleController . Hmm... the findAll() methods gives us everything... but it's pretty limited. In fact,
it takes zero arguments: you can't control it at all:
81 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 30
31 public function homepage(EntityManagerInterface $em)
32 {
... line 33
34 $articles = $repository->findAll();
... lines 35 - 38
39 }
... lines 40 - 79
80 }
But, some of the other methods are just a little bit more flexible. To control the order, use findBy() instead,
pass this an empty array, and then another array with publishedAt set to DESC :
81 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 30
31 public function homepage(EntityManagerInterface $em)
32 {
... line 33
34 $articles = $repository->findBy([], ['publishedAt' => 'DESC']);
... lines 35 - 38
39 }
... lines 40 - 79
80 }
The first array is where you would normally pass some criteria for a WHERE clause. If we pass nothing, we get
everything!
Try it - refresh! Much better!
Hello ArticleRepository
Except... hmm... it probably does not make sense to show the unpublished articles on the homepage. And this
is when things get a bit more interesting. Sure, you can pass simple criteria to findBy() , like slug equal to some
value. But, in this case, we need a query that says WHERE publishedAt IS NOT NULL . That's just not possible with
findBy() !
And so... for the first time, we're going to write - drumroll - a custom query!
Let me show you something cool: when we originally generated our entity, the command created the Article
class, but it also created an ArticleRepository class in the Repository directory. Try this: dump($repository) and,
refresh. Guess what? This is an instance of that ArticleRepository !
Yes, there is a connection between the Article and ArticleRepository classes. In fact, that connection is explicitly
configured right at the top of your Article class:
93 lines src/Entity/Article.php
... lines 1 - 6
7 /**
8 * @ORM\Entity(repositoryClass="App\Repository\ArticleRepository")
9 */
10 class Article
11 {
... lines 12 - 91
92 }
This says: when we ask for the Article class's repository, Doctrine should give us an instance of this
ArticleRepository class:
51 lines src/Repository/ArticleRepository.php
... lines 1 - 5
6 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
... lines 7 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 49
50 }
Oh, and the built-in find*() methods actually come from one of the parent classes of ArticleRepository .
So... why the heck are we talking about this? Because, if you want to create a custom query, you can do that
by creating a custom method inside of this class. And, hey! It even has a couple of examples:
51 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 21
22 // /**
23 // * @return Article[] Returns an array of Article objects
24 // */
25 /*
26 public function findByExampleField($value)
27 {
28 return $this->createQueryBuilder('a')
29 ->andWhere('a.exampleField = :val')
30 ->setParameter('val', $value)
31 ->orderBy('a.id', 'ASC')
32 ->setMaxResults(10)
33 ->getQuery()
34 ->getResult()
35 ;
36 }
37 */
38
39 /*
40 public function findOneBySomeField($value): ?Article
41 {
42 return $this->createQueryBuilder('a')
43 ->andWhere('a.exampleField = :val')
44 ->setParameter('val', $value)
45 ->getQuery()
46 ->getOneOrNullResult()
47 ;
48 }
49 */
50 }
49 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 21
22 /**
23 * @return Article[]
24 */
25 public function findAllPublishedOrderedByNewest()
26 {
... lines 27 - 34
35 }
... lines 36 - 47
48 }
How do you write custom queries in Doctrine? Well, you're already familiar with writing SQL, and, yea, it is
possible to write raw SQL queries with Doctrine. But, most of the time, you won't do this. Instead, because
Doctrine is a library that works with many different database engines, Doctrine has its own SQL-like language
called Doctrine query language, or DQL.
Fortunately, DQL looks almost exactly like SQL. Except, instead of table and column names in your query, you'll
use class and property names. Again, Doctrine really wants you to pretend like there is no database, tables or
columns behind the scenes. It wants you to pretend like you're saving and fetching objects and their properties.
49 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 24
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.exampleField = :val')
29 ->setParameter('val', $value)
30 ->orderBy('a.id', 'ASC')
31 ->setMaxResults(10)
32 ->getQuery()
33 ->getResult()
34 ;
35 }
... lines 36 - 47
48 }
One nice thing is that you can do this all in any order - you could put the order by first, and the where
statements after. The query builder doesn't care!
Oh, and see this andWhere() ?
49 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 24
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.exampleField = :val')
... lines 29 - 33
34 ;
35 }
... lines 36 - 47
48 }
There is a normal where() method, but it's safe to use andWhere() even if this is the first WHERE clause. Again
the query builder is smart enough to figure it out. I recommend andWhere() , because where() will remove any
previous where clauses you may have added... which... can be a gotcha!
DQL - and so, the query builder - also uses prepared statements. If you're not familiar with them, it's a really
simple idea: whenever you want to put a dynamic value into a query, instead of hacking it into the string with
concatenation, put : and any placeholder name. Then, later, give that placeholder a value with
->setParameter() :
49 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 24
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.exampleField = :val')
29 ->setParameter('val', $value)
... lines 30 - 33
34 ;
35 }
... lines 36 - 47
48 }
47 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 24
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.publishedAt IS NOT NULL')
... lines 29 - 31
32 ;
33 }
... lines 34 - 45
46 }
You can totally see how close this is to normal SQL. You can even put OR statements inside the string, like
a.publishedAt IS NULL OR a.publishedAt > NOW() .
Oh, and what the heck does the a mean? Think of this as the table alias for Article in the query - just like how
you can say SELECT a.* FROM article AS a .
It could be anything: if you used article instead, you'd just need to change all the references from a. to
article. .
47 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 24
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.publishedAt IS NOT NULL')
29 ->orderBy('a.publishedAt', 'DESC')
... lines 30 - 31
32 ;
33 }
... lines 34 - 45
46 }
Oh, and this is a good example of how we're referencing the property name on the entity. The column name in
the database is actually published_at , but we don't use that here.
Finally, let's remove the max result:
47 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 24
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.publishedAt IS NOT NULL')
29 ->orderBy('a.publishedAt', 'DESC')
... lines 30 - 31
32 ;
33 }
... lines 34 - 45
46 }
Once you're done building your query, you always call getQuery() and then, to get the array of Article objects,
getResult() :
47 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 24
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.publishedAt IS NOT NULL')
29 ->orderBy('a.publishedAt', 'DESC')
30 ->getQuery()
31 ->getResult()
32 ;
33 }
... lines 34 - 45
46 }
47 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 34
35 /*
36 public function findOneBySomeField($value): ?Article
37 {
38 return $this->createQueryBuilder('a')
39 ->andWhere('a.exampleField = :val')
40 ->setParameter('val', $value)
41 ->getQuery()
42 ->getOneOrNullResult()
43 ;
44 }
45 */
46 }
It's almost the same: build the query, call getQuery() , but then finish with getOneOrNullResult() .
So, in all normal situations, you always call getQuery() , then you'll either call getResult() to return many rows of
articles, or getOneOrNullResult() to return a single Article object. Got it?
Now that our new findAllPublishedOrderedByNewest() method is done, let's go use it in the controller:
$repository-> , and there it is!
81 lines src/Controller/ArticleController.php
... lines 1 - 15
16 class ArticleController extends AbstractController
17 {
... lines 18 - 30
31 public function homepage(EntityManagerInterface $em)
32 {
33 $repository = $em->getRepository(Article::class);
34 $articles = $repository->findAllPublishedOrderedByNewest();
... lines 35 - 38
39 }
... lines 40 - 79
80 }
Let's give it a try! Move over and, refresh! Perfect! The order is correct and the unpublished articles are gone.
Autowiring ArticleRepository
To make this even cooler, let me show you a trick. Instead of getting the entity manager and then calling
getRepository() to get the ArticleRepository , you can take a shortcut: just type ArticleRepository $repository :
81 lines src/Controller/ArticleController.php
... lines 1 - 5
6 use App\Repository\ArticleRepository;
... lines 7 - 16
17 class ArticleController extends AbstractController
... lines 18 - 31
32 public function homepage(ArticleRepository $repository)
33 {
34 $articles = $repository->findAllPublishedOrderedByNewest();
... lines 35 - 38
39 }
... lines 40 - 79
80 }
This works for a simple reason: all of your repositories are automatically registered as services in the container.
So you can autowire them like anything else. This is how I actually code when I need a repository.
And when we refresh, no surprise, it works!
Custom queries are a big topic, and we'll continue writing a few more here and there. But if you have
something particularly challenging, check out our Go Pro with Doctrine Queries tutorial. That tutorial uses
Symfony 3, but the query logic is exactly the same as in Symfony 4.
Next, I want to show you two more tricks: one for re-using query logic between multiple queries, and another
super shortcut to fetch any entity with zero work.
Chapter 11: Query Logic Re-use & Shortcuts
One of my favorite things about the query builder is that, with a few tricks, you can reuse query logic! Check
this out: right now, we only have one custom method in ArticleRepository :
47 lines src/Repository/ArticleRepository.php
... lines 1 - 14
15 class ArticleRepository extends ServiceEntityRepository
16 {
... lines 17 - 21
22 /**
23 * @return Article[]
24 */
25 public function findAllPublishedOrderedByNewest()
26 {
27 return $this->createQueryBuilder('a')
28 ->andWhere('a.publishedAt IS NOT NULL')
29 ->orderBy('a.publishedAt', 'DESC')
30 ->getQuery()
31 ->getResult()
32 ;
33 }
... lines 34 - 45
46 }
But, as our app grows, we'll certainly need to add more. And there's a pretty darn good chance that another
custom query will also need to filter its results to only show published articles. In a perfect world, we would
share that logic, between both custom methods. And... we can do that!
Step 1 is to isolate the query logic that we need to share into its own private method. At the bottom, create a
private function addIsPublishedQueryBuilder() with a QueryBuilder type-hint - the one from Doctrine\ORM - and $qb :
54 lines src/Repository/ArticleRepository.php
... lines 1 - 6
7 use Doctrine\ORM\QueryBuilder;
... lines 8 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 48
49 private function addIsPublishedQueryBuilder(QueryBuilder $qb)
50 {
... line 51
52 }
53 }
Next, go up, copy that part of the query, and just return $qb->andWhere('a.publishedAt IS NOT NULL') :
54 lines src/Repository/ArticleRepository.php
... lines 1 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 48
49 private function addIsPublishedQueryBuilder(QueryBuilder $qb)
50 {
51 return $qb->andWhere('a.publishedAt IS NOT NULL');
52 }
53 }
And since we're returning this - and each query builder method returns itself - back up top, we can say
$qb = $this->createQueryBuilder('a') , and below, return $this->addIsPublishedQueryBuilder() passing it $qb :
54 lines src/Repository/ArticleRepository.php
... lines 1 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 25
26 public function findAllPublishedOrderedByNewest()
27 {
28 $qb = $this->createQueryBuilder('a');
29
30 return $this->addIsPublishedQueryBuilder($qb)
31 ->orderBy('a.publishedAt', 'DESC')
32 ->getQuery()
33 ->getResult()
34 ;
35 }
... lines 36 - 52
53 }
Fancier Re-Use
This is nice... but since I do this a lot, we can get a bit fancier. Create another private method called
getOrCreateQueryBuilder() with a QueryBuilder argument like before, but make it optional:
58 lines src/Repository/ArticleRepository.php
... lines 1 - 6
7 use Doctrine\ORM\QueryBuilder;
... lines 8 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 52
53 private function getOrCreateQueryBuilder(QueryBuilder $qb = null)
54 {
... line 55
56 }
57 }
Here's the idea: when someone calls this method, if the query builder is passed, we'll just return it. Otherwise
we will return a new one with $this->createQueryBuilder('a') :
58 lines src/Repository/ArticleRepository.php
... lines 1 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 52
53 private function getOrCreateQueryBuilder(QueryBuilder $qb = null)
54 {
55 return $qb ?: $this->createQueryBuilder('a');
56 }
57 }
If you're not used to this syntax, it means that if a QueryBuilder object is passed, return that QueryBuilder
object. If a QueryBuilder object is not passed, then create one.
This is cool, because now we can make the argument to addIsPublishedQueryBuilder() also optional:
58 lines src/Repository/ArticleRepository.php
... lines 1 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 46
47 private function addIsPublishedQueryBuilder(QueryBuilder $qb = null)
48 {
... lines 49 - 50
51 }
... lines 52 - 56
57 }
Inside, use the new method: return $this->getOrCreateQueryBuilder() passing it $qb , and then our andWhere() :
58 lines src/Repository/ArticleRepository.php
... lines 1 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 46
47 private function addIsPublishedQueryBuilder(QueryBuilder $qb = null)
48 {
49 return $this->getOrCreateQueryBuilder($qb)
50 ->andWhere('a.publishedAt IS NOT NULL');
51 }
... lines 52 - 56
57 }
But the real beautiful thing is back up top. This whole method can now be one big chained call:
return $this->addIsPublishedQueryBuilder() - and pass nothing:
58 lines src/Repository/ArticleRepository.php
... lines 1 - 15
16 class ArticleRepository extends ServiceEntityRepository
17 {
... lines 18 - 25
26 public function findAllPublishedOrderedByNewest()
27 {
28 return $this->addIsPublishedQueryBuilder()
29 ->orderBy('a.publishedAt', 'DESC')
30 ->getQuery()
31 ->getResult()
32 ;
33 }
... lines 34 - 56
57 }
81 lines src/Controller/ArticleController.php
... lines 1 - 5
6 use App\Repository\ArticleRepository;
... lines 7 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 31
32 public function homepage(ArticleRepository $repository)
33 {
34 $articles = $repository->findAllPublishedOrderedByNewest();
... lines 35 - 38
39 }
... lines 40 - 79
80 }
Life is good. But it's also really common to query for just one object. And in these situations, if the query you
need is simple... you can make Symfony do all of the work:
81 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 43
44 public function show($slug, SlackClient $slack, EntityManagerInterface $em)
45 {
... lines 46 - 50
51 /** @var Article $article */
52 $article = $repository->findOneBy(['slug' => $slug]);
... lines 53 - 66
67 }
... lines 68 - 79
80 }
Let me show you: remove the $slug argument and replace it with Article $article :
74 lines src/Controller/ArticleController.php
... lines 1 - 4
5 use App\Entity\Article;
... lines 6 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 40
41 /**
42 * @Route("/news/{slug}", name="article_show")
43 */
44 public function show(Article $article, SlackClient $slack)
45 {
... lines 46 - 59
60 }
... lines 61 - 72
73 }
74 lines src/Controller/ArticleController.php
... lines 1 - 4
5 use App\Entity\Article;
... lines 6 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 40
41 /**
42 * @Route("/news/{slug}", name="article_show")
43 */
44 public function show(Article $article, SlackClient $slack)
45 {
46 if ($article->getSlug() === 'khaaaaaan') {
... line 47
48 }
... lines 49 - 59
60 }
... lines 61 - 72
73 }
We can also remove all of the query, and even the 404 logic:
74 lines src/Controller/ArticleController.php
... lines 1 - 4
5 use App\Entity\Article;
... lines 6 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 40
41 /**
42 * @Route("/news/{slug}", name="article_show")
43 */
44 public function show(Article $article, SlackClient $slack)
45 {
46 if ($article->getSlug() === 'khaaaaaan') {
47 $slack->sendMessage('Kahn', 'Ah, Kirk, my old friend...');
48 }
49
50 $comments = [
51 'I ate a normal rock once. It did NOT taste like bacon!',
52 'Woohoo! I\'m going on an all-asteroid diet!',
53 'I like bacon too! Buy some from my site! bakinsomebacon.com',
54 ];
55
56 return $this->render('article/show.html.twig', [
57 'article' => $article,
58 'comments' => $comments,
59 ]);
60 }
... lines 61 - 72
73 }
Before we talk about this, move over and click on one of the articles. Yea! Somehow, this totally works! Back in
our code, we can remove the unused EntityManagerInterface argument:
74 lines src/Controller/ArticleController.php
... lines 1 - 4
5 use App\Entity\Article;
... lines 6 - 7
8 use App\Service\SlackClient;
... lines 9 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 43
44 public function show(Article $article, SlackClient $slack)
45 {
... lines 46 - 59
60 }
... lines 61 - 72
73 }
Here's the deal. We already know that if you type-hint, a service, Symfony will pass you that service. In addition
to that, if you type-hint an entity class, Symfony will automatically query for that entity. How? It looks at all of
the route's placeholder values - which is just one in this case, {slug} - and creates a query where the slug field
matches that value:
Tip
74 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 40
41 /**
42 * @Route("/news/{slug}", name="article_show")
43 */
44 public function show(Article $article, SlackClient $slack)
45 {
... lines 46 - 59
60 }
... lines 61 - 72
73 }
In other words, to use this trick, your routing wildcard must be named the same as the property on your entity,
which is usually how I do things anyways. It executes the exact same query that we were doing before by
hand! If there is not a slug that matches this, it also automatically throws a 404, before the controller is ever
called.
In fact, try that - put in a bad slug. Yep, error! Something about the Article object not found by the
@ParamConverter annotation. So, that's not a great error message - it makes more sense if you know that the
name of this feature internally is ParamConverter .
So... yea! If you organize your route wildcards to match the property on your entity, which is a good idea
anyways, then you can use this trick. If you need a more complex query, no problem! You can't use this
shortcut, but it's still simple enough: autowire the ArticleRepository , and then call whatever method you need.
Chapter 12: Updating an Entity with New Fields
It's time to get back to work on the article page... because... some of this stuff is still hardcoded! Lame! Like,
the author, number of hearts, and this image. There are a few possible images in our project that our dummy
articles can point to.
Our mission is clear: create three new fields in Article and use those to make all of this finally dynamic! Let's
go!
Open your Article entity. The simplest way to add new fields is just to... add them by hand! It's easy enough to
copy an existing property, paste, rename, and configure it. Of course, if you want a getter and setter method,
you'll also need to create those.
If you pass this the name of an existing entity, it can actually update that class and add new fields. Magic! First,
add author , use string as the type. And yea, in the future when we have a "user" system, this field might be a
database relation to that table. But for now, use a string. Say no to nullable. Reminder: when you say no to
nullable, it means that this field must be set in the database. If you try to save an entity without any data on it,
you'll get a huge database exception.
Next, add heartCount , as an integer, and say not null: this should always have a value, even if it's zero. Then,
finally, the image. In the database, we'll store only the image filename. And, full disclosure, uploading files is a
whole different topic that we'll cover in a different tutorial. In this example, we're going to use a few existing
images in the public/ directory. But, both in this situation and in a real-file upload situation, the field on your
entity looks the same: imageFilename as a string and nullable yes, because maybe the image is optional when
you first start writing an article.
Ok, hit enter and, done! Let's go check out the entity! Great: three new properties on top:
And of course, at the bottom, here are their getter and setter methods:
Now that we have the new fields, don't forget! We need a migration:
When that finishes, go look at the new file to make sure it doesn't have any surprises: ALTER TABLE article , and
then it adds author , heart_count and image_filename :
29 lines src/Migrations/Version20180414171443.php
... lines 1 - 10
11 class Version20180414171443 extends AbstractMigration
12 {
13 public function up(Schema $schema)
14 {
15 // this up() migration is auto-generated, please modify it to your needs
16 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
17
18 $this->addSql('ALTER TABLE article ADD author VARCHAR(255) NOT NULL, ADD heart_count INT NOT NULL, ADD image_filen
19 }
20
21 public function down(Schema $schema)
22 {
23 // this down() migration is auto-generated, please modify it to your needs
24 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
25
26 $this->addSql('ALTER TABLE article DROP author, DROP heart_count');
27 }
28 }
I love it!
Close that, run back to your terminal, and migrate!
Actually, to be more clear: nullable=true means that it is allowed to be null in the database. If you don't see
nullable , it uses the default value, which is false.
Anyways, this means that heartCount needs a value! But here's a cool idea: once our admin area is fully
finished, when an author creates a new article, they shouldn't need to set the heartCount manually. I mean, it's
not like we expect the form to have a "heart count" input field on it. Nope, we expect it to automatically default
to zero for new articles.
So... how can we give a property a default value in the database? By giving it a default value in PHP:
$heartCount = 0 :
61 lines src/Controller/ArticleAdminController.php
... lines 1 - 10
11 class ArticleAdminController extends AbstractController
12 {
... lines 13 - 15
16 public function new(EntityManagerInterface $em)
17 {
... lines 18 - 40
41 // publish most articles
42 if (rand(1, 10) > 2) {
43 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
44 }
45
46 $article->setAuthor('Mike Ferengi')
... lines 47 - 58
59 }
60 }
Then, ->setHeartCount() and give this a random number between, how about, 5 and 100:
61 lines src/Controller/ArticleAdminController.php
... lines 1 - 10
11 class ArticleAdminController extends AbstractController
12 {
... lines 13 - 15
16 public function new(EntityManagerInterface $em)
17 {
... lines 18 - 45
46 $article->setAuthor('Mike Ferengi')
47 ->setHeartCount(rand(5, 100))
... lines 48 - 58
59 }
60 }
And finally, ->setImageFilename() . The file we've been using is called asteroid.jpeg . Keep using that:
61 lines src/Controller/ArticleAdminController.php
... lines 1 - 10
11 class ArticleAdminController extends AbstractController
12 {
... lines 13 - 15
16 public function new(EntityManagerInterface $em)
17 {
... lines 18 - 45
46 $article->setAuthor('Mike Ferengi')
47 ->setHeartCount(rand(5, 100))
48 ->setImageFilename('asteroid.jpeg')
49 ;
... lines 50 - 58
59 }
60 }
Excelente! Because we already have a bunch of records in the database where these fields are blank, just to
keep things simple, let's delete the table entirely and start fresh. Do that with:
If you check out the page now and refresh... cool, it's empty. Now, go to /admin/article/new and... refresh a few
times. Awesome! Check out the homepage!
We have articles... but actually... this author is still hardcoded in the template. Easy fix!
58 lines templates/article/homepage.html.twig
... lines 1 - 2
3 {% block body %}
4 <div class="container">
5 <div class="row">
6
7 <!-- Article List -->
8
9 <div class="col-sm-12 col-md-8">
... lines 10 - 18
19 <!-- Supporting Articles -->
20
21 {% for article in articles %}
22 <div class="article-container my-1">
23 <a href="{{ path('article_show', {slug: article.slug}) }}">
... line 24
25 <div class="article-title d-inline-block pl-3 align-middle">
... lines 26 - 27
28 <span class="align-left article-details"><img class="article-author-img rounded-circle" src="{{ asset('images/a
... line 29
30 </div>
31 </a>
32 </div>
33 {% endfor %}
34 </div>
... lines 35 - 54
55 </div>
56 </div>
57 {% endblock %}
Then, in show.html.twig , change the article's heart count - here it is - to {{ article.heartCount }} . And also update
the author, just like before:
86 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
... line 13
14 <div class="show-article-title-container d-inline-block pl-3 align-middle">
... lines 15 - 16
17 <span class="align-left article-details"><img class="article-author-img rounded-circle" src="{{ asset('images/a
... lines 18 - 20
21 <span class="pl-2 article-details">
22 <span class="js-like-article-count">{{ article.heartCount }}</span>
... line 23
24 </span>
25 </div>
26 </div>
27 </div>
... lines 28 - 73
74 </div>
75 </div>
76 </div>
77 </div>
78
79 {% endblock %}
... lines 80 - 86
If you try the homepage now, ok, it looks exactly the same, but we know that these author names are now
dynamic. If you click into an article.. yea! We have 88 hearts - that's definitely coming from the database.
58 lines templates/article/homepage.html.twig
... lines 1 - 2
3 {% block body %}
4 <div class="container">
5 <div class="row">
6
7 <!-- Article List -->
8
9 <div class="col-sm-12 col-md-8">
... lines 10 - 18
19 <!-- Supporting Articles -->
20
21 {% for article in articles %}
22 <div class="article-container my-1">
23 <a href="{{ path('article_show', {slug: article.slug}) }}">
24 <img class="article-img" src="{{ asset('images/asteroid.jpeg') }}">
... lines 25 - 30
31 </a>
32 </div>
33 {% endfor %}
34 </div>
... lines 35 - 54
55 </div>
56 </div>
57 {% endblock %}
So... this is a little bit tricky, because only part of this - the asteroid.jpeg part - is stored in the database. One
solution would be to use Twig's concatenation operator, which is ~ , then article.imageFilename :
{# templates/article/homepage.html.twig #}
{# ... #}
<img class="article-img" src="{{ asset('images/'~article.imageFilename) }}">
{# ... #}
You don't see the ~ much in Twig, but it works like a . in PHP.
That's fine, but a nicer way would be to create a new method that does this for us. Open Article and, at the
bottom, create a new public function getImagePath() :
58 lines templates/article/homepage.html.twig
... lines 1 - 2
3 {% block body %}
4 <div class="container">
5 <div class="row">
6
7 <!-- Article List -->
8
9 <div class="col-sm-12 col-md-8">
... lines 10 - 18
19 <!-- Supporting Articles -->
20
21 {% for article in articles %}
22 <div class="article-container my-1">
23 <a href="{{ path('article_show', {slug: article.slug}) }}">
24 <img class="article-img" src="{{ asset(article.imagePath) }}">
... lines 25 - 30
31 </a>
32 </div>
33 {% endfor %}
34 </div>
... lines 35 - 54
55 </div>
56 </div>
57 {% endblock %}
And yea, imagePath is totally not a real property on Article ! But thanks to the kung fu powers of Twig, this
works fine.
Oh, and side note: notice that there is not an opening slash on these paths:
As a reminder, you do not need to include the opening / when using the asset() function: Symfony will add it
there automatically.
Ok, try it out - refresh! It still works! And now that we've centralized that method, in show.html.twig , it's super
easy to make the same change: article.imagePath :
86 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
13 <img class="show-article-img" src="{{ asset(article.imagePath) }}">
... lines 14 - 25
26 </div>
27 </div>
... lines 28 - 73
74 </div>
75 </div>
76 </div>
77 </div>
78
79 {% endblock %}
... lines 80 - 86
Awesome. And when you click on the show page, it works too.
Next! Now that the heart count is stored in the database, let's make our AJAX endpoint that "likes" an article
actually work correctly. Right now, it does nothing, and returns a random number. We can do better.
Chapter 13: Updating an Entity
In the previous tutorial, we created our heart feature! You click on the heart, it makes an Ajax request back to
the server, and returns the new number of hearts. It's all very cute. In theory... when we click the heart, it
would update the number of "hearts" for this article somewhere in the database.
But actually, instead of updating the database... well... it does nothing, and returns a new, random number of
hearts. Lame!
Look in the public/js directory: open article_show.js :
16 lines public/js/article_show.js
1 $(document).ready(function() {
2 $('.js-like-article').on('click', function(e) {
3 e.preventDefault();
4
5 var $link = $(e.currentTarget);
6 $link.toggleClass('fa-heart-o').toggleClass('fa-heart');
7
8 $.ajax({
9 method: 'POST',
10 url: $link.attr('href')
11 }).done(function(data) {
12 $('.js-like-article-count').html(data.hearts);
13 })
14 });
15 });
In that tutorial, we wrote some simple JavaScript that said: when the "like" link is clicked, toggle the styling on
the heart, and then send a POST request to the URL that's in the href of the link. Then, when the AJAX call
finishes, read the new number of hearts from the JSON response and update the page.
The href that we're reading lives in show.html.twig . Here it is:
86 lines templates/article/show.html.twig
... lines 1 - 4
5 {% block body %}
6
7 <div class="container">
8 <div class="row">
9 <div class="col-sm-12">
10 <div class="show-article-container p-3 mt-4">
11 <div class="row">
12 <div class="col-sm-12">
... line 13
14 <div class="show-article-title-container d-inline-block pl-3 align-middle">
... lines 15 - 20
21 <span class="pl-2 article-details">
... line 22
23 <a href="{{ path('article_toggle_heart', {slug: article.slug}) }}" class="fa fa-heart-o like-article js-like-article"
24 </span>
25 </div>
26 </div>
27 </div>
... lines 28 - 73
74 </div>
75 </div>
76 </div>
77 </div>
78
79 {% endblock %}
... lines 80 - 86
It's a URL to some route called article_toggle_heart . And we're sending the article slug to that endpoint.
Open up ArticleController , and scroll down to find that route: it's toggleArticleHeart() :
74 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart($slug, LoggerInterface $logger)
66 {
67 // TODO - actually heart/unheart the article!
68
69 $logger->info('Article is being hearted!');
70
71 return new JsonResponse(['hearts' => rand(5, 100)]);
72 }
73 }
And, as you can see... this endpoint doesn't actually do anything! Other than return JSON with a random
number, which our JavaScript uses to update the page:
16 lines public/js/article_show.js
1 $(document).ready(function() {
2 $('.js-like-article').on('click', function(e) {
... lines 3 - 7
8 $.ajax({
... lines 9 - 10
11 }).done(function(data) {
12 $('.js-like-article-count').html(data.hearts);
13 })
14 });
15 });
75 lines src/Controller/ArticleController.php
... lines 1 - 4
5 use App\Entity\Article;
... lines 6 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart(Article $article, LoggerInterface $logger, EntityManagerInterface $em)
66 {
... lines 67 - 72
73 }
74 }
Thanks to the type-hint, Symfony will automatically try to find an Article with this slug.
Then, to update the heartCount , just $article->setHeartCount() and then $article->getHeartCount() + 1 :
75 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart(Article $article, LoggerInterface $logger, EntityManagerInterface $em)
66 {
67 $article->setHeartCount($article->getHeartCount() + 1);
... lines 68 - 72
73 }
74 }
Side note, it's not important for this tutorial, but in a high-traffic system, this could introduce a race condition.
Between the time this article is queried for, and when it saves, 10 other people might have also liked the
article. And that would mean that this would actually save the old, wrong number, effectively removing the 10
hearts that occurred during those microseconds.
Anyways, at the bottom, instead of the random number, use $article->getHeartCount() :
75 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart(Article $article, LoggerInterface $logger, EntityManagerInterface $em)
66 {
67 $article->setHeartCount($article->getHeartCount() + 1);
... lines 68 - 71
72 return new JsonResponse(['hearts' => $article->getHeartCount()]);
73 }
74 }
So, now, to the key question: how do we run an UPDATE query in the database? Actually, it's the exact same as
inserting a new article. Fetch the entity manager like normal: EntityManagerInterface $em :
75 lines src/Controller/ArticleController.php
... lines 1 - 8
9 use Doctrine\ORM\EntityManagerInterface;
... lines 10 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart(Article $article, LoggerInterface $logger, EntityManagerInterface $em)
66 {
... lines 67 - 72
73 }
74 }
75 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart(Article $article, LoggerInterface $logger, EntityManagerInterface $em)
66 {
67 $article->setHeartCount($article->getHeartCount() + 1);
68 $em->flush();
69
70 $logger->info('Article is being hearted!');
71
72 return new JsonResponse(['hearts' => $article->getHeartCount()]);
73 }
74 }
But wait! I did not call $em->persist($article) . We could call this... it's just not needed for updates! When you
query Doctrine for an object, it already knows that you want that object to be saved to the database when you
call flush() . Doctrine is also smart enough to know that it should update the object, instead of inserting a new
one.
Ok, go back and refresh! Here is the real heart count for this article: 88. Click the heart and... yea! 89! And if
you refresh, it stays! We can do 90, 91, 92, 93, and forever! And yea... this is not quite realistic yet. On a real
site, I should only be able to like this article one time. But, we'll need to talk about users and security before we
can do that.
75 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart(Article $article, LoggerInterface $logger, EntityManagerInterface $em)
66 {
67 $article->setHeartCount($article->getHeartCount() + 1);
... lines 68 - 72
73 }
74 }
But, whenever possible, it's better to move code out of your controller. Usually we do this by creating a new
service class and putting the logic there. But, if the logic is simple, it can sometimes live inside your entity
class. Check this out: open Article , scroll to the bottom, and add a new method:
public function incrementHeartCount() . Give it no arguments and return self, like our other methods:
75 lines src/Controller/ArticleController.php
... lines 1 - 16
17 class ArticleController extends AbstractController
18 {
... lines 19 - 61
62 /**
63 * @Route("/news/{slug}/heart", name="article_toggle_heart", methods={"POST"})
64 */
65 public function toggleArticleHeart(Article $article, LoggerInterface $logger, EntityManagerInterface $em)
66 {
67 $article->incrementHeartCount();
... lines 68 - 72
73 }
74 }
That's so nice. This moves the logic to a better place, and, it reads really well:
Hello Article: I would like you to increment your heart count. Thanks!
I mean, should any part of the app ever need to change this? Probably not: they should just call our more
descriptive incrementHeartCount() instead:
I am going to keep it, because we use it to generate our fake data, but I want you to really think about this
point.
By removing unnecessary getter or setter methods, and replacing them with more descriptive methods that fit
your business logic, you can, little-by-little, give your entities more clarity. Some people take this to an extreme
and have almost zero getters and setters. Here at KnpU, we tend to be more pragmatic: we usually have
getters and setters, but we always look for ways to be more descriptive.
Next, our dummy article data is boring, and we're creating it in a hacky way:
61 lines src/Controller/ArticleAdminController.php
... lines 1 - 10
11 class ArticleAdminController extends AbstractController
12 {
13 /**
14 * @Route("/admin/article/new")
15 */
16 public function new(EntityManagerInterface $em)
17 {
18 $article = new Article();
19 $article->setTitle('Why Asteroids Taste Like Bacon')
20 ->setSlug('why-asteroids-taste-like-bacon-'.rand(100, 999))
21 ->setContent(<<<EOF
22 Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
23 lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
24 labore minim pork belly spare ribs cupim short loin in. Elit exercitation eiusmod dolore cow
25 **turkey** shank eu pork belly meatball non cupim.
26
27 Laboris beef ribs fatback fugiat eiusmod jowl kielbasa alcatra dolore velit ea ball tip. Pariatur
28 laboris sunt venison, et laborum dolore minim non meatball. Shankle eu flank aliqua shoulder,
29 capicola biltong frankfurter boudin cupim officia. Exercitation fugiat consectetur ham. Adipisicing
30 picanha shank et filet mignon pork belly ut ullamco. Irure velit turducken ground round doner incididunt
31 occaecat lorem meatball prosciutto quis strip steak.
32
33 Meatball adipisicing ribeye bacon strip steak eu. Consectetur ham hock pork hamburger enim strip steak
34 mollit quis officia meatloaf tri-tip swine. Cow ut reprehenderit, buffalo incididunt in filet mignon
35 strip steak pork belly aliquip capicola officia. Labore deserunt esse chicken lorem shoulder tail consectetur
36 cow est ribeye adipisicing. Pig hamburger pork belly enim. Do porchetta minim capicola irure pancetta chuck
37 fugiat.
38 EOF
39 );
40
41 // publish most articles
42 if (rand(1, 10) > 2) {
43 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
44 }
45
46 $article->setAuthor('Mike Ferengi')
47 ->setHeartCount(rand(5, 100))
48 ->setImageFilename('asteroid.jpeg')
49 ;
50
51 $em->persist($article);
52 $em->flush();
53
54 return new Response(sprintf(
55 'Hiya! New Article id: #%d slug: %s',
56 $article->getId(),
57 $article->getSlug()
58 ));
59 }
60 }
Let's build an awesome fixtures system instead.
Chapter 14: Fixtures: Seeding Dummy Data!
We're creating our dummy article data in a really ... uh... dummy way: with a, sort of, "secret" endpoint that
creates an almost-identical article, each time we go there:
61 lines src/Controller/ArticleAdminController.php
... lines 1 - 10
11 class ArticleAdminController extends AbstractController
12 {
13 /**
14 * @Route("/admin/article/new")
15 */
16 public function new(EntityManagerInterface $em)
17 {
18 $article = new Article();
19 $article->setTitle('Why Asteroids Taste Like Bacon')
20 ->setSlug('why-asteroids-taste-like-bacon-'.rand(100, 999))
21 ->setContent(<<<EOF
22 Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
23 lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
24 labore minim pork belly spare ribs cupim short loin in. Elit exercitation eiusmod dolore cow
25 **turkey** shank eu pork belly meatball non cupim.
26
27 Laboris beef ribs fatback fugiat eiusmod jowl kielbasa alcatra dolore velit ea ball tip. Pariatur
28 laboris sunt venison, et laborum dolore minim non meatball. Shankle eu flank aliqua shoulder,
29 capicola biltong frankfurter boudin cupim officia. Exercitation fugiat consectetur ham. Adipisicing
30 picanha shank et filet mignon pork belly ut ullamco. Irure velit turducken ground round doner incididunt
31 occaecat lorem meatball prosciutto quis strip steak.
32
33 Meatball adipisicing ribeye bacon strip steak eu. Consectetur ham hock pork hamburger enim strip steak
34 mollit quis officia meatloaf tri-tip swine. Cow ut reprehenderit, buffalo incididunt in filet mignon
35 strip steak pork belly aliquip capicola officia. Labore deserunt esse chicken lorem shoulder tail consectetur
36 cow est ribeye adipisicing. Pig hamburger pork belly enim. Do porchetta minim capicola irure pancetta chuck
37 fugiat.
38 EOF
39 );
40
41 // publish most articles
42 if (rand(1, 10) > 2) {
43 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
44 }
45
46 $article->setAuthor('Mike Ferengi')
47 ->setHeartCount(rand(5, 100))
48 ->setImageFilename('asteroid.jpeg')
49 ;
50
51 $em->persist($article);
52 $em->flush();
53
54 return new Response(sprintf(
55 'Hiya! New Article id: #%d slug: %s',
56 $article->getId(),
57 $article->getSlug()
58 ));
59 }
60 }
Honestly, it's not great for development: every article on the homepage pretty much looks the same.
Yep, our dummy data sucks. And, that's important! If we could load a rich set of random data easily, we could
develop and debug faster.
Installing DoctrineFixturesBundle
To help with this, we'll use a great library called DoctrineFixturesBundle... but with our own spin to make things
really fun.
First let's get it installed. Find your terminal and run
And yep, this is a Flex alias, and we're using --dev because this tool will help us load fake data for
development... which is not something we need in our production code. If you've ever accidentally replaced the
production database with dummy data... you know what I mean.
Call it ArticleFixtures . It's fairly common to have one fixture class per entity, or sometimes, per group of entities.
And... done!
Go check it out: src/DataFixtures/ArticleFixtures.php :
18 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 2
3 namespace App\DataFixtures;
4
5 use Doctrine\Bundle\FixturesBundle\Fixture;
6 use Doctrine\Common\Persistence\ObjectManager;
7
8 class ArticleFixtures extends Fixture
9 {
10 public function load(ObjectManager $manager)
11 {
12 // $product = new Product();
13 // $manager->persist($product);
14
15 $manager->flush();
16 }
17 }
The idea behind fixtures is dead simple: step (1) we write code to create and save objects, and then step (2),
we run a new console command that executes all of our fixture classes.
51 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 4
5 use App\Entity\Article;
... lines 6 - 8
9 class ArticleFixtures extends Fixture
10 {
11 public function load(ObjectManager $manager)
12 {
13 $article = new Article();
... lines 14 - 48
49 }
50 }
51 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 8
9 class ArticleFixtures extends Fixture
10 {
11 public function load(ObjectManager $manager)
12 {
13 $article = new Article();
... lines 14 - 45
46 $manager->persist($article);
47
48 $manager->flush();
49 }
50 }
27 lines src/Controller/ArticleAdminController.php
... lines 1 - 10
11 class ArticleAdminController extends AbstractController
12 {
13 /**
14 * @Route("/admin/article/new")
15 */
16 public function new(EntityManagerInterface $em)
17 {
18 die('todo');
... lines 19 - 24
25 }
26 }
This will ask if you want to continue because - important note! - the command will empty the database first,
and then load fresh data. Again, not something you want to run on production... not saying I've done that
before.
When it finishes, find your browser, and refresh. It works!
Then, refresh! Awesome! Except that the articles are still totally boring and identical... we'll talk about that in
the next chapter.
22 lines src/DataFixtures/BaseFixture.php
... lines 1 - 2
3 namespace App\DataFixtures;
4
5 use Doctrine\Bundle\FixturesBundle\Fixture;
... lines 6 - 7
8 abstract class BaseFixture extends Fixture
9 {
... lines 10 - 20
21 }
Here's the idea: this will not be a fixture class that the bundle will execute. Instead, it will be a base class with
some cool helper methods. To start, copy the load() method and implement it here. Re-type ObjectManager to
get its use statement:
22 lines src/DataFixtures/BaseFixture.php
... lines 1 - 2
3 namespace App\DataFixtures;
... lines 4 - 5
6 use Doctrine\Common\Persistence\ObjectManager;
7
8 abstract class BaseFixture extends Fixture
9 {
... lines 10 - 14
15 public function load(ObjectManager $manager)
16 {
... lines 17 - 19
20 }
21 }
Oh, ObjectManager is an interface implemented by EntityManager , it's not too important: just think "this is the
entity manager".
Next, and this won't make sense yet, create a private $manager property, and set it inside the load() method:
22 lines src/DataFixtures/BaseFixture.php
... lines 1 - 7
8 abstract class BaseFixture extends Fixture
9 {
... line 10
11 private $manager;
... lines 12 - 14
15 public function load(ObjectManager $manager)
16 {
17 $this->manager = $manager;
... lines 18 - 19
20 }
21 }
Finally, create an abstract protected function called loadData() with that same ObjectManager argument:
22 lines src/DataFixtures/BaseFixture.php
... lines 1 - 7
8 abstract class BaseFixture extends Fixture
9 {
... lines 10 - 12
13 abstract protected function loadData(ObjectManager $manager);
... lines 14 - 20
21 }
22 lines src/DataFixtures/BaseFixture.php
... lines 1 - 7
8 abstract class BaseFixture extends Fixture
9 {
... lines 10 - 14
15 public function load(ObjectManager $manager)
16 {
... lines 17 - 18
19 $this->loadData($manager);
20 }
21 }
So far, this doesn't do anything special. Back in ArticleFixtures , extend the new BaseFixture instead. I'll also
cleanup the extra use statement:
50 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 4
5 use App\Entity\Article;
6 use Doctrine\Common\Persistence\ObjectManager;
7
8 class ArticleFixtures extends BaseFixture
9 {
... lines 10 - 48
49 }
50 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
... lines 12 - 47
48 }
49 }
And... yea! The fixture system will call load() on BaseFixture , that will call loadData() on ArticleFixtures and...
well... everything will work exactly like before.
34 lines src/DataFixtures/BaseFixture.php
... lines 1 - 7
8 abstract class BaseFixture extends Fixture
9 {
... lines 10 - 21
22 protected function createMany(string $className, int $count, callable $factory)
23 {
24 for ($i = 0; $i < $count; $i++) {
25 $entity = new $className();
26 $factory($entity, $i);
27
28 $this->manager->persist($entity);
29 // store for usage later as App\Entity\ClassName_#COUNT#
30 $this->addReference($className . '_' . $i, $entity);
31 }
32 }
33 }
Oh, and to make PhpStorm happy, at the top, add some PHPDoc that the $manager property is an
ObjectManager instance:
34 lines src/DataFixtures/BaseFixture.php
... lines 1 - 5
6 use Doctrine\Common\Persistence\ObjectManager;
7
8 abstract class BaseFixture extends Fixture
9 {
10 /** @var ObjectManager */
11 private $manager;
... lines 12 - 32
33 }
Anyways, say hello to createMany() ! A simple method that we can call to create multiple instances of an object.
Here's the idea: we call createMany() and pass it the class we want to create, how many we want to create, and
a callback method that will be called each time an object is created. That'll be our chance to load that object
with data.
Basically, it does the for loop for us... which is not a huge deal, except for two nice things. First, it calls
persist() for us, so we don't have to:
34 lines src/DataFixtures/BaseFixture.php
... lines 1 - 7
8 abstract class BaseFixture extends Fixture
9 {
... lines 10 - 21
22 protected function createMany(string $className, int $count, callable $factory)
23 {
24 for ($i = 0; $i < $count; $i++) {
... lines 25 - 27
28 $this->manager->persist($entity);
... lines 29 - 30
31 }
32 }
33 }
Ok, cool, but not amazing. But, this last line is cool:
34 lines src/DataFixtures/BaseFixture.php
... lines 1 - 7
8 abstract class BaseFixture extends Fixture
9 {
... lines 10 - 21
22 protected function createMany(string $className, int $count, callable $factory)
23 {
24 for ($i = 0; $i < $count; $i++) {
... lines 25 - 28
29 // store for usage later as App\Entity\ClassName_#COUNT#
30 $this->addReference($className . '_' . $i, $entity);
31 }
32 }
33 }
It won't matter yet, but in a future tutorial, we will have multiple fixtures classes. When we do, we will need to
be able to reference objects created in one fixture class from other fixture classes. By calling addReference() , all
of our objects are automatically stored and ready to be fetched with a key that's their class name plus the
index number.
The point is: this is going to save us some serious work... but not until the next tutorial.
Back in ArticleFixtures , use the new method: $this->createMany() passing it Article::class , 10, and a function:
49 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
12 $this->createMany(Article::class, 10, function(Article $article, $count) {
... lines 13 - 43
44 });
... lines 45 - 46
47 }
48 }
This will have two args: the Article that was just created and a count of which article this is. Inside the method,
we can remove the $article = new Article() , and instead of a random number on the slug, we can use $count :
49 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
12 $this->createMany(Article::class, 10, function(Article $article, $count) {
13 $article->setTitle('Why Asteroids Taste Like Bacon')
14 ->setSlug('why-asteroids-taste-like-bacon-'.$count)
15 ->setContent(<<<EOF
16 Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
17 lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
18 labore minim pork belly spare ribs cupim short loin in. Elit exercitation eiusmod dolore cow
19 **turkey** shank eu pork belly meatball non cupim.
20
21 Laboris beef ribs fatback fugiat eiusmod jowl kielbasa alcatra dolore velit ea ball tip. Pariatur
22 laboris sunt venison, et laborum dolore minim non meatball. Shankle eu flank aliqua shoulder,
23 capicola biltong frankfurter boudin cupim officia. Exercitation fugiat consectetur ham. Adipisicing
24 picanha shank et filet mignon pork belly ut ullamco. Irure velit turducken ground round doner incididunt
25 occaecat lorem meatball prosciutto quis strip steak.
26
27 Meatball adipisicing ribeye bacon strip steak eu. Consectetur ham hock pork hamburger enim strip steak
28 mollit quis officia meatloaf tri-tip swine. Cow ut reprehenderit, buffalo incididunt in filet mignon
29 strip steak pork belly aliquip capicola officia. Labore deserunt esse chicken lorem shoulder tail consectetur
30 cow est ribeye adipisicing. Pig hamburger pork belly enim. Do porchetta minim capicola irure pancetta chuck
31 fugiat.
32 EOF
33 );
34
35 // publish most articles
36 if (rand(1, 10) > 2) {
37 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
38 }
39
40 $article->setAuthor('Mike Ferengi')
41 ->setHeartCount(rand(5, 100))
42 ->setImageFilename('asteroid.jpeg')
43 ;
44 });
... lines 45 - 46
47 }
48 }
At the bottom, the persist isn't hurting anything, but it's not needed anymore.
49 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
12 $this->createMany(Article::class, 10, function(Article $article, $count) {
13 $article->setTitle('Why Asteroids Taste Like Bacon')
14 ->setSlug('why-asteroids-taste-like-bacon-'.$count)
15 ->setContent(<<<EOF
16 Spicy **jalapeno bacon** ipsum dolor amet veniam shank in dolore. Ham hock nisi landjaeger cow,
17 lorem proident [beef ribs](https://baconipsum.com/) aute enim veniam ut cillum pork chuck picanha. Dolore reprehenderit
18 labore minim pork belly spare ribs cupim short loin in. Elit exercitation eiusmod dolore cow
19 **turkey** shank eu pork belly meatball non cupim.
20
21 Laboris beef ribs fatback fugiat eiusmod jowl kielbasa alcatra dolore velit ea ball tip. Pariatur
22 laboris sunt venison, et laborum dolore minim non meatball. Shankle eu flank aliqua shoulder,
23 capicola biltong frankfurter boudin cupim officia. Exercitation fugiat consectetur ham. Adipisicing
24 picanha shank et filet mignon pork belly ut ullamco. Irure velit turducken ground round doner incididunt
25 occaecat lorem meatball prosciutto quis strip steak.
26
27 Meatball adipisicing ribeye bacon strip steak eu. Consectetur ham hock pork hamburger enim strip steak
28 mollit quis officia meatloaf tri-tip swine. Cow ut reprehenderit, buffalo incididunt in filet mignon
29 strip steak pork belly aliquip capicola officia. Labore deserunt esse chicken lorem shoulder tail consectetur
30 cow est ribeye adipisicing. Pig hamburger pork belly enim. Do porchetta minim capicola irure pancetta chuck
31 fugiat.
32 EOF
33 );
34
35 // publish most articles
36 if (rand(1, 10) > 2) {
37 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
38 }
39
40 $article->setAuthor('Mike Ferengi')
41 ->setHeartCount(rand(5, 100))
42 ->setImageFilename('asteroid.jpeg')
43 ;
44 });
45
46 $manager->flush();
47 }
48 }
So, it's a little bit fancier, and it'll save that important reference for us. Let's try it! Reload the fixtures again:
No errors! Refresh the homepage: ah, our same, boring list of 10 identical articles. In the next chapter, let's use
an awesome library called Faker to give each article rich, unique, realistic data.
Chapter 15: Using Faker for Seeding Data
The problem now is that our dummy data is super, duper boring. It's all the same stuff, over, and over again.
Honestly, I keep falling asleep when I see the homepage. Obviously, as good PHP developers, you guys know
that we could put some random code here and there to spice things up. I mean, we do already have a random
$publishedAt date:
49 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
12 $this->createMany(Article::class, 10, function(Article $article, $count) {
... lines 13 - 35
36 if (rand(1, 10) > 2) {
37 $article->setPublishedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
38 }
... lines 39 - 43
44 });
... lines 45 - 46
47 }
48 }
But, instead of creating that random data by hand, there's a much cooler way. We're going to use a library
called Faker. Google for "Faker PHP" to find the GitHub page from Francois Zaninotto. Fun fact, Francois was
the original documentation lead for symfony 1. He's awesome.
Anyways, this library is all about creating dummy data. Check it out: you can use it to generate random names,
random addresses, random text, random letters, numbers between this and that, paragraphs, street codes and
even winning lottery numbers! Basically, it's awesome.
Installing Faker
So let's get it installed. Copy the composer require line, move over and paste. But, add the --dev at the end:
Because we're going to use this library for our fixtures only - it's not needed on production.
Setting up Faker
When that finishes, head back to its docs so we can see how to use it. Ok: we just need to say
$faker = Faker\Factory::create() . Open our BaseFixture class: let's setup Faker in this, central spot. Create a new
protected $faker property:
40 lines src/DataFixtures/BaseFixture.php
... lines 1 - 9
10 abstract class BaseFixture extends Fixture
11 {
... lines 12 - 15
16 protected $faker;
... lines 17 - 38
39 }
And down below, I'll say, $this->faker = and look for a class called Factory from Faker, and ::create() :
40 lines src/DataFixtures/BaseFixture.php
... lines 1 - 6
7 use Faker\Factory;
... lines 8 - 9
10 abstract class BaseFixture extends Fixture
11 {
... lines 12 - 15
16 protected $faker;
... lines 17 - 19
20 public function load(ObjectManager $manager)
21 {
22 $this->manager = $manager;
23 $this->faker = Factory::create();
... lines 24 - 25
26 }
... lines 27 - 38
39 }
We should also add some PHPDoc above the property to help PhpStorm know what type of object it is. Hold
Command - or Ctrl - and click the create() method: let's see what this returns exactly. Apparently, it returns a
Generator .
Cool! Above the property, add /** @var Generator */ - the one from Faker :
40 lines src/DataFixtures/BaseFixture.php
... lines 1 - 7
8 use Faker\Generator;
9
10 abstract class BaseFixture extends Fixture
11 {
... lines 12 - 14
15 /** @var Generator */
16 protected $faker;
... lines 17 - 38
39 }
Perfect! Now, using Faker will be as easy as pie! Specifically, eating pie, cause, that's super easy.
49 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
12 $this->createMany(Article::class, 10, function(Article $article, $count) {
... lines 13 - 34
35 // publish most articles
36 if ($this->faker->boolean(70)) {
... line 37
38 }
... lines 39 - 43
44 });
... lines 45 - 46
47 }
48 }
And below, we had this long expression to create a random date. Now say,
$this->faker->dateTimeBetween('-100 days', '-1 days') :
49 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
12 $this->createMany(Article::class, 10, function(Article $article, $count) {
... lines 13 - 34
35 // publish most articles
36 if ($this->faker->boolean(70)) {
37 $article->setPublishedAt($this->faker->dateTimeBetween('-100 days', '-1 days'));
38 }
... lines 39 - 43
44 });
... lines 45 - 46
47 }
48 }
I love it! Down for heartCount , use another Faker function: $this->faker->numberBetween(5, 100) :
49 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 public function loadData(ObjectManager $manager)
11 {
12 $this->createMany(Article::class, 10, function(Article $article, $count) {
... lines 13 - 39
40 $article->setAuthor('Mike Ferengi')
41 ->setHeartCount($this->faker->numberBetween(5, 100))
... line 42
43 ;
44 });
... lines 45 - 46
47 }
48 }
After these few improvements, let's make sure the system is actually as easy as pie. Find your terminal and
run:
No errors and... back on the browser, it works! Of course, the big problem is that the title, author and article
images are always the same. Snooze.
Faker does have methods to generate random titles, random names and even random images. But, the more
realistic you make your fake data, the easier it will be to build real features for your app.
66 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
10 private static $articleTitles = [
11 'Why Asteroids Taste Like Bacon',
12 'Life on Planet Mercury: Tan, Relaxing and Fabulous',
13 'Light Speed Travel: Fountain of Youth or Fallacy',
14 ];
15
16 private static $articleImages = [
17 'asteroid.jpeg',
18 'mercury.jpeg',
19 'lightspeed.png',
20 ];
21
22 private static $articleAuthors = [
23 'Mike Ferengi',
24 'Amy Oort',
25 ];
... lines 26 - 64
65 }
These represent some realistic article titles, article images that exist, and two article authors. So, instead of
making completely random titles, authors and images, we'll randomly choose from this list.
But even here, Faker can help us. For title, say $this->faker->randomElement() and pass self::$articleTitles :
66 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
... lines 10 - 26
27 public function loadData(ObjectManager $manager)
28 {
29 $this->createMany(Article::class, 10, function(Article $article, $count) {
30 $article->setTitle($this->faker->randomElement(self::$articleTitles))
... lines 31 - 60
61 });
... lines 62 - 63
64 }
65 }
66 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
... lines 10 - 26
27 public function loadData(ObjectManager $manager)
28 {
29 $this->createMany(Article::class, 10, function(Article $article, $count) {
30 $article->setTitle($this->faker->randomElement(self::$articleTitles))
31 ->setSlug($this->faker->slug)
... lines 32 - 60
61 });
... lines 62 - 63
64 }
65 }
The slug will now be totally different than the article title, but honestly, who cares?
For author, do the same thing: $this->faker->randomElement() and pass self::$articleAuthors :
66 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
... lines 10 - 26
27 public function loadData(ObjectManager $manager)
28 {
29 $this->createMany(Article::class, 10, function(Article $article, $count) {
... lines 30 - 56
57 $article->setAuthor($this->faker->randomElement(self::$articleAuthors))
... lines 58 - 59
60 ;
61 });
... lines 62 - 63
64 }
65 }
Copy that, and repeat it one more time for the imageFile , this time using self::$articleImages :
66 lines src/DataFixtures/ArticleFixtures.php
... lines 1 - 7
8 class ArticleFixtures extends BaseFixture
9 {
... lines 10 - 26
27 public function loadData(ObjectManager $manager)
28 {
29 $this->createMany(Article::class, 10, function(Article $article, $count) {
... lines 30 - 56
57 $article->setAuthor($this->faker->randomElement(self::$articleAuthors))
... line 58
59 ->setImageFilename($this->faker->randomElement(self::$articleImages))
60 ;
61 });
... lines 62 - 63
64 }
65 }
Awesome! Let's go reload those fixtures!
No errors! Find your browser and, try it! Oh, it's so much better.
If creating nice, random data seems like a small thing, it's not! Having rich data that you can easily load will
increase your ability to create new features and fix bugs fast. It's totally worth it.
Next! Let's install a really cool library with automatic slugging super-powers.
Chapter 16: Sluggable & other Wonderful Behaviors
We're using Faker to generate a random slug for each dummy article. Thanks to this, back on the homepage,
look at the URLs: they're truly random slugs: they have no relation to the title.
But, really, shouldn't the slug be generated from the title? What I mean is, if I set the Article's title, something
should automatically convert that into a slug and make sure it's unique in the database. We shouldn't need to
worry about doing that manually.
And... yea! There's a really cool library that can do this, and a bunch of other magic! Google for
StofDoctrineExtensionsBundle , and then click into its documentation.
Ok, let me explain something: there is a normal, PHP library called DoctrineExtension, which can add a lot of
different behaviors to your entities, like sluggable, where you automatically generate the slug from another
field. Other behaviors include Loggable, where each change to an entity is tracked, or Blameable, where the
user who created or updated an entity is automatically recorded.
Installing StofDoctrineExtensionsBundle
This bundle - StofDoctrineExtensionsBundle - helps to integrate that library into a Symfony project. Copy the
composer require line, find your terminal, and paste!
While that's working, let's go check out the documentation. This is a wonderful library, but its documentation is
confusing . So, let's navigate to the parts we need. Scroll down to find a section called "Activate the extensions
you want".
As we saw, there are a lot of different, possible behaviors. For performance reasons, when you install this
bundle, you need to explicitly say which behaviors you want, like timestampable , by setting it to true.
Contrib Recipes
Move back to the terminal to see if things are done. Oh, interesting! It stopped! And it's asking us if we want to
install the recipe for StofDoctrineExtensionsBundle. Hmm... that's weird... because Flex has already installed
many other recipes without asking us a question like this.
But! It says that the recipe for this package comes from the "contrib" repository, which is open to community
contributions. Symfony has two recipe repositories. The main repository is closely controlled for quality. The
second - the "contrib" repository - has some basic checks, but the community can freely contribute recipes. For
security reasons, when you download a package that installs a recipe from that repository, it will ask you first
before installing it. And, there's a link if you want to review the recipe.
Configuring Sluggable
Thanks to this, we now have a shiny new config/packages/stof_doctrine_extensions.yaml file:
5 lines config/packages/stof_doctrine_extensions.yaml
1 # Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
2 # See the official DoctrineExtensions documentation for more details: https://github.com/Atlantic18/DoctrineExtensions/tree/maste
3 stof_doctrine_extensions:
4 default_locale: en_US
This is where we need to enable the extensions we want. We want sluggable . We can use the example in the
docs as a guide. Add orm , then default . The default is referring to the default entity manager... because some
projects can actually have multiple entity managers. Then, sluggable: true :
8 lines config/packages/stof_doctrine_extensions.yaml
1 # Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
2 # See the official DoctrineExtensions documentation for more details: https://github.com/Atlantic18/DoctrineExtensions/tree/maste
3 stof_doctrine_extensions:
4 default_locale: en_US
5 orm:
6 default:
7 sluggable: true
As soon as we do this... drumroll... absolutely nothing will happen. Ok, behind the scenes, the bundle is now
looking for slug fields on our entities. But, we need a little bit more config to activate it for Article . Open that
class and find the slug property.
Now, go back to the documentation. Another confusing thing about this bundle is that the documentation is
split in two places: this page shows you how to configure the bundle... but most of the docs are in the library.
Scroll up and find the DoctrineExtensions Documentation link.
Awesome. Click into sluggable.md . Down a bit... it tells us that to use this feature, we need to add an
@Gedmo\Slug() annotation above the slug field. Let's do it! Use @Gedmo\Slug , then fields={"title"} :
That's all we need! Back in ArticleFixtures , we no longer need to set the slug manually. Try it out: find your
terminal, and load those fixtures!
No errors! That's a really good sign, because the slug column is required in the database. Go back to the
homepage and... refresh! Brilliant! The slug is clean and clearly based off of the title! As an added benefit, look
at how some of these have a number on the end. The Sluggable behavior is making sure that each slug is
unique. So, if a slug already exists in the database, it adds a -1 , -2 , -3 , etc. until it finds an open space.
If you ever need to do something automatically when an entity is added, updated or removed, think of this
system.
Next, let's find out how to rescue things when migrations go wrong!
Chapter 17: When Migrations Fail
My other favorite Doctrine Extension behavior is timestampable. Go back to the library's documentation and
click to view the Timestampable docs.
Oh, it's so nice: with this behavior, we can add $createdAt and $updatedAt fields to our entity, and they will be
automatically set. Believe me, this will save your butt sometime in the future when something happens on
your site you can't quite explain. A mystery!
Update the Article entity and add createdAt , as a datetime , and say "no" to nullable: this should always be
populated. Do the same thing for updatedAt : it should also always be set: it will match createdAt when the
entity is first saved. Hit enter to finish adding fields:
Awesome! Move over and open that file. Yep, this looks good: an ALTER TABLE to add created_at and
updated_at :
29 lines src/Migrations/Version20180418130337.php
... lines 1 - 2
3 namespace DoctrineMigrations;
4
5 use Doctrine\DBAL\Migrations\AbstractMigration;
6 use Doctrine\DBAL\Schema\Schema;
7
8 /**
9 * Auto-generated Migration: Please modify to your needs!
10 */
11 class Version20180418130337 extends AbstractMigration
12 {
13 public function up(Schema $schema)
14 {
15 // this up() migration is auto-generated, please modify it to your needs
16 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
17
18 $this->addSql('ALTER TABLE article ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME NOT NULL');
19 }
20
21 public function down(Schema $schema)
22 {
23 // this down() migration is auto-generated, please modify it to your needs
24 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
25
26 $this->addSql('ALTER TABLE article DROP created_at, DROP updated_at');
27 }
28 }
Hmm. The problem is that our database already has articles. So when MySQL tries to create a new datetime
column that is not nullable, it has a hard time figuring out what value to put for those existing rows!
Yep, unfortunately, sometimes, migrations fail. And fixing them is a delicate process. Let's think about this.
What we really want to do is create those columns, but allow them to be null... at first. Then, we can update
both fields to today's date. And, then we can use another ALTER TABLE query to finally make them not null.
That's totally doable! And we just need to modify the migration by hand. Instead of NOT NULL , use
DEFAULT NULL . Do the same for updated_at :
30 lines src/Migrations/Version20180418130337.php
... lines 1 - 10
11 class Version20180418130337 extends AbstractMigration
12 {
13 public function up(Schema $schema)
14 {
15 // this up() migration is auto-generated, please modify it to your needs
16 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
17
18 $this->addSql('ALTER TABLE article ADD created_at DATETIME DEFAULT NULL, ADD updated_at DATETIME DEFAULT NULL'
... line 19
20 }
... lines 21 - 28
29 }
30 lines src/Migrations/Version20180418130337.php
... lines 1 - 10
11 class Version20180418130337 extends AbstractMigration
12 {
13 public function up(Schema $schema)
14 {
15 // this up() migration is auto-generated, please modify it to your needs
16 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
17
18 $this->addSql('ALTER TABLE article ADD created_at DATETIME DEFAULT NULL, ADD updated_at DATETIME DEFAULT NULL'
19 $this->addSql('UPDATE article SET created_at = NOW(), updated_at = NOW()');
20 }
... lines 21 - 28
29 }
We still need another query to change things back to not null, but don't do it yet: we can be lazy. Instead, find
your terminal: let's try the migration again. But, wait! You may or may not be able to re-run the migration
immediately. In this case, the original migration had only one query, and that one query failed. This means that
no part of the migration executed successfully.
But sometimes, a migration may contain multiple lines of SQL. And, if the second or third line fails, then, well,
we're in a really weird state! In that situation, if we tried to rerun the migration, the first line would execute for
the second time, and it would probably fail.
Basically, when a migration fails, it's possible that your migration system is now in an invalid state. When that
happens, you should completely drop your database and start over. You can do that with:
And then:
And then you can migrate. Anyways, we are not in an invalid state: so we can just re-try the migration:
$ php bin/console doctrine:migrations:migrate
And this time, it works! To finally make the fields not nullable, we can ask Doctrine to generate a new
migration:
Go check it out!
29 lines src/Migrations/Version20180418130730.php
... lines 1 - 2
3 namespace DoctrineMigrations;
4
5 use Doctrine\DBAL\Migrations\AbstractMigration;
6 use Doctrine\DBAL\Schema\Schema;
7
8 /**
9 * Auto-generated Migration: Please modify to your needs!
10 */
11 class Version20180418130730 extends AbstractMigration
12 {
13 public function up(Schema $schema)
14 {
15 // this up() migration is auto-generated, please modify it to your needs
16 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
17
18 $this->addSql('ALTER TABLE article CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DA
19 }
20
21 public function down(Schema $schema)
22 {
23 // this down() migration is auto-generated, please modify it to your needs
24 $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely o
25
26 $this->addSql('ALTER TABLE article CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_
27 }
28 }
Ha! Nice! It simply changes the fields to be NOT NULL. Run it!
Ok, let's add Timestampable! First, we need to activate it, which again, is described way down on the bundle's
docs. Open config/packages/stof_doctrine_extensions.yaml , and add timestampable: true :
9 lines config/packages/stof_doctrine_extensions.yaml
... lines 1 - 2
3 stof_doctrine_extensions:
4 default_locale: en_US
5 orm:
6 default:
7 sluggable: true
8 timestampable: true
Second, your entity needs some annotations. For this, go back to the library's docs. Easy enough: we just need
@Gedmo\Timestampable .
Back in our project, open Article and scroll down to find the new fields. Above createdAt , add @Timestampable()
with on="create" :
That should be it! Find your terminal, and reload the fixtures!
Yes! They are set! And each time we update, the updated_at will change.
Yea! Hold Command or Ctrl and click to see that. Awesome: this contains the exact same code that we had
before! If you want Timestampable, just use this trait, generate a migration and... done!
And, talking about migrations, there could be some slight column differences between these columns and the
original ones we created. Let's check that. Run:
Cool! The fields in the trait are identical to what we had before. That means that we can already test things
with:
Up Next: Relations!
Ok guys! I hope you are loving Doctrine! We just got a lot of functionality fast. We have magic - like
Timestampable & Sluggable - rich data fixtures, and a rocking migration system.
One thing that we have not talked about yet is production config. And... that's because it's already setup. The
Doctrine recipe came with its own config/packages/prod/doctrine.yaml config file, which makes sure that anything
that can be cached easily, is cached:
32 lines config/packages/prod/doctrine.yaml
1 doctrine:
2 orm:
3 metadata_cache_driver:
4 type: service
5 id: doctrine.system_cache_provider
6 query_cache_driver:
7 type: service
8 id: doctrine.system_cache_provider
9 result_cache_driver:
10 type: service
11 id: doctrine.result_cache_provider
12
13 services:
14 doctrine.result_cache_provider:
15 class: Symfony\Component\Cache\DoctrineProvider
16 public: false
17 arguments:
18 - '@doctrine.result_cache_pool'
19 doctrine.system_cache_provider:
20 class: Symfony\Component\Cache\DoctrineProvider
21 public: false
22 arguments:
23 - '@doctrine.system_cache_pool'
24
25 framework:
26 cache:
27 pools:
28 doctrine.result_cache_pool:
29 adapter: cache.app
30 doctrine.system_cache_pool:
31 adapter: cache.system
The other huge topic that we have not talked about yet is Doctrine relations. But, we should totally talk about
those - they're awesome! So let's do that in our next tutorial, with foreign keys, join queries and high-fives so
that we can create a really rich database.
Alright guys, seeya next time.