Course Symfony Doctrine
Course Symfony Doctrine
Well hey friends! And bienvenidos to our tutorial about learning Spanish! What? That's next week? Doctrine?
Ah: welcome to our tutorial all about making Symfony talk to a database... in English.
We learned a ton in the first two courses of this series, especially the last tutorial where we demystified services, autowiring
and configuration. That hard work is about to pay off as we take our app to the next level by adding a database. That's going
to make things way, way more interesting.
And not only is Doctrine powerful, but it's also easy to use. I'll admit that this was not always the case. But Doctrine is now
more accessible and fun to use than ever before. I think you're going to love it.
Project Setup
To learn the most about Doctrine - and to become the third amigo - you should definitely code along with me by downloading
the course code from this page. After you unzip the file, you'll find a start/ directory with the same code that you see here.
Check out this README.md file for all the setup fun!
The last step will be to open a terminal and use the Symfony binary to start a local web server - you can download the binary
at https://symfony.com/download. Run:
symfony serve -d
This starts a web server in the background on port 8000. I'll copy the URL, spin over to my browser and say hello to...
Cauldron Overflow! Our question and answer site for witches and wizards: a place to debug what went wrong when you tried
to make your cat invisible and instead made your car invisible.
So far, we have a homepage that lists questions and you can view each individual question and its answers. But... this is all
hardcoded! None of this is coming from a database... yet. That is our job.
Installing Doctrine
Now, remember: Symfony starts small: it does not come with every feature and library that you might ever need. And so,
Doctrine is not installed yet.
Auto-Unpacked Packs
Let's... "unpack" this command!
First, orm is one of those Symfony Flex aliases. We only need to say composer require orm but, in reality, this is a shortcut for
a library called symfony/orm-pack.
Also, we talked about "packs" in a previous course. A pack is a, sort of, fake package that exists simply to help you install
several other packages.
Let me show you: copy the name of the package, and go open it in GitHub: https://github.com/symfony/orm-pack. Yep! It's
nothing more than a single composer.json file! The whole point of this library is that it requires a few other packages. That
means that we can composer require this one package, but in reality, we will get all four of these libraries.
Now, one of the other packages that we have in our project is symfony/flex, which is what powers the alias and recipe
systems. Starting in symfony/flex version 1.9 - which I am using in this project - when you install a pack, Flex does something
special.
Go and look at your composer.json file. What you would expect to see is one new line for symfony/orm-pack: the one library
that we just required. In reality, Composer would also download its 4 dependencies... but only the pack would show up here.
But... surprise! Instead of symfony/orm-pack, the 4 packages it requires are here instead!
84 lines composer.json
{
... lines 2 - 3
"require": {
... lines 5 - 7
"composer/package-versions-deprecated": "^1.8",
"doctrine/doctrine-bundle": "^2.1",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.7",
... lines 12 - 26
},
... lines 28 - 82
}
Here's the deal: before symfony/flex 1.9, when you required a pack, nothing special happened: Composer added the one
new package to composer.json. But starting in symfony/flex 1.9, instead of adding the pack, it adds the individual libraries
that the pack requires: these 4 lines. It does this because it makes it much easier for us to manage the versions of each
package independently.
The point is: a pack is nothing more than a shortcut to install several packages. And in the latest version of Flex, it adds those
"several" packages to your composer.json file automatically to make life easier.
To see what the recipes did, I'll clear my screen and say:
git status
Ok: in addition to the normal files that we expect to be modified, the recipe also modified .env and created some new files.
Go check out .env. At the bottom... here it is: it added a new DATABASE_URL. This is the environment variable that Doctrine
uses to connect to the database.
Tip
The default DATABASE_URL now uses PostgreSQL, but there is a commented-out MySQL example above if you prefer
that. But in both cases, if you use our Docker integration (keep watching!) then you won't need to configure
DATABASE_URL manually.
30 lines .env
... lines 1 - 22
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-
dbal/en/latest/reference/configuration.html#connecting-using-a-url
# For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
# For a PostgreSQL database, use: "postgresql://db_user:[email protected]:5432/db_name?
serverVersion=11&charset=utf8"
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
DATABASE_URL=mysql://db_user:[email protected]:3306/db_name?serverVersion=5.7
###
And... we can see this! The recipe also added another file called config/packages/doctrine.yaml
This file is responsible for configuring DoctrineBundle. And you can actually see that this doctrine.dbal.url key points to the
environment variable! We won't need to do much work in this file, but I wanted you to see that the environment variable is
passed to the bundle.
19 lines config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '5.7'
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
The recipe also added a few directories src/Entity/, src/Repository/, and migrations/, which we'll talk about soon.
So all we need to do to start working with Doctrine is configure this DATABASE_URL environment variable to point to a
database that we have running somewhere.
To do that, we're going to do something special in this tutorial. Instead of telling you to install MySQL locally, we're going to
use Docker. If you already use Docker, great! But if you haven't used Docker... or you tried it and didn't like it, give me just a
few minutes to convince you - I think you're going to love how Symfony integrates with Docker. That's next!
Chapter 2: make:docker:database
Doctrine is installed! Woo! Now we need to make sure a database is running - like MySQL or PostgreSQL - and then update
the DATABASE_URL environment variable to point to it.
30 lines .env
... lines 1 - 27
DATABASE_URL=mysql://db_user:[email protected]:3306/db_name?serverVersion=5.7
... lines 29 - 30
So: you can absolutely start a database manually: you can download MySQL or PostgreSQL onto your machine and start it.
Or you can use Docker, which is what we will do. OooOoooo.
Using Docker?
Now, hold on: if you're nervous about Docker... or you haven't used it much... or you used it and hated it, stay with me! Using
Docker is optional for this tutorial, but we're going to use it in a very lightweight way.
The only requirement to get started is that you need to have Docker downloaded and running on your machine. I already
have Docker running on my machine for Mac.
Docker is all about creating tiny containers - like a container that holds a MySQL instance and another that holds a PHP
installation. Traditionally, when I think of Docker, I think of a full Docker setup: a container for PHP, a container for MySQL
and another container for Nginx - all of which communicate to each other. In that situation, you don't have anything installed
on your "local" machine except for Docker itself.
That "full Docker" setup is great - and, if you like it, awesome. But it also adds complexity: sharing source code with the
containers can make your app super slow - especially on a Mac - and if you need to run a bin/console command, you need to
execute that from within a Docker container.
For me, it's kind of the best of both worlds: it makes it super easy to launch services like MySQL... but without the complexity
that often comes with Docker.
Hello make:docker:database
Ok, ready? To manage our Docker containers, we need to create a docker-compose.yaml file that describes what we need.
That file is pretty simple but... let's cheat! Find your terminal and run:
This command comes from MakerBundle version 1.20... and I love it. A big thanks to community member Jesse Rushlow for
contributing this!
Ok: it doesn't see a docker-compose.yaml file, so it's going to create a new one. I'll use MySQL and, for the version - I'll use
latest - we'll talk more about that in a few minutes.
Well, in reality, the only thing this command did was create a docker-compose.yaml file: it didn't communicate with Docker or
start any containers - it just created this new docker-compose.yaml file.
13 lines docker-compose.yaml
version: '3.7'
services:
database:
image: 'mysql:latest'
environment:
MYSQL_ROOT_PASSWORD: password
ports:
# To allow the host machine to access the ports below, modify the lines below.
# For example, to allow the host to connect to port 3306 on the container, you would change
# "3306" to "3306:3306". Where the first port is exposed to the host and the second is the container port.
# See https://docs.docker.com/compose/compose-file/#ports for more information.
- '3306'
And... it's pretty basic: we have a service called database - that's just an internal name for it - which uses a mysql image at its
latest version. And we're setting an environment variable in the container that makes sure the root user password is...
password! At the bottom, the ports config means that port 3306 of the container will be exposed to our host machine.
That last part is important: this will make it possible for our PHP code to talk directly to MySQL in that container. This syntax
actually means that port 3306 of the container will be exposed to a random port on our host machine. Don't worry: I'll show
you exactly what that means.
docker-compose up
So... yay! We have a docker-compose.yaml file! To start all of the containers that are described in it... which is just one - run:
docker-compose up -d
The -d means "run as a daemon" - it runs in the background instead of holding onto my terminal.
The first time you run this it will take a bit longer because it needs to download MySQL. But eventually.... yes! With one
command, we now have a database running in the background!
So... how do we communicate with it? Next, let's learn a bit more about docker-compose including how to connect to the
MySQL instance and shut down the container.
Chapter 3: docker-compose & Exposed Ports
We just started our MySQL docker container thanks to docker-compose. So... ah... now what? How can we talk to that
database? Great question!
docker-compose
This lists all the commands that you can use with docker-compose. Most of these we won't need to worry about. But one
good one is ps, which stands for "process status". Try it:
docker-compose ps
This shows all the containers that docker-compose is running for this project... which is just one right now. Ah, and check this
out! Port 3306 of the container is being shared to our local machine on port 32773. This is a random port number that will be
different each time we restart the container.
Boom! We are inside of the container talking to MySQL! By the way, if you don't have MySQL installed locally, you can also
do this by running:
That will "execute" the mysql command inside the container that's called database.
SHOW DATABASES
docker-compose stop
docker-compose down
This loops through all of the services in docker-compose.yaml and, not only stops each container, but also removes its
image. It's like completely deleting that "mini server" - including its data.
docker-compose up -d
It will create the whole container from scratch: any data from before will be gone.
docker-compose down
to stop and destroy the container. If we try to connect to MySQL now it, of course, fails. Now run:
docker-compose up -d
docker-compose ps
Ah! Look at that port! It was 32773 the first time we ran it. Now the container is exposed on port 32775. Let's try connecting:
Ah. The truth is that, even though it looks like docker-compose up is instant, in reality, it takes a few seconds for MySQL to
truly start. Eventually if we try again...
Yes! We are in! But you won't see the docker_coolness database that we created earlier because docker-compose down
destroyed our data.
At this point, we've created a docker-compose.yaml file and used docker-compose to launch a MySQL container that we can
talk to. Awesome!
To connect to this from our Symfony app, all we need to do is update the DATABASE_URL environment variable to use the
right password and port.
But... we're not going to do that. It would work... but it turns out that our app is already aware of the correct DATABASE_URL
value... even though we haven't configured anything. Let's talk about how next.
Chapter 4: docker-compose Env Vars & Symfony
Thanks to our docker-compose.yaml file and the docker-compose up command, we started a MySQL container in Docker.
You can prove it by running:
docker-compose ps
Yep! Port 3306 of the container is being exposed to my host machine on port 32776, which is a random port that will change
each time we run docker-compose up.
Normally I would copy DATABASE_URL, go into .env.local, paste, and update it to whatever my local settings are, like root
user, no password and a creative database.
Let me show you. When we started our app, we used the Symfony binary to create a local web server. I'll run:
symfony server:stop
to stop it... just so I can show you the command we used. It was:
symfony serve -d
Tip
If you're using Ubuntu and running Docker in "root" mode, then you will need to run sudo symfony serve -d for Symfony to
see the Docker environment variables. Later, when we use things like symfony console, you will also need to use sudo. Note
that this may cause some cache file ownership issues in your Symfony app while developing. Ok, have fun!
That started a web server at localhost:8000. So: what we're seeing in the browser is being served by the symfony binary.
Well... surprise! The Symfony binary has special integration with Docker! It detects that we have a docker-compose.yaml file
in this project, loops over all of the running services, reads their config and exposes real environment variables to our app
with the connection details for each one.
For example, because this service is called database - we technically could have called it anything - the Symfony binary is
already exposing an environment variable called DATABASE_URL: the exact environment variable that Doctrine is looking
for.
Back at your editor, open up public/index.php: the front controller for our project. We normally don't need to mess with this
file... but let's temporarily hack in some code. After the autoload line, add dd($_SERVER).
33 lines public/index.php
... lines 1 - 7
require dirname(__DIR__).'/vendor/autoload.php';
dd($_SERVER);
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
... lines 13 - 33
The $_SERVER global variable holds a lot of things including any real environment variables that are passed to PHP. Back
at the browser, refresh and search for "database". Check it out! A DATABASE_URL environment variable!
That is being set by the Symfony binary, which is reading the info dynamically from Docker. It has all the correct info
including port 32776.
When a real environment variable exists, it overrides the value that you have in .env or .env.local. In other words, as soon as
we run docker-compose up, our app has access to a DATABASE_URL environment variable that points to the Docker
container. We don't need to configure anything!
31 lines public/index.php
... lines 1 - 7
require dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
... lines 11 - 31
Another way to see what environment variables the Symfony binary is exporting to our app is by running:
And... yes! This has DATABASE_URL and some other DATABASE variables that you can use for each part... if you need to.
If we added a second service to docker-compose - like a Redis container - then that would show up here too.
The big picture is this: all we need to do is run docker-compose up -d and our Symfony app is immediately setup to talk to the
database. I love that.
But... we can't really do anything yet... because the MySQL instance is empty! Next, let's create our database and make sure
that Doctrine knows exactly which version of MySQL we're using.
Chapter 5: doctrine:database:create & server_version
We have a Docker database container running and our app is instantly configured to talk to it thanks to the Symfony web
server. But... we can't really do anything yet... because that MySQL instance is empty! In var:export, you can see that the
database name is apparently "main". But that does not exist yet.
No problem! When we installed Doctrine, it added a bunch of new bin/console commands to our app. Run:
php bin/console
and scroll up to find a huge list that start with doctrine:. The vast majority of these are not very important - and we'll talk about
the ones that are.
And... yikes!
Huh. For some reason, it's using this DATABASE_URL from .env instead of the one that's set by the Symfony binary.
30 lines .env
... lines 1 - 27
DATABASE_URL=mysql://db_user:[email protected]:3306/db_name?serverVersion=5.7
... lines 29 - 30
The problem is that, when you load your site in the browser, this is processed through the Symfony web server. That allows
the Symfony binary to inject all of the environment variables.
But when you just run a random bin/console command, that does not use the symfony binary. And so, it does not have an
opportunity to add the environment variables.
php bin/console
We'll run:
symfony console
symfony console literally means bin/console... but because we're running it through the Symfony executable, it will inject the
environment variables that are coming from Docker.
Tip
The latest version of MakerBundle generates a MYSQL_DATABASE: main config into your docker-compose.yaml file. If you
have this, then the database will already be created! Feel free to run the command just in case ;).
So:
13 lines docker-compose.yaml
... line 1
services:
database:
image: 'mysql:latest'
... lines 5 - 13
Google for Docker hub to find https://hub.docker.com. When you say that you want a mysql image at version latest, Docker
communicates back to Docker Hub to get the details. Search for MySQL for all the info about that image including the tags
that are currently available. Right now, the latest tag is equal to 8.0.
Head back over to docker-compose.yaml. You don't have to do this, but I'm going to change latest to 8.0 so that I'm locked at
a specific version that won't suddenly change.
13 lines docker-compose.yaml
... line 1
services:
database:
image: 'mysql:8.0'
... lines 5 - 13
Over at the terminal, even though latest and 8.0 are technically the same image, let's restart docker-compose anyways to
update the image. Run:
docker-compose down
And then:
docker-compose up -d
It quickly downloaded the new image... which was probably just a "pointer" to the same image we used before.
Setting server_version
Now that we've set the MySQL version in Docker, we should also do the same thing with Doctrine. Open up
config/packages/doctrine.yaml. See that server_version key?
19 lines config/packages/doctrine.yaml
doctrine:
dbal:
... lines 3 - 4
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '5.7'
... lines 8 - 19
Set this to 8.0. If you're using mariadb, you can use a format like mariadb-10.5.4.
19 lines config/packages/doctrine.yaml
doctrine:
dbal:
... lines 3 - 6
server_version: '8.0'
... lines 8 - 19
This is... kind of an annoying thing to set, but it is important. It tells Doctrine what version of MySQL we're running so that it
knows what features are supported. It uses that to adjust the exact SQL it generates. Make sure that your production
database uses this version or higher.
Doctrine is an ORM: an object relational mapper. That's a fancy way of saying that, for each table in the database, we will
have a corresponding class in PHP. And for each column on that table, there will be a property in that class. When you query
for a row in a table, Doctrine will give you an object with that row's data set on the properties.
So if you want to create a database table in Doctrine, the way you do that is actually by creating the class it will map to.
These "classes" that map to tables have a special name: entity classes.
make:entity
You can create an entity class by hand - there's nothing very special about them. But... come on! There's a much easier way.
Find your terminal and run one of my favorite bin/console commands:
It doesn't matter in this case, because this command won't talk directly to the database, and so, it doesn't need the
environment variables. This command just generates code.
Now remember: the whole point of our site is for witches and wizards to ask questions and then post answers. So the very
first thing we need is a Question entity. So, a question table in the database.
Enter Question. The command immediately starts asking what properties we want, which really, also means what columns
we want in the table. Let's add a few. Call the first one name - that will be the short name or "title" of the question like
"Reversing a Spell". The command then asks what "type" we want. Doctrine has its own type system: enter "?" to see the full
list.
These aren't MySQL column types, but each maps to one. For example, a string maps to a VARCHAR in MySQL. And there
are a bunch more.
In our case, choose string: that's good for any text 255 characters or less. For the column length, I'll use the default 255. The
next question says:
Say "No". This means that the column will be required in the database.
And... congrats! We just added our first field! Let's add a few more. Call the next slug: this will be the URL-safe version of the
name that shows up in the URL. It will also be a string, let's set its length to 100 and, once again, I'll say "no" to "nullable".
For the actual body of the question, let's call it question. This will be long, so we can't use the string type. Instead, use text...
and make this also required in the database.
Add one more field: askedAt. This will be a date field - kind of like a "published at" field. For the type, ooh! It recommends
datetime - that is exactly what we want! Hit enter and this time say "yes" to nullable. The idea is that users will be able to start
writing a question and save it to the database with a null askedAt because it's not finished. When the user is finished and
ready to post it, then we would set that value.
And... we're done! Hit enter one more time to exit make:entity.
If you scroll back up to the top of the command, you can see that it created 2 files: src/Entity/Question.php and
src/Repository/QuestionRepository.php.
For now, completely ignore the repository class: it's not important yet and we'll talk about it later.
93 lines src/Entity/Question.php
... lines 1 - 2
namespace App\Entity;
use App\Repository\QuestionRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=QuestionRepository::class)
*/
class Question
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $name;
/**
* @ORM\Column(type="string", length=100)
*/
private $slug;
/**
* @ORM\Column(type="text")
*/
private $question;
/**
* @ORM\Column(type="datetime", nullable=true)
*/
private $askedAt;
... lines 39 - 91
}
The first thing I want you to notice is that... there's nothing special about this class. It's a boring, normal class... it doesn't even
extend a base class! It has several private properties and, to access those, the command generated getter and setter
methods, like getName(), getSlug() and setSlug().
93 lines src/Entity/Question.php
... lines 1 - 10
class Question
{
... lines 13 - 39
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getQuestion(): ?string
{
return $this->question;
}
public function setQuestion(string $question): self
{
$this->question = $question;
return $this;
}
public function getAskedAt(): ?\DateTimeInterface
{
return $this->askedAt;
}
public function setAskedAt(?\DateTimeInterface $askedAt): self
{
$this->askedAt = $askedAt;
return $this;
}
}
It's just about the most boring class you'll ever see.
But of course, if Doctrine is going to map this class and its properties to a database table, it's needs to know a few things. For
example, it needs to know that the name property should map to a name column and that its type is a string.
93 lines src/Entity/Question.php
... lines 1 - 10
class Question
{
... lines 13 - 19
/**
* @ORM\Column(type="string", length=255)
*/
private $name;
... lines 24 - 91
}
The way that Doctrine does this is by reading annotations. Well, you can also use XML, but I love annotations.
For example, the @ORM\Entity() above the class is what actually tells Doctrine:
93 lines src/Entity/Question.php
... lines 1 - 7
/**
* @ORM\Entity(repositoryClass=QuestionRepository::class)
*/
class Question
{
... lines 13 - 91
}
Hey! This is not just a normal, boring PHP class. This is an "entity": a class that I want to be able to store in the
database.
Then, the @ORM\Column() above the properties is how Doctrine knows which properties should be stored in the table and
their types.
Annotations Reference
Now, there are a bunch of different annotations and options you can use to configure Doctrine. Most are pretty simple - but let
me show you an awesome reference. Search for doctrine annotations reference to find a really nice spot on their site where
you can see a list of all the different possible annotations and their options.
For example, @Column() tells you all the different options that you can pass to it, like a name option. So if you wanted to
control the name of the slug column - instead of letting Doctrine determine it automatically - you would do that by adding a
name option.
One of the other annotations that you'll see here is @Table. Oh, and notice, in the docs, the annotation will be called
@Table. But inside Symfony, we always use @ORM\Table. Those are both referring to the same thing.
Anyways, if you wanted to control the name of the table, we could say @ORM\Table() and pass it name="" with some cool
table name.
But I won't bother doing that because Doctrine will guess a good table name from the class. Oh, and by the way, the auto-
completion that you're seeing on the annotations comes from the "PHP Annotations" plugin in PhpStorm.
Ok: status check! The make:entity command helped us create this new entity class, but it did not talk to the database. There
is still no question table.
How do we create the table? By generating a migration. Doctrine's migrations system is amazing. It will even allow us to
perform table changes with basically zero work. Let's find out how next.
Chapter 7: Migrations
We have a beautiful new Question entity class that is supposed to map to a question table in the database. But... that table
does not exist yet. How can we create it?
Well, because Doctrine has all of this configuration about the entity, like the fields and field types, it should - in theory - be
able to create the table for us. And... it absolutely can!
Hello make:migration
The mechanism we use to make database structure changes is called migrations. At your terminal, run:
Of course: the command doesn't have access to the Docker environment variables. I meant to run:
This time... cool! It generated a new file inside of a migrations/ directory. Let's go check it out! In migrations/ open the one new
file and... awesome! It has an up() method with the exact SQL we need!
32 lines migrations/Version20200707173854.php
... lines 1 - 4
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20200707173854 extends AbstractMigration
{
public function getDescription() : string
{
return '';
}
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE question (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL, question LONGTEXT NOT NULL, asked_at DATETIME DEFAULT NULL, PRIMARY
KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE question');
}
}
CREATE TABLE question...
The make:migration command is smart: it compares the actual database - which has zero tables at the moment - with all of
our entity classes - just one right now - and then generates the SQL needed to make the database match those entities.
It saw the one Question entity... but no question table, and so, it generated the CREATE TABLE statement.
Executing Migrations
But this query has not been executed yet. To do that, run:
This shows all the migrations in your app, which is just one right now. Next to that migration is says "Status Migrated". How
does it know that?
Behind the scenes, the migration system created a table in the database called doctrine_migration_versions. Each time it
executes a migration file, it adds a new row to that table that records that it was executed.
again... it's smart enough to not execute the same migration twice. It looks at the table, sees that it already ran this, and skips
it.
When you deploy to production, you'll also run doctrine:migrations:migrate. When you do that, it will check the
doctrine_migration_versions table in the production database and execute any new migrations.
93 lines src/Entity/Question.php
... lines 1 - 10
class Question
{
... lines 13 - 24
/**
* @ORM\Column(type="string", length=100, unique=true)
*/
private $slug;
... lines 29 - 91
}
That won't change how our PHP code behaves - this doesn't relate to form validation or anything like that. This simply tells
Doctrine:
Of course... just making this change did not somehow magically add the unique constraint to the database. To do that, we
need to generate another migration.
32 lines migrations/Version20200707174149.php
... lines 1 - 4
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20200707174149 extends AbstractMigration
{
public function getDescription() : string
{
return '';
}
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE UNIQUE INDEX UNIQ_B6F7494E989D9B62 ON question (slug)');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX UNIQ_B6F7494E989D9B62 ON question');
}
}
And... woh! It's a CREATE UNIQUE INDEX statement for the slug column! The migrations system compared the question
table in the database to the Question entity, determined that the only difference was a missing unique index and then
generated the SQL to add it. Honestly, that's amazing.
This sees both migrations, but only runs the one that hasn't been executed yet. The slug column is now unique in the
database.
So this is the workflow: create a new entity or change an existing entity, run make:migration, and then execute it with
doctrine:migrations:migrate. This keeps your database in sync with your entity classes and give you a set of migrations that
you can run when you deploy to production.
Next: it's time to create some Question objects in PHP and see how we can save those to the question table.
Chapter 8: Persisting to the Database
We have a beautiful entity class and, thanks to the migrations that we just executed, we have a corresponding question table
in the database. Time to insert some data!
So instead of asking:
We need to think:
Let's create a Question object, populate it with data and then ask Doctrine to save it.
Open up src/Controller/QuestionController.php, which already holds the homepage and show page. At the bottom, add
public function and... let's call it new(). Above, say @Route() with /questions/new.
64 lines src/Controller/QuestionController.php
... lines 1 - 10
class QuestionController extends AbstractController
{
... lines 13 - 55
/**
* @Route("/questions/new")
*/
public function new()
{
... line 61
}
}
To keep things simple, return a new Response() - the one from HttpFoundation - with Time for some Doctrine magic!
64 lines src/Controller/QuestionController.php
... lines 1 - 7
use Symfony\Component\HttpFoundation\Response;
... lines 9 - 10
class QuestionController extends AbstractController
{
... lines 13 - 55
/**
* @Route("/questions/new")
*/
public function new()
{
return new Response('Time for some Doctrine magic!');
}
}
There's no Doctrine logic yet, but this should work. At the browser, hit enter and... woh! It doesn't work! There's no error, but
this is not the page we expected. It looks like the question show page. And, in fact, if you look down on the web debug
toolbar... yea! The route is app_question_show!
The problem is that the url /questions/new does match this route! It look like "new" is the slug. Routes match from top to
bottom and Symfony stops as soon as it finds the first matching route. So the easiest fix is to just move the more specific route
above this one.
64 lines src/Controller/QuestionController.php
... lines 1 - 10
class QuestionController extends AbstractController
{
... lines 13 - 25
public function homepage()
{
... line 28
}
... line 30
/**
* @Route("/questions/new")
*/
public function new()
{
return new Response('Time for some Doctrine magic!');
}
/**
* @Route("/questions/{slug}", name="app_question_show")
*/
public function show($slug, MarkdownHelper $markdownHelper)
{
... lines 44 - 61
}
}
This doesn't happen too often, but this is how I handle it.
But we're not going to talk about Symfony forms yet. Instead, let's "fake it" inside the controller. Let's create a Question object,
set some hardcoded data on it and ask Doctrine to save it.
And because there is nothing special about our entity class, instantiating it looks exactly like you would expect: $question =
new Question() and I'll auto-complete this so that PhpStorm adds the Question use statement.
87 lines src/Controller/QuestionController.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 11
class QuestionController extends AbstractController
{
... lines 14 - 34
public function new()
{
$question = new Question();
... lines 38 - 59
}
... lines 61 - 85
}
Next, call $question->setName('Missing pants') - an unfortunate magical side effect of an incorrect spell.
87 lines src/Controller/QuestionController.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 11
class QuestionController extends AbstractController
{
... lines 14 - 34
public function new()
{
$question = new Question();
$question->setName('Missing pants')
... lines 39 - 59
}
... lines 61 - 85
}
And ->setSlug('missing-pants') with a random number at the end so that each one is unique.
87 lines src/Controller/QuestionController.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 11
class QuestionController extends AbstractController
{
... lines 14 - 34
public function new()
{
$question = new Question();
$question->setName('Missing pants')
->setSlug('missing-pants-'.rand(0, 1000))
... lines 40 - 59
}
... lines 61 - 85
}
For the main part of the question, call ->setQuestion() and, because this is long, I'll use the multiline syntax - <<<EOF - and
paste in some content. You can copy this from the code block on this page or use any text.
87 lines src/Controller/QuestionController.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 11
class QuestionController extends AbstractController
{
... lines 14 - 34
public function new()
{
$question = new Question();
$question->setName('Missing pants')
->setSlug('missing-pants-'.rand(0, 1000))
->setQuestion(<<<EOF
Hi! So... I'm having a *weird* day. Yesterday, I cast a spell
to make my dishes wash themselves. But while I was casting it,
I slipped a little and I think `I also hit my pants with the spell`.
When I woke up this morning, I caught a quick glimpse of my pants
opening the front door and walking out! I've been out all afternoon
(with no pants mind you) searching for them.
Does anyone have a spell to call your pants back?
EOF
);
... lines 52 - 59
}
... lines 61 - 85
}
The last field is $askedAt. Let's add some randomness to this: if a random number between 1 and 10 is greater than 2, then
call $question->setAskedAt(). Remember: askedAt is allowed to be null in the database... and if it is, we want that to mean
that the user hasn't published the question yet. This if statement will give us a nice mixture of published and unpublished
questions.
87 lines src/Controller/QuestionController.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 11
class QuestionController extends AbstractController
{
... lines 14 - 34
public function new()
{
$question = new Question();
$question->setName('Missing pants')
->setSlug('missing-pants-'.rand(0, 1000))
->setQuestion(<<<EOF
... lines 41 - 49
EOF
);
if (rand(1, 10) > 2) {
... line 54
}
... lines 56 - 59
}
... lines 61 - 85
}
Also remember that the $askedAt property is a datetime field. This means that it will be a DATETIME type in MySQL: a field
that is ultimately set via a date string. But in PHP, instead of dealing with strings, thankfully we get to deal with DateTime
objects. Let's say new \DateTime() and add some randomness here too: sprintf('-%d days') and pass a random number from
1 to 100.
87 lines src/Controller/QuestionController.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 11
class QuestionController extends AbstractController
{
... lines 14 - 34
public function new()
{
$question = new Question();
$question->setName('Missing pants')
->setSlug('missing-pants-'.rand(0, 1000))
->setQuestion(<<<EOF
... lines 41 - 49
EOF
);
if (rand(1, 10) > 2) {
$question->setAskedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
}
... lines 56 - 59
}
... lines 61 - 85
}
So, the askedAt will be anywhere from 1 to 100 days ago.
87 lines src/Controller/QuestionController.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 11
class QuestionController extends AbstractController
{
... lines 14 - 34
public function new()
{
$question = new Question();
$question->setName('Missing pants')
->setSlug('missing-pants-'.rand(0, 1000))
->setQuestion(<<<EOF
... lines 41 - 49
EOF
);
if (rand(1, 10) > 2) {
$question->setAskedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
}
dd($question);
... lines 58 - 59
}
... lines 61 - 85
}
then move over, refresh and... hello nice, boring Question object! Notice that the id property is still null because we haven't
saved it to the database yet.
This returns several services, but most are lower level. The one we want - which is the most important service by far in
Doctrine - is EntityManagerInterface.
Let's go use it! Back in the controller, add a new argument to autowire this: EntityManagerInterface $entityManager.
93 lines src/Controller/QuestionController.php
... lines 1 - 6
use Doctrine\ORM\EntityManagerInterface;
... lines 8 - 12
class QuestionController extends AbstractController
{
... lines 15 - 35
public function new(EntityManagerInterface $entityManager)
{
... lines 38 - 65
}
... lines 67 - 91
}
93 lines src/Controller/QuestionController.php
... lines 1 - 6
use Doctrine\ORM\EntityManagerInterface;
... lines 8 - 12
class QuestionController extends AbstractController
{
... lines 15 - 35
public function new(EntityManagerInterface $entityManager)
{
... lines 38 - 57
$entityManager->persist($question);
$entityManager->flush();
... lines 60 - 65
}
... lines 67 - 91
}
Yes, you need both lines. The persist() call simply says:
The persist line does not make any queries. The INSERT query happens when we call flush(). The flush() method says:
Yo Doctrine! Please look at all of the objects that you are "aware" of and make all the queries you need to save
those.
So this is how saving looks: a persist() and flush() right next to each other. If you ever needed to, you could call persist() on 5
different objects and then call flush() once at the end to make all of those queries at the same time.
Anyways, now that we have a Question object, let's make the Response more interesting. I'll say sprintf with:
Passing $question->getId() for the first placeholder and $question->getSlug() for the second.
93 lines src/Controller/QuestionController.php
... lines 1 - 6
use Doctrine\ORM\EntityManagerInterface;
... lines 8 - 12
class QuestionController extends AbstractController
{
... lines 15 - 35
public function new(EntityManagerInterface $entityManager)
{
... lines 38 - 57
$entityManager->persist($question);
$entityManager->flush();
return new Response(sprintf(
'Well hallo! The shiny new question is id #%d, slug: %s',
$question->getId(),
$question->getSlug()
));
}
... lines 67 - 91
}
Ok, back at the browser, before saving, the Question object had no id value. But now when we refresh... yes! It has an id!
After saving, Doctrine automatically sets the new id on the object. We can refresh over and over again to add more and more
question rows to the table.
Let's go see them! If you ever want to make a query to see something, Doctrine has a handy bin/console command for that:
Our question table has data! And each time we refresh, we got more data! You get a question! You get a question!
Copy the slug from the latest one and then go to /questions/that-slug to see it. Except... this is not actually that question. The
name is kinda right... but that's it. Over in the show() action, this is because nothing is being loaded from the database. Lame!
Here's our next mission: use the $slug to query for a row of Question data and use that to make this page truly dynamic.
How? The entity manager that we use to save data can also be used to fetch data.
The Repository
Start by adding a third argument: EntityManagerInterface $entityManager. This interface has a bunch of methods on it. But...
most of the time, you'll only use three: persist() and flush() to save, and getRepository() when you want to get data.
97 lines src/Controller/QuestionController.php
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 70
public function show($slug, MarkdownHelper $markdownHelper, EntityManagerInterface $entityManager)
{
... lines 73 - 94
}
}
Say $repository = $entityManager->getRepository() and pass the entity class that we want to query. So Question::class.
97 lines src/Controller/QuestionController.php
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 70
public function show($slug, MarkdownHelper $markdownHelper, EntityManagerInterface $entityManager)
{
if ($this->isDebug) {
$this->logger->info('We are in debug mode!');
}
$repository = $entityManager->getRepository(Question::class);
... lines 78 - 94
}
}
Whenever you need to get data, you'll first get the repository for an entity. This repository object is really really good at
querying from the question table. And it has several methods to help us.
For example, we want to query WHERE the slug column equals the $slug variable. Do that with $question = $repository->
and... this auto completes a bunch of methods. We want findOneBy(). Pass this an array of the WHERE statements we need:
'slug' => $slug. After, dd($question).
97 lines src/Controller/QuestionController.php
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 70
public function show($slug, MarkdownHelper $markdownHelper, EntityManagerInterface $entityManager)
{
... lines 73 - 76
$repository = $entityManager->getRepository(Question::class);
$question = $repository->findOneBy(['slug' => $slug]);
dd($question);
... lines 80 - 94
}
}
Ok, let's see what this returns! Refresh and... woohoo! This gives us a Question object. Doctrine finds the matching row of
data and uses that to populate an object, which is beautiful.
The repository has a number of other methods on it. For example, findOneBy() returns a single object and findBy() returns an
array of objects that match whatever criteria you pass. The findAll() method returns an array of all Question objects and there
are a few others. So without doing any work, we can easily execute the most basic queries. Now, eventually we will need to
do more complex stuff - and for that, we'll write custom queries. We'll see that later.
Let's think: what do we want to do when someone goes to a URL that doesn't match a real question? The answer is: trigger a
404 page! Great! Um... how do we trigger a 404 page in Symfony?
First, this is optional - I'm going to say /** space and then type Question|null.
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 70
public function show($slug, MarkdownHelper $markdownHelper, EntityManagerInterface $entityManager)
{
... lines 73 - 77
/** @var Question|null $question */
$question = $repository->findOneBy(['slug' => $slug]);
... lines 80 - 98
}
}
This simply helps my editor know that this is a Question object or null, which will assist auto-completion. And, to be honest,
PhpStorm is so smart that... I think it already knew this.
Below, if not $question, trigger a 404 page by saying throw $this->createNotFoundException(), which is a method on the
parent AbstractController class. Pass this any message you want:
That's it! But notice the throw. createNotFoundException() instantiates an exception object - a very special exception object
that triggers a 404 page. Most of the time in Symfony, if you throw an exception, it will cause a 500 page. But this special
exception maps to a 404.
Let's try it: refresh and... yes! You can see it up here: "404 Not found" with our message.
Two things about this. First: this is the development error page. If we changed the environment to prod, we would see a much
more boring 404 page with no error or stack trace details. We won't talk about it, but the Symfony docs have details about
how you can customize the look and feel of your error pages on production.
The second thing I want to say is that the message - no question found for slug - is something that only developers will see.
Feel free to make this as descriptive as you want: you don't need to worry about a real user seeing it.
Now that we have a Question object in our controller, let's use it in our template to render real, dynamic info. That's next.
Chapter 10: Entity objects in Twig
We now have a Question object inside our controller. And at the bottom, we render a template. What we need to do is pass
that Question object into the template and use it on the page to print the name and other info.
Remove the dd(), leave the $answers - we'll keep those hardcoded for now because we don't have an Answer entity yet -
and get rid of the hardcoded $question, and $questionText.
96 lines src/Controller/QuestionController.php
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 70
public function show($slug, MarkdownHelper $markdownHelper, EntityManagerInterface $entityManager)
{
... lines 73 - 79
if (!$question) {
throw $this->createNotFoundException(sprintf('no question found for slug "%s"', $slug));
}
$answers = [
'Make sure your cat is sitting `purrrfectly` still ' ,
'Honestly, I like furry shoes better than MY cat',
'Maybe... try saying the spell backwards?',
];
... lines 89 - 93
}
}
96 lines src/Controller/QuestionController.php
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 70
public function show($slug, MarkdownHelper $markdownHelper, EntityManagerInterface $entityManager)
{
... lines 73 - 79
if (!$question) {
throw $this->createNotFoundException(sprintf('no question found for slug "%s"', $slug));
}
$answers = [
'Make sure your cat is sitting `purrrfectly` still ' ,
'Honestly, I like furry shoes better than MY cat',
'Maybe... try saying the spell backwards?',
];
return $this->render('question/show.html.twig', [
'question' => $question,
'answers' => $answers,
]);
}
}
71 lines templates/question/show.html.twig
... lines 1 - 2
{% block title %}Question: {{ question.name }}{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 27
<div class="col">
<h1 class="q-title-show">{{ question.name }}</h1>
<div class="q-display p-3">
<i class="fa fa-quote-left mr-3"></i>
<p class="d-inline">{{ question.question }}</p>
<p class="pt-4"><strong>--Tisha</strong></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
... lines 41 - 68
</div>
{% endblock %}
71 lines templates/question/show.html.twig
... lines 1 - 2
{% block title %}Question: {{ question.name }}{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 27
<div class="col">
<h1 class="q-title-show">{{ question.name }}</h1>
<div class="q-display p-3">
<i class="fa fa-quote-left mr-3"></i>
<p class="d-inline">{{ question.question }}</p>
<p class="pt-4"><strong>--Tisha</strong></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
... lines 41 - 68
</div>
{% endblock %}
I think that's it! Testing time! Move over, go back to the real question slug and... there it is! We have a real name and real
question text. This date is still hard coded, but we'll fix that soon.
We said question.name... which makes it look like it's reading the name property. But... if you look at the name property inside
of the Question entity... it's private! That means we can't access the name property directly. What's going on?
93 lines src/Entity/Question.php
... lines 1 - 10
class Question
{
... lines 13 - 22
private $name;
... lines 24 - 91
}
We're witnessing some Twig magic. In reality, when we say question.name, Twig first does look to see if the name property
exists and is public. If it were public, Twig would use it. But since it's not, Twig then tries to call a getName() method. Yep, we
write question.name, but, behind the scenes, Twig is smart enough to call getName().
93 lines src/Entity/Question.php
... lines 1 - 10
class Question
{
... lines 13 - 22
private $name;
... lines 24 - 44
public function getName(): ?string
{
return $this->name;
}
... lines 49 - 91
}
I love this: it means you can run around saying question.name in your template and not really worry about the whether there's
a getter method or not. It's especially friendly to non-PHP frontend devs.
If you wanted to actually call a method - like getName() - that is allowed, but it's usually not necessary.
The one thing that we did lose is that, originally, the question text was being parsed through markdown. We can fix that really
easily by using the parse_markdown filter that we created in the last tutorial.
71 lines templates/question/show.html.twig
... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 27
<div class="col">
<h1 class="q-title-show">{{ question.name }}</h1>
<div class="q-display p-3">
<i class="fa fa-quote-left mr-3"></i>
<p class="d-inline">{{ question.question|parse_markdown }}</p>
<p class="pt-4"><strong>--Tisha</strong></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
... lines 41 - 68
</div>
{% endblock %}
If you ever want to debug a query directly, click "View runnable query" to get a version that you can copy.
So... are we out of luck? Nah - we can use a trick. Go to /_profiler to find a list of the most recent requests we've made. Here's
the one we just made to /questions/new. Click the little token string on the right to jump into the full profiler for that request! Go
to the "Doctrine" tab and... bam! Cool! It even wraps the INSERT in a transaction.
Remember this trick the next time you want to see database queries, a rendered version of an error, or something else for an
AJAX request.
Go back a few times to the question show page. The last piece of question data that's hardcoded is this "asked 10 minutes
ago" text. Search for it in the template... there it is, line 18.
Let's make this dynamic... but, not just by printing some boring date like "July 10th at 10:30 EST". Yuck. Let's print a much-
friendlier "10 minutes ago" type of message next.
Chapter 11: "5 Minutes Ago" Strings
Let's make this date dynamic! The field on Question that we're going to use is $askedAt, which - remember - might be null. If
a Question hasn't been published yet, then it won't have an askedAt.
Let's plan for this. In the template, add {% if question.askedAt %} with an {% else %} and {% endif %}
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
<small>
{% if question.askedAt %}
... lines 18 - 19
{% else %}
... line 21
{% endif %}
</small>
... lines 24 - 29
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
<small>
{% if question.askedAt %}
... lines 18 - 19
{% else %}
(unpublished)
{% endif %}
</small>
... lines 24 - 29
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
In a real app, we would probably not allow users to see unpublished questions... we could do that in our controller by
checking for this field and saying throw $this->createNotFoundException() if it's null. But... maybe a user will be able to
preview their own unpublished questions. If they did, we'll show unpublished.
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
<small>
{% if question.askedAt %}
... line 18
{{ question.askedAt }}
{% else %}
(unpublished)
{% endif %}
</small>
... lines 24 - 29
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
But... you might be shouting: "Hey Ryan! That's not going to work!".
We know that when we have a datetime type in Doctrine, it's stored in PHP as a DateTime object. That's nice because
DateTime objects are easy to work with... but we can't simply print them.
To fix this, pass the DateTime object through a |date() filter. This takes a format argument - something like Y-m-d H:i:s.
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
<small>
{% if question.askedAt %}
... line 18
{{ question.askedAt|date('Y-m-d H:i:s') }}
{% else %}
(unpublished)
{% endif %}
</small>
... lines 24 - 29
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
When we try the page now... it's technically correct... but yikes! This... well... how can I put this politely: it looks like a backend
developer designed this.
KnpTimeBundle
Whenever I render dates, I like to make them relative. Instead of printing an exact date, I prefer something like "10 minutes
ago". It also avoids timezone problems... because 10 minutes ago makes sense to everyone! But this exact date would really
need a timezone to make sense.
So let's do this. Start by adding the word "Asked" back before the date. Cool.
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
<small>
{% if question.askedAt %}
Asked <br>
{{ question.askedAt|date('Y-m-d H:i:s') }}
{% else %}
(unpublished)
{% endif %}
</small>
... lines 24 - 29
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
To convert the DateTime into a friendly string, we can install a nice bundle. At your terminal, run:
You could find this bundle if you googled for "Symfony ago". As we know, the main thing that a bundle gives us is more
services. In this case, the bundle gives us one main service that provides a Twig filter called ago.
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
<small>
{% if question.askedAt %}
Asked <br>
{{ question.askedAt|ago }}
{% else %}
(unpublished)
{% endif %}
</small>
... lines 24 - 29
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
Next: let's make the homepage dynamic by querying for all of the questions in the database and rendering them. Along the
way, we're going to learn a secret about the repository object.
Chapter 12: Custom Repository Class
Now that the show page is working, let's bring the homepage to life! This time, instead of querying for one Question object,
we want to query for all of them.
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 27
public function homepage(EntityManagerInterface $entityManager)
{
... lines 30 - 34
}
... lines 36 - 98
}
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 27
public function homepage(EntityManagerInterface $entityManager)
{
$repository = $entityManager->getRepository(Question::class);
... lines 31 - 34
}
... lines 36 - 98
}
And finally, $questions = $repository->findAll(). Let's dd($questions) to see what these look like.
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 27
public function homepage(EntityManagerInterface $entityManager)
{
$repository = $entityManager->getRepository(Question::class);
$questions = $repository->findAll();
return $this->render('question/homepage.html.twig', [
'questions' => $questions,
]);
}
... lines 37 - 99
}
Pop open the template: templates/question/homepage.html.twig. Let's see: the homepage currently has two hard coded
questions. I want to loop right inside the row: {% for question in questions %}. Trace the markup down to see where this ends
and... add {% endfor %}. Delete the 2nd hard-coded question completely.
50 lines templates/question/homepage.html.twig
... lines 1 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in questions %}
<div class="col-12 mb-3">
... lines 19 - 43
</div>
{% endfor %}
</div>
</div>
... lines 48 - 50
Perfect. Now it's just like the show page because we have a question variable. The first thing to update is the question name
- {{ question.name }} and the slug also needs to be dynamic: question.slug.
50 lines templates/question/homepage.html.twig
... lines 1 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in questions %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container p-4">
<div class="row">
... lines 22 - 27
<div class="col">
<a class="q-title" href="{{ path('app_question_show', { slug: question.slug }) }}"><h2>{{ question.name }}</h2></a>
... lines 30 - 34
</div>
</div>
</div>
... lines 38 - 42
</div>
</div>
{% endfor %}
</div>
</div>
... lines 48 - 50
Below, for the question text, use {{ question.question|parse_markdown }}. We might also want to only show some of the
question on the page - we could do that by adding a new method - like getQuestionPreview() to the entity - and using it here.
We'll see this idea of custom entity methods later.
50 lines templates/question/homepage.html.twig
... lines 1 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in questions %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container p-4">
<div class="row">
... lines 22 - 27
<div class="col">
<a class="q-title" href="{{ path('app_question_show', { slug: question.slug }) }}"><h2>{{ question.name }}</h2></a>
<div class="q-display p-3">
<i class="fa fa-quote-left mr-3"></i>
<p class="d-inline">{{ question.question|parse_markdown }}</p>
<p class="pt-4"><strong>--Tisha</strong></p>
</div>
</div>
</div>
</div>
... lines 38 - 42
</div>
</div>
{% endfor %}
</div>
</div>
... lines 48 - 50
50 lines templates/question/homepage.html.twig
... lines 1 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in questions %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container p-4">
<div class="row">
... lines 22 - 27
<div class="col">
<a class="q-title" href="{{ path('app_question_show', { slug: question.slug }) }}"><h2>{{ question.name }}</h2></a>
<div class="q-display p-3">
<i class="fa fa-quote-left mr-3"></i>
<p class="d-inline">{{ question.question|parse_markdown }}</p>
<p class="pt-4"><strong>--Tisha</strong></p>
</div>
</div>
</div>
</div>
<a class="answer-link" href="{{ path('app_question_show', { slug: question.slug }) }}" style="color: #fff;">
... lines 39 - 41
</a>
</div>
</div>
{% endfor %}
</div>
</div>
... lines 48 - 50
Done! Doctrine makes it easy to query for data and Twig makes it easy to render. Go team! At the browser, refresh and... cool!
If you click the database icon on the web debug toolbar, you can see that the query doesn't have an ORDER BY yet. When
you're working with the built-in methods on the repository class, you're a bit limited - there are many custom things that these
methods simply can't do. For example, findAll() doesn't have any arguments: there's no way to customize the order or
anything else. Soon we'll learn how to write custom queries so we can do whatever we want.
But, in this case, there is another method that can help: findBy(). Pass this an empty array - we don't need any WHERE
statements - and then another array with 'askedAt' => 'DESC'.
Let's try it! Refresh! And... click the first: 10 days ago. Click the second: 1 month ago! I think we got it! If we jump into the
profiler... yes! It has ORDER BY asked_at DESC.
We've now pushed the built-in repository methods about as far as they can go.
EntityRepository
Question time: when we call getRepository(), what does that actually return? It's an object of course, but what type of object?
The answer is: EntityRepository.
In PhpStorm, I'll press Shift+Shift and type EntityRepository.php. I want to see what this looks like. Make sure to include all
"non project items". Here it is!
EntityRepository lives deep down inside of Doctrine and it is where the methods we've been using live, like find(), findAll(),
findBy(), findOneBy() and some more.
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 27
public function homepage(EntityManagerInterface $entityManager)
{
$repository = $entityManager->getRepository(Question::class);
dd($repository);
... lines 32 - 36
}
... lines 38 - 100
}
51 lines src/Repository/QuestionRepository.php
... lines 1 - 2
namespace App\Repository;
use App\Entity\Question;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @method Question|null find($id, $lockMode = null, $lockVersion = null)
* @method Question|null findOneBy(array $criteria, array $orderBy = null)
* @method Question[] findAll()
* @method Question[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class QuestionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Question::class);
}
... lines 21 - 49
}
When we originally ran make:entity to generate Question, it actually generated two classes: Question and
QuestionRepository. This class extends another called ServiceEntityRepository. And if you hold Command or Ctrl and click
into it, that class extends EntityRepository! The class we were just looking at.
When we ask for the repository for the Question entity, Doctrine actually returns a QuestionRepository object. But since that
ultimately extends EntityRepository, we have access to all the helper methods like findAll() and findBy().
But... how does Doctrine knows to give us an instance of this class? How does it connect the Question entity to the
QuestionRepository class? Is it relying on a naming convention?
Nope! The answer lives at the top of the Question class: we have @ORM\Entity() with
repositoryClass=QuestionRepository::class. This was generated for us by make:entity.
93 lines src/Entity/Question.php
... lines 1 - 7
/**
* @ORM\Entity(repositoryClass=QuestionRepository::class)
*/
class Question
{
... lines 13 - 91
}
Here's the big picture: when we call getRepository() and pass it Question::class, Doctrine will give us an instance of
QuestionRepository. And because that extends EntityRepository, we get access to the shortcut methods!
The class already has an example: uncomment the findByExampleField() method. If I have a findByExampleField() method
in the repository, it means that we can call this from the controller.
49 lines src/Repository/QuestionRepository.php
... lines 1 - 14
class QuestionRepository extends ServiceEntityRepository
{
... lines 17 - 21
/**
* @return Question[] Returns an array of Question objects
*/
public function findByExampleField($value)
{
return $this->createQueryBuilder('q')
->andWhere('q.exampleField = :val')
->setParameter('val', $value)
->orderBy('q.id', 'ASC')
->setMaxResults(10)
->getQuery()
->getResult()
;
}
... lines 36 - 47
}
In a few minutes, we're going to write a custom query that finds all questions WHERE askedAt IS NOT NULL. In
QuestionRepository, let's create a method to hold this. How about: findAllAskedOrderedByNewest() and this won't need any
arguments.
49 lines src/Repository/QuestionRepository.php
... lines 1 - 14
class QuestionRepository extends ServiceEntityRepository
{
... lines 17 - 24
public function findAllAskedOrderedByNewest()
{
... lines 27 - 34
}
... lines 36 - 47
}
... lines 1 - 12
class QuestionController extends AbstractController
{
... lines 15 - 27
public function homepage(EntityManagerInterface $entityManager)
{
$repository = $entityManager->getRepository(Question::class);
$questions = $repository->findAllAskedOrderedByNewest();
... lines 32 - 35
}
... lines 37 - 99
}
Of course, that won't work yet because the logic is all wrong, but it will call the new method.
Next, let's learn about DQL and the query builder. Then, we'll create a custom query that will return the exact results we want.
Chapter 13: DQL & The Query Builder
We just learned that when you ask for a repository, what you actually get back is a custom class. Well, technically you don't
have to have a custom repository class - and if you don't, Doctrine will just give you an instance of EntityRepository. But in
practice, I always have custom repository classes.
Anyways, when we ask for the repository for the Question entity, we get back an instance of this QuestionRepository. The
cool thing is that we can add custom methods to hold custom queries. In fact, every time I write a custom query, I'll put it in a
repository class.
Here's the new goal: I want to change the query on the homepage so that it hides any questions WHERE askedAt IS NULL.
This will hide "unpublished" questions.
DQL
We know that we use SQL queries to talk to databases. Internally, Doctrine has a slightly different language called DQL:
Doctrine Query Language. But don't worry, it's almost identical to SQL. The main difference is that, with DQL, you reference
class and property names instead of table and column names. Otherwise, it basically looks the same.
The QueryBuilder
Now, you can absolutely write DQL strings by hand and execute them. Or you can use a super handy object called the
QueryBuilder, which allows you to build that DQL string using a convenient object. That is what you see here.
The $this->createQueryBuilder() line creates the QueryBuilder object. And because we're inside of the QuestionRepository,
the QueryBuilder will already know to query FROM the question table. The q is basically the table alias, like SELECT *
FROM question as q. We'll use that everywhere to refer to properties on Question.
Then, most of the methods on QueryBuilder are pretty intuitive, like, andWhere() and orderBy(). setMaxResults() is probably
one of the least intuitive and it's still pretty simple: this adds a LIMIT.
Prepared Statements
Check out the andWhere(): q.exampleField = :value. Doctrine uses prepared statements... which is a fancy way of saying that
you should never concatenate a dynamic value into a string. This allows for SQL injections.
Instead, whenever you have something dynamic, set it to a placeholder - like :value and then set that placeholder with
setParameter(). This is how prepared statements work. It's not unique at all to Doctrine, but I wanted to point it out.
47 lines src/Repository/QuestionRepository.php
... lines 1 - 14
class QuestionRepository extends ServiceEntityRepository
{
... lines 17 - 24
public function findAllAskedOrderedByNewest()
{
return $this->createQueryBuilder('q')
->andWhere('q.askedAt IS NOT NULL')
... line 29
->getQuery()
->getResult()
;
}
... lines 34 - 45
}
I'm using askedAt because that's the name of the property... even though the column in the table is asked_at. Now add -
>orderBy() with q.askedAt and DESC.
47 lines src/Repository/QuestionRepository.php
... lines 1 - 14
class QuestionRepository extends ServiceEntityRepository
{
... lines 17 - 24
public function findAllAskedOrderedByNewest()
{
return $this->createQueryBuilder('q')
->andWhere('q.askedAt IS NOT NULL')
->orderBy('q.askedAt', 'DESC')
->getQuery()
->getResult()
;
}
... lines 34 - 45
}
Oh, and notice that I'm using andWhere()... even though there are no WHERE clauses before this! I'm doing this for 2
reasons. First... because it's allowed! Doctrine is smart enough to figure out if it needs an AND statement or not. And second,
there is a where() method... but it's kind of dangerous because it will override any where() or andWhere() calls that you had
earlier. So, I never use it.
Once we're done building our query, we always finish with getQuery() to transforms it into a finished Query object. Then, the
getResult() method will return an array of Question objects. My @return already says this! Woo!
The other common final method is getOneOrNullResult() which I use when I want to find a single record.
Ok: with any luck, this will return the array of Question objects we need! Let's try it! Find your browser, refresh and... no errors!
But I can't exactly tell if it's hiding the right stuff. Let's click on the web debug toolbar to see the query. I think that's right! Click
"View formatted query". That's definitely right!
And yes, if you ever have a super duper custom complex query and you just want to write it in normal SQL, you can
absolutely do that. The Doctrine queries tutorial will show you how.
Check it out: remove the EntityManagerInterface argument and replace it with QuestionRepository $repository. Celebrate by
deleting the getRepository() call.
... lines 1 - 5
use App\Repository\QuestionRepository;
... lines 7 - 13
class QuestionController extends AbstractController
{
... lines 16 - 28
public function homepage(QuestionRepository $repository)
{
$questions = $repository->findAllAskedOrderedByNewest();
return $this->render('question/homepage.html.twig', [
'questions' => $questions,
]);
}
... lines 37 - 99
}
If we move over and refresh... it still works! In practice, when I need to query for something, this is what I do: I autowire the
specific repository I need. The only time that I work with the entity manager directly is when I need to save something - like
we're doing in the new() method.
Thanks to the QueryBuilder object, we can leverage a pattern inside our repository that will allow us to reuse pieces of query
logic for multiple queries. Let me show you how next.
Chapter 14: Reusing Query Logic & Param Converters
Maybe my favorite thing about the QueryBuilder is that if you have multiple methods inside a repository, you can reuse query
logic between them. For example, a lot of queries might need this andWhere('q.askedAt IS NOT NULL') logic. That's not
complex, but I would still love to not repeat this line over and over again in every method and query. Instead, let's centralize
this logic.
54 lines src/Repository/QuestionRepository.php
... lines 1 - 6
use Doctrine\ORM\QueryBuilder;
... lines 8 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 36
private function addIsAskedQueryBuilder(QueryBuilder $qb): QueryBuilder
{
... line 39
}
... lines 41 - 52
}
Inside, we're going to modify the QueryBuilder that's passed to us to add the custom logic. So, $qb-> and then copy the
andWhere('q.askedAt IS NOT NULL'). Oh, and return this.
54 lines src/Repository/QuestionRepository.php
... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 36
private function addIsAskedQueryBuilder(QueryBuilder $qb): QueryBuilder
{
return $qb->andWhere('q.askedAt IS NOT NULL');
}
... lines 41 - 52
}
Pretty much every QueryBuilder method returns itself, which is nice because it allows us to do method chaining. By returning
the QueryBuilder from our method, we will also be able to chain off of it.
Ok, back in the original method, first create a QueryBuilder and set it to a variable. So, $qb = $this->createQueryBuilder().
54 lines src/Repository/QuestionRepository.php
... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 25
public function findAllAskedOrderedByNewest()
{
$qb = $this->createQueryBuilder('q');
... lines 29 - 34
}
... lines 36 - 52
}
Then we can say return $this->addIsAskedQueryBuilder($qb) and then the rest of the query.
54 lines src/Repository/QuestionRepository.php
... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 25
public function findAllAskedOrderedByNewest()
{
$qb = $this->createQueryBuilder('q');
return $this->addIsAskedQueryBuilder($qb)
->orderBy('q.askedAt', 'DESC')
->getQuery()
->getResult()
;
}
... lines 36 - 52
}
How cool is that? We now have a private method that we can call whenever we have a query that should only return
published questions. And as a bonus... when we refresh... it doesn't break!
Create another private method at the bottom called getOrCreateQueryBuilder(). This will accept an optional QueryBuilder
argument - so QueryBuilder $qb = null. And, it will return a QueryBuilder.
58 lines src/Repository/QuestionRepository.php
... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 40
private function getOrCreateQueryBuilder(QueryBuilder $qb = null): QueryBuilder
{
... line 43
}
... lines 45 - 56
}
This is totally a convenience method. If the QueryBuilder is passed, return it, else, return $this->createQueryBuilder() using
the same q alias.
58 lines src/Repository/QuestionRepository.php
... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 40
private function getOrCreateQueryBuilder(QueryBuilder $qb = null): QueryBuilder
{
return $qb ?: $this->createQueryBuilder('q');
}
... lines 45 - 56
}
This is useful because, in addIsAskedQueryBuilder(), we can add = null to make its QueryBuilder argument optional. Make
this work by saying return $this->getOrCreateQueryBuilder() passing $qb. Then ->andWhere('q.askedAt IS NOT NULL')
58 lines src/Repository/QuestionRepository.php
... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 34
private function addIsAskedQueryBuilder(QueryBuilder $qb = null): QueryBuilder
{
return $this->getOrCreateQueryBuilder($qb)
->andWhere('q.askedAt IS NOT NULL');
}
... lines 40 - 56
}
So, if somebody passes us an existing QueryBuilder, we use it! But if not, we'll create an empty QueryBuilder automatically.
That's customer service!
All of this basically just makes the helper method easier to use above. Now we can just return $this-
>addIsAskedQueryBuilder() with no $qb argument.
58 lines src/Repository/QuestionRepository.php
... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 25
public function findAllAskedOrderedByNewest()
{
return $this->addIsAskedQueryBuilder()
->orderBy('q.askedAt', 'DESC')
->getQuery()
->getResult()
;
}
... lines 34 - 56
}
Before we celebrate and throw a well-deserved taco party, let's make sure it works. Refresh and... it does! Sweet! Tacos!
Next, I've got another shortcut to show you! This time it's about letting Symfony query for an object automatically in the
controller... a feature I love.
Chapter 15: Automatic Controller Queries: Param Converter
Once again, I have a confession: I've still be making us do too much work. Dang!
Head over to QuestionController and find the show() action. Instead of manually querying for the Question object via
findOneBy(), Symfony can make that query for us automatically.
... lines 1 - 13
class QuestionController extends AbstractController
{
... lines 16 - 72
/**
* @Route("/questions/{slug}", name="app_question_show")
*/
public function show($slug, EntityManagerInterface $entityManager)
{
... lines 78 - 81
$repository = $entityManager->getRepository(Question::class);
/** @var Question|null $question */
$question = $repository->findOneBy(['slug' => $slug]);
... lines 85 - 98
}
}
Automatic Queries
Here's how: replace the $slug argument with Question $question. The important thing here is not the name of the argument,
but the type-hint: we're type-hinting the argument with an entity class.
And... we're done! Symfony will see the type-hint and automatically query for a Question object WHERE slug = the {slug}
route wildcard value.
This means that we don't need any of the repository logic down here... or even the 404 stuff. I explain why in a minute. We
can also delete my EntityManagerInterface argument... and, actually, we haven't needed this MarkdownHelper argument for
awhile.
94 lines src/Controller/QuestionController.php
... lines 1 - 13
class QuestionController extends AbstractController
{
... lines 16 - 72
/**
* @Route("/questions/{slug}", name="app_question_show")
*/
public function show(Question $question)
{
if ($this->isDebug) {
$this->logger->info('We are in debug mode!');
}
$answers = [
'Make sure your cat is sitting `purrrfectly` still ' ,
'Honestly, I like furry shoes better than MY cat',
'Maybe... try saying the spell backwards?',
];
return $this->render('question/show.html.twig', [
'question' => $question,
'answers' => $answers,
]);
}
}
Before we chat about what's going on, let's try it. Refresh the homepage, then click into one of the questions. Yes! It works!
You can even see the query in the web debug toolbar. It's exactly what we expect: WHERE slug = that slug.
94 lines src/Controller/QuestionController.php
... lines 1 - 13
class QuestionController extends AbstractController
{
... lines 16 - 72
/**
* @Route("/questions/{slug}", name="app_question_show")
*/
public function show(Question $question)
{
... lines 78 - 91
}
}
So this works because our wildcard is called slug, which exactly matches the property name. Quite literally this makes a
query where slug equals the {slug} part of the URL. If we also had an {id} wildcard in the URL, then the query would be
WHERE slug = {slug} AND id = {id}.
It even handles the 404 for us! If we add foo to the slug in the URL... we still get a 404!
This feature is called a param converter and I freakin' love it. But it doesn't always work. If you have a situation where you
need a more complex query... or maybe for some reason the wildcard can't match your property name... or you have an extra
wildcard that is not meant to be in the query, then this won't work. Well, there is a way to get it to work - but I don't think it's
worth the trouble.
And... that's fine! In those cases, just use your repository object to make the query like you normally would. The param
converter is an awesome shortcut for the most common cases.
Next: let's add some voting to our question. When we do that, we're going to look closer at the methods inside of the Question
entity, which right now, are just getter and setter methods. Are we allowed to add our own custom methods here? And if so,
when should we?
Chapter 16: Smarter Entity Methods
We are on an epic quest to make everything on the question page truly dynamic. In the design, each question can get up and
down voted... but this doesn't work yet and the vote count - + 6 - is hardcoded in the template.
To get this working, let's add a new votes property to the Question entity. When a user clicks the up button, we will increase
the votes. When they click down, we'll decrease it. In the future, when we have a true user authentication system, we could
make this smarter by recording who is voting and preventing someone from voting multiple times. But our simpler plan will
work great for now.
Once again, I could use symfony console... and I probably should. But since this command doesn't need the database
environment variables, bin/console also works.
This time, enter Question so that we can update the entity. Yea! make:entity can also be used to modify an entity! Add a new
field called votes, make it an integer type and set it to not nullable in the database. Hit enter to finish.
Ok! Let's go check out the Question entity. It looks exactly like we expected: a $votes property and, at the bottom, getVotes()
and setVotes() methods.
... lines 1 - 10
class Question
{
... lines 13 - 39
/**
* @ORM\Column(type="integer")
*/
private $votes;
... lines 44 - 97
public function getVotes(): ?int
{
return $this->votes;
}
public function setVotes(int $votes): self
{
$this->votes = $votes;
return $this;
}
}
so that the Symfony binary can inject the environment variables. When this finishes, I like to double check the migration to
make sure it doesn't contain any surprises.
32 lines migrations/Version20200708195925.php
... lines 1 - 4
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
... lines 10 - 12
final class Version20200708195925 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question ADD votes INT NOT NULL');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question DROP votes');
}
}
Beautiful!
Hmm, yea: that makes sense. We didn't set the votes property, so it's trying to create a new row with null for that column.
What we probably want to do is default votes to be zero. How can we set a default value for a column in Doctrine?
Actually, that's not really the right question to ask. A better question would be: how can we default the value of a property in
PHP?
And the answer to that is simple. In Question, just say private $votes = 0
... lines 1 - 10
class Question
{
... lines 13 - 39
/**
* @ORM\Column(type="integer")
*/
private $votes = 0;
... lines 44 - 108
}
It's that easy. Now, when we instantiate a Question object, votes will be zero. And when it saves the database... the votes
column will be zero instead of null. There is actually a way inside the @ORM\Column annotation to specifically set the
default value of the column in the database, but I've never used it. Setting the default value on the property works beautifully.
... lines 1 - 10
class Question
{
... lines 13 - 97
public function getVotes(): int
{
return $this->votes;
}
... lines 102 - 108
}
96 lines src/Controller/QuestionController.php
... lines 1 - 13
class QuestionController extends AbstractController
{
... lines 16 - 40
public function new(EntityManagerInterface $entityManager)
{
... lines 43 - 62
$question->setVotes(rand(-20, 50));
$entityManager->persist($question);
$entityManager->flush();
... lines 67 - 72
}
... lines 74 - 94
}
Back on the browser, I'll refresh /questions/new a few times to get some fresh data. Copy the new slug and put that into the
address bar to view the new Question.
Rendering the true vote count should be easy. Open up templates/question/show.html.twig. Find the vote number... + 6 and
replace it with {{ question.votes }}
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
... line 14
<div class="mt-3">
... lines 16 - 24
<div class="vote-arrows vote-arrows-alt flex-fill pt-2" style="min-width: 90px;">
... lines 26 - 27
<span>{{ question.votes }}</span>
</div>
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
That's good boring code. Back at the browser, when we refresh... nice! This has minus 10 votes... it must not be a great
question.
But... our designer actually does want positive vote numbers to have a plus sign. No problem. We could add some extra Twig
logic: if the number is positive, then add a plus sign before printing the votes.
There's nothing wrong with having simple logic like this in Twig. But if there is another place that we could put that logic,
that's usually better. In this case, we could add a new method to the Question entity itself: a method that returns the string
representation of the vote count - complete with the + and - signs. That would keep the logic out of Twig and even make that
code reusable. Heck! We could also unit test it!
Check it out: inside the Question entity - it doesn't matter where, but I'll put it right after getVotes() so that it's next to related
methods - add public function getVotesString() with a string return type. Inside, I'll paste some logic.
This first determines the "prefix" - the plus or minus sign - and then adds that before the number - using the abs() function to
avoid two minus signs for negative numbers. In other words, this returns the exact string we want. How nice is that? Easy to
read & reusable.
... lines 1 - 10
class Question
{
... lines 13 - 102
public function getVotesString(): string
{
$prefix = $this->getVotes() >=0 ? '+' : '-';
return sprintf('%s %d', $prefix, abs($this->getVotes()));
}
... lines 109 - 115
}
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
... line 14
<div class="mt-3">
... lines 16 - 24
<div class="vote-arrows vote-arrows-alt flex-fill pt-2" style="min-width: 90px;">
... lines 26 - 27
<span>{{ question.votesString }}</span>
</div>
</div>
</div>
... lines 32 - 39
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
That's it. Let's try it! Over on the browser, refresh and... there it is! + 10!
The cool thing about this is that we said question.votesString. But... there is no $votesString property inside of Question!
And... that's fine! When we say question.votesString, Twig is smart enough to call the getVotesString() method.
Now that we're printing the vote number, let's make it possible to click these up and down vote buttons. This will be the first
time we execute an update query and we'll get to talk more about "smart" entity methods. That's all next.
Chapter 17: Request Object & POST Data
Time to hook up the vote functionality. Here's the plan: these up and down vote icons are actually buttons. I'll show you: in
show.html.twig... it's a <button> with name="direction" and value="up".
75 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
... lines 16 - 24
<div class="vote-arrows vote-arrows-alt flex-fill pt-2" style="min-width: 90px;">
<button class="vote-up btn btn-link" name="direction" value="up"><i class="far fa-arrow-alt-circle-up"></i></button>
<button class="vote-down btn btn-link" name="direction" value="down"><i class="far fa-arrow-alt-circle-
down"></i></button>
<span>{{ question.votesString }}</span>
</div>
</div>
</div>
<div class="col">
... lines 33 - 38
</div>
</div>
</div>
</div>
</div>
</div>
... lines 45 - 72
</div>
... lines 74 - 75
Thanks to the name and value attributes, if we wrapped this in a <form> and then click one of these buttons, the form would
submit and send a POST parameter called direction that's equal to either up or down, based on which button was clicked. It's
like having an extra input in your form.
So that's exactly what we're going to do: wrap this in a form, make it submit to a new endpoint, read the direction value and
increase or decrease the vote count. We could do this with an AJAX call instead of a form submit. From a Doctrine and
Symfony perspective, it really makes no difference. So I'll keep it simple and leave JavaScript out of this.
... lines 1 - 13
class QuestionController extends AbstractController
{
... lines 16 - 95
/**
* @Route("/questions/{slug}/vote", name="app_question_vote", methods="POST")
*/
public function questionVote(Question $question)
{
... line 101
}
}
This means that I can only make a POST request to this endpoint. If we try to make a GET request, the route won't match.
That's nice for 2 reasons. First, it's a best-practice: if an endpoint changes data on the server, it should not allow GET
requests. The second reason is... really an example of why this best practice exists. If we allowed GET requests, then it
would make it too easy to vote: someone could post the voting URL somewhere and unknowing users would vote just by
clicking it. Worse, bots might follow that link and start voting themselves.
Anyways, like before with the show page, we have a {slug} route wildcard that we need to use to query for the Question
object. Let's do that the same way: add an argument with a Question type-hint. And, for now, just dd($question).
... lines 1 - 13
class QuestionController extends AbstractController
{
... lines 16 - 95
/**
* @Route("/questions/{slug}/vote", name="app_question_vote", methods="POST")
*/
public function questionVote(Question $question)
{
dd($question);
}
}
77 lines templates/question/show.html.twig
... lines 1 - 5
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ asset('images/tisha.png') }}" width="100" height="100" alt="Tisha avatar">
<div class="mt-3">
... lines 16 - 24
<form action="{{ path('app_question_vote', { slug: question.slug }) }}" method="POST">
<div class="vote-arrows vote-arrows-alt flex-fill pt-2" style="min-width: 90px;">
<button class="vote-up btn btn-link" name="direction" value="up"><i class="far fa-arrow-alt-circle-up"></i></button>
<button class="vote-down btn btn-link" name="direction" value="down"><i class="far fa-arrow-alt-circle-
down"></i></button>
<span>{{ question.votesString }}</span>
</div>
</form>
</div>
</div>
... lines 34 - 41
</div>
</div>
</div>
</div>
</div>
... lines 47 - 74
</div>
... lines 76 - 77
Cool! With any luck, when we refresh the page, we should be able to click either button to submit to the endpoint. And... yes!
Symfony queried for the Question object and we dumped it.
So the question now is: how can we read POST data from inside of Symfony? Well, whenever you need to read POST data
or query parameters or headers, what you're really doing is reading information from the Request. And, in Symfony, there is a
Request object that holds all of this data. To read POST data, we need to get the Request object!
And because needing the request is so common, you can get it in a controller by using its type-hint. Check this out: add
Request - make sure you get the one from HttpFoundation - and then $request.
This looks like service autowiring. It looks just like how we can type-hint EntityManagerInterface to get that service. But... the
truth is that the Request is not a service in the container.
First, we can have an argument whose name matches one of the wildcards in the route. Second, we can autowire services
with their type-hint. Third, we can type-hint an entity class to tell Symfony to automatically query for it. And finally, we can
type-hint the Request class to get the request. Yep, this specific class has its own special case.
There are a few other possible types of arguments that you can have in your controllers, but these are the main ones.
to find the right method. Let's dump one part of the request: $request->request->all().
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 99
public function questionVote(Question $question, Request $request)
{
dd($question, $request->request->all());
}
}
Yeah, I know: it looks a little funny: $request->request?. Technically, POST parameters are known as "request" parameters.
So this $request->request is a small object that holds all of the POST parameters. The ->all() method returns them as an
array.
So when we go over now and refresh... yes! We see 'direction' => 'up'!
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 99
public function questionVote(Question $question, Request $request)
{
$direction = $request->request->get('direction');
... lines 103 - 108
dd($question);
}
}
Now, if $direction === 'up', then $question->setVotes($question->getVotes() + 1). Else if $direction === 'down', do the same
thing, but - 1.
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 99
public function questionVote(Question $question, Request $request)
{
$direction = $request->request->get('direction');
if ($direction === 'up') {
$question->setVotes($question->getVotes() + 1);
} elseif ($direction === 'down') {
$question->setVotes($question->getVotes() - 1);
}
dd($question);
}
}
If the direction is some other value, let's just ignore it. That probably means that someone is messing with our form and
ignoring it is safe. At the bottom, dd($question) to see what it looks like.
Ok, right now this question has 10 votes. When we refresh... yes! 11! Go back to the show page and hit down. 9!
But... this did not save to the database yet: it's just updating the value on our PHP object. And also, I think we can accomplish
this + 1 and - 1 logic in a cleaner way.
Next, let's talk about anemic versus rich models. Then we'll learn how to make an UPDATE query to update the vote count.
Hint: we already know how to do this.
Chapter 18: Update Query & Rich vs Anemic Models
On the show page, we can now up vote or down vote the question... mostly. In the controller, we read the direction POST
parameter to know which button was clicked and change the vote count. This doesn't save to the database yet, but we'll do
that in a few minutes.
In Question, at the bottom, add a new public function called upVote(). I'm going make this return self.
... lines 1 - 10
class Question
{
... lines 13 - 116
public function upVote(): self
{
... lines 119 - 121
}
... lines 123 - 129
}
Inside, say $this->votes++. Then, return $this... just because that allows method chaining. All of the setter methods return
$this.
... lines 1 - 10
class Question
{
... lines 13 - 116
public function upVote(): self
{
$this->votes++;
return $this;
}
... lines 123 - 129
}
Copy this, paste, and create another called downVote() that will do $this->votes--.
I'm not going to bother adding any PHP documentation above these, because... their names are already so descriptive:
upVote() and downVote()!
I love doing this because it makes the code in our controller so nice. If the direction is up, $question->upVote(). If it's down,
$question->downVote().
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 99
public function questionVote(Question $question, Request $request)
{
$direction = $request->request->get('direction');
if ($direction === 'up') {
$question->upVote();
} elseif ($direction === 'down') {
$question->downVote();
}
dd($question);
}
}
How beautiful is that? And when we move over to try it... we're still good!
But sometimes you might not need - or even want - a getter or setter method. For example, do we really want a setVotes()
method? Should anything in our app be able to set the vote directly to any number? Probably not. Probably we will always
want to use upVote() or downVote().
Now, I will keep this method... but only because we're using it in QuestionController. In the new() method... we're using it to
set the fake data.
But this touches on a really interesting idea: by removing any unnecessary getter or setter methods in your entity and
replacing them with more descriptive methods that fit your business logic - like upVote() and downVote() - you can, little by
little, give your entities more clarity. upVote(), and downVote() are very clear & descriptive. Someone calling these doesn't
even need to know or care how they work internally.
Some people take this to an extreme and have almost zero getter and setter methods on their entities. Here at Symfonycasts,
we tend to be more pragmatic. We usually have getters and setters method, but we always look for ways to be more
descriptive - like upVote() and downVote().
Updating an Entity in the Database
Okay, let's finish this! In our controller, back down in questionVote(), how can we execute an update query to save the new
vote count to the database? Well, no surprise, whenever we need to save something in Doctrine, we need the entity
manager.
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 99
public function questionVote(Question $question, Request $request, EntityManagerInterface $entityManager)
{
... lines 102 - 114
}
}
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 99
public function questionVote(Question $question, Request $request, EntityManagerInterface $entityManager)
{
$direction = $request->request->get('direction');
if ($direction === 'up') {
$question->upVote();
} elseif ($direction === 'down') {
$question->downVote();
}
$entityManager->flush();
... lines 111 - 114
}
}
Done! Seriously! Doctrine is smart enough to realize that the Question object already exists in the database and make an
update query instead of an insert. We don't need to worry about "is this an insert or an update" at all? Doctrine has that
covered.
No persist() on Update?
But wait, didn't I forget the persist() call? Up in the new() action, we learned that to insert something, we need to get the entity
manager and then call persist() and flush().
This time, we could have added persist(), but we don't need to. Scroll back up to new(). Remember: the point of persist() is to
make Doctrine aware of your object so that when you call flush(), it knows to check that object and execute whatever query it
needs to save that into the database, whether that is an INSERT of UPDATE query.
Down in questionVote(), because Doctrine was used to query for this Question object... it's already aware of it! When we call
flush(), it already knows to check the Question object for changes and performs an UPDATE query. Doctrine is smart.
Redirecting
Ok, now that this is saving... what should our controller return? Well, usually after a form submit, we will redirect somewhere.
Let's do that. How? return $this->redirectToRoute() and then pass the name of the route that we want to redirect to. Let's use
app_question_show to redirect to the show page and then pass any wildcard values as the second argument: slug set to
$question->getSlug().
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 99
public function questionVote(Question $question, Request $request, EntityManagerInterface $entityManager)
{
$direction = $request->request->get('direction');
if ($direction === 'up') {
$question->upVote();
} elseif ($direction === 'down') {
$question->downVote();
}
$entityManager->flush();
return $this->redirectToRoute('app_question_show', [
'slug' => $question->getSlug()
]);
}
}
Two things about this. First, until now, we've only generated URLs from inside of Twig, by using the {{ path() }} function. We
pass the same arguments to redirectToRoute() because, internally, it generates a URL just like path() does.
And second... more of a question. On a high level... what is a redirect? When a server wants to redirect you to another page,
how does it do that?
A redirect is nothing more than a special type of response. It's a response that has a 301 or 302 status code and a Location
header that tells your browser where to go.
Let's do some digging and find out how redirectToRoute() does this. Hold Command or Ctrl and click redirectToRoute() to
jump to that method inside of AbstractController. This apparently calls another method: redirect(). Hold Command or Ctrl
again to jump to that.
Ah, here's the answer: this returns a RedirectResponse object. Hold Command or Ctrl one more time to jump into this class.
RedirectResponse live deep in the core of Symfony and it extends Response! Yes this is just a special subclass of
Response that's really good at creating redirect responses.
Let's close all of these core classes. The point is: the redirectToRoute() method doesn't do anything magical: it simply returns
a Response object that's really good at redirecting.
Ok: testing time! Spin over to your browser and go back to the show page. Right now this has 10 votes. Hit "up vote" and...
11! Do it again: 12! Then... 13! Downvote... 12. We got it!
Like I said earlier, in a real app, when we have user authentication, we might prevent someone from voting multiple times.
But, we can worry about that later.
Next: we have created a way to load dummy data into our database via the /questions/new page. But... it's pretty hacky.
Our /questions/new page is nice... it gave us a simple way to create and save some dummy data, so that we could have
enough to work on the homepage & show page.
Having a rich set of data to work with while you're developing is pretty important. Without it, you're going to spend a lot of time
constantly setting up your database before you work on something. It's a big waste in the long-run.
Installing DoctrineFixturesBundle
This "dummy data" has a special name: data fixtures. And instead of creating them in a random controller like
QuestionController, we can install a bundle to do it properly. Find your terminal and run:
When this finishes... it installed a recipe! I committed all of my changes before recording, so I'll run:
git status
to see what it did. Ok: it updated the normal composer.json, composed.lock and symfony.lock files, it enabled the bundle and
ooh: it created a new src/DataFixtures/ directory! Let's go see what's inside src/DataFixtures/ - a shiny new AppFixtures
class!
18 lines src/DataFixtures/AppFixtures.php
... lines 1 - 2
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
// $product = new Product();
// $manager->persist($product);
$manager->flush();
}
}
The DoctrineFixturesBundle that we just installed is a beautifully simple bundle. First, we create one or more of these fixture
classes: classes that extend Fixture and have this load() method. Second, inside load(), we write normal PHP code to create
as many dummy objects as we want. And third, we run a new console command that will call the load() method on every
fixture class.
18 lines src/DataFixtures/AppFixtures.php
... lines 1 - 2
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 12 - 15
}
}
88 lines src/Controller/QuestionController.php
... lines 1 - 14
class QuestionController extends AbstractController
{
... lines 17 - 41
public function new()
{
return new Response('Sounds like a GREAT feature for V2!');
}
... lines 46 - 86
}
Back in AppFixtures, paste the code and... check it out! PhpStorm was smart enough to see that we're using the Question
class and ask us if we want to import its use statement. We definitely do!
40 lines src/DataFixtures/AppFixtures.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 8
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
$question = new Question();
$question->setName('Missing pants')
->setSlug('missing-pants-'.rand(0, 1000))
->setQuestion(<<<EOF
Hi! So... I'm having a *weird* day. Yesterday, I cast a spell
to make my dishes wash themselves. But while I was casting it,
I slipped a little and I think `I also hit my pants with the spell`.
When I woke up this morning, I caught a quick glimpse of my pants
opening the front door and walking out! I've been out all afternoon
(with no pants mind you) searching for them.
Does anyone have a spell to call your pants back?
EOF
);
if (rand(1, 10) > 2) {
$question->setAskedAt(new \DateTime(sprintf('-%d days', rand(1, 100))));
}
$question->setVotes(rand(-20, 50));
$entityManager->persist($question);
$manager->flush();
}
}
The only problem now is that we don't have an $entityManager variable. Hmm, but we do have a $manager variable that's
passed to the load() method - it's an ObjectManager?
This is actually the entity manager in disguise: ObjectManager is an interface that it implements. So change the persist() call
to $manager... and we only need one flush().
40 lines src/DataFixtures/AppFixtures.php
... lines 1 - 4
use App\Entity\Question;
... lines 6 - 8
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 13 - 34
$manager->persist($question);
$manager->flush();
}
}
Done! Well, this isn't a very interesting fixture class... it's only going to create one Question.... but it's a good start. Let's see if
it works!
Executing the Fixtures
Head over to your terminal. The new bundle gave us one new command: doctrine:fixtures:load. Execute that through the
symfony binary:
It asks us to confirm because each time we run this command, it will completely empty the database before loading the new
data. And... I think it worked! Go check out the homepage. Refresh and... yes! We have the one question from the fixture
class.
This isn't that useful yet, but it gave us a chance to see how the bundle works. Oh, and if you don't see anything on this page,
it's probably because the one Question that was loaded has an askedAt set to null... so it's not showing up. Try re-running the
command once or twice to get a fresh Question.
So what I love about DoctrineFixturesBundle is how simple it is: I have a load() method where I can create and save as many
objects as I want. We can even create multiple fixtures classes to organize better and we can control the order in which each
is called.
What I hate about DoctrineFixturesBundle is that... I need to do all this work by hand! If you start creating a lot of objects -
especially once you have database relationships where objects are linked to other objects... these classes can get ugly fast.
And they're not much fun to write.
So, next: let's use a shiny new library called Foundry to create numerous, random, rich dummy data.
Chapter 20: Foundry: Fixture Model Factories
In the load() method of the fixture class, we can create as much dummy data as we want. Right now... we're creating exactly
one Question... which isn't making for a very realistic experience.
If we created more questions... and especially in the future when we will have multiple database tables that relate to each
other, this class would start to get ugly. It's... already kind of ugly.
Hello Foundry!
No, we deserve better! Let's use a super fun new library instead. Google for "Zenstruck Foundry" and find its GitHub Page.
Foundry is all about creating Doctrine entity objects in an easy, repeatable way. It's perfect for fixtures as well as for
functional tests where you want to seed your database with data at the start of each test. It even has extra features for test
assertions!
The bundle was created by Kevin Bond - a long time Symfony contributor and friend of mine who's been creating some really
excellent libraries lately. Foundry is Canadian for fun!
Installing Foundry
Let's get to work! Scroll down to the installation, copy the composer require line, find your terminal and paste. The --dev is
here because we only need to load dummy data in the dev & test environments.
While that's running, head back to the docs. Let me show you what this bundle is all about. Suppose you have entities like
Category or Post. The idea is that, for each entity, we will generate a corresponding model factory. So, a Post entity will have
a PostFactory class, which will look something like this.
Once we have that, we can configure some default data for the entity class and then... start creating objects!
I know I explained that quickly, but that's because we're going to see this in action. Back at the terminal... let's wait for this to
finish. I'm actually recording at my parents' house... where the Internet is barely a step up from dial-up.
After an edited break where I ate a sandwich and watched Moana, this finally finishes.
make:factory
Let's generate one of those fancy-looking model factories for Question. To do that, run:
I also could have run bin/console make:factory... because this command doesn't need the database environment variables...
but it's easier to get in the habit of always using symfony console.
Select Question from the list and... done! Go check out the new class src/Factory/QuestionFactory.php.
42 lines src/Factory/QuestionFactory.php
... lines 1 - 2
namespace App\Factory;
use App\Entity\Question;
use App\Repository\QuestionRepository;
use Zenstruck\Foundry\RepositoryProxy;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
/**
* @method static Question|Proxy findOrCreate(array $attributes)
* @method static Question|Proxy random()
* @method static Question[]|Proxy[] randomSet(int $number)
* @method static Question[]|Proxy[] randomRange(int $min, int $max)
* @method static QuestionRepository|RepositoryProxy repository()
* @method Question|Proxy create($attributes = [])
* @method Question[]|Proxy[] createMany(int $number, $attributes = [])
*/
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
// TODO add your default values here (https://github.com/zenstruck/foundry#model-factories)
];
}
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
// ->beforeInstantiate(function(Question $question) {})
;
}
protected static function getClass(): string
{
return Question::class;
}
}
57 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
'name' => 'Missing pants',
... lines 26 - 40
];
}
... lines 43 - 55
}
This works a bit like Twig. When Foundry sees the name key, it will call the setName() method on Question. Internally, this
uses Symfony's property-access component, which I'm mentioning, because it also supports passing data through the
constructor if you need that.
Copy the rest of the dummy code from our fixture class, delete it... and delete everything actually.
19 lines src/DataFixtures/AppFixtures.php
... lines 1 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 14 - 15
$manager->flush();
}
}
But we need to convert all of this into array keys. As exciting as this is... I'll... type really fast.
57 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
'name' => 'Missing pants',
'slug' => 'missing-pants-'.rand(0, 1000),
'question' => <<<EOF
Hi! So... I'm having a *weird* day. Yesterday, I cast a spell
to make my dishes wash themselves. But while I was casting it,
I slipped a little and I think `I also hit my pants with the spell`.
When I woke up this morning, I caught a quick glimpse of my pants
opening the front door and walking out! I've been out all afternoon
(with no pants mind you) searching for them.
Does anyone have a spell to call your pants back?
EOF
,
'askedAt' => rand(1, 10) > 2 ? new \DateTime(sprintf('-%d days', rand(1, 100))) : null,
'votes' => rand(-20, 50),
];
}
... lines 43 - 55
}
How? First, say QuestionFactory::new(). That will give us a new instance of the QuestionFactory. Now ->create() to create a
single Question.
19 lines src/DataFixtures/AppFixtures.php
... lines 1 - 5
use App\Factory\QuestionFactory;
... lines 7 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
QuestionFactory::new()->create();
$manager->flush();
}
}
Done! Ok, it's still not interesting - it will create just one Question... but let's try it! Re-run the fixtures:
Answer yes and... no errors! Head over to the browser, refresh and... oh! Zero questions! Ah, my one question is probably
unpublished. Load the fixtures again:
symfony console doctrine:fixtures:load
createMany()
At this point, you might be wondering: why is this better? Valid question. It's better because we've only just started to scratch
the service of what Foundry can do. Want to create 20 questions instead of just one? Change create() to createMany(20).
19 lines src/DataFixtures/AppFixtures.php
... lines 1 - 5
use App\Factory\QuestionFactory;
... lines 7 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
QuestionFactory::new()->createMany(20);
$manager->flush();
}
}
Then go check out the homepage. Hello 20 questions created with one line of very readable code.
But wait there's more Foundry goodness! Foundry comes with built-in support for a library called faker. A handy tool for
creating truly fake data. Let's improve the quality of our fake data and see a few other cool things that Foundry can do next.
Chapter 21: Foundry Tricks
In QuestionFactory, we're already doing a pretty good job of making some of this data random so that all of the questions
aren't identical. To help with this, Foundry comes with built-in support for Faker: a library that's great at creating all kinds of
interesting, fake data.
Using Faker
If you look at the top of the Foundry docs, you'll see a section called Faker and a link to the Faker documentation. This tells
you everything that Faker can do... which is... a lot. Let's use it to make our fixtures even better.
Tip
The Faker library now has a new home! At https://github.com/FakerPHP/Faker. Same great library, shiny new home.
For example, for the random -1 to -100 days, we can make it more readable by replacing the new \DateTime() with
self::faker() - that's how you can get an instance of the Faker object - then ->dateTimeBetween() to go from -100 days to -1
day.
57 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
... lines 25 - 38
'askedAt' => rand(1, 10) > 2 ? self::faker()->dateTimeBetween('-100 days', '-1 days') : null,
... line 40
];
}
... lines 43 - 55
}
And because this is more flexible, we can even change it from -100 days to -1 minute!
57 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
... lines 25 - 38
'askedAt' => rand(1, 10) > 2 ? self::faker()->dateTimeBetween('-100 days', '-1 minute') : null,
... line 40
];
}
... lines 43 - 55
}
Even the random true/false condition at the beginning can be generated by Faker. What we really want is to create published
questions about 70% of the time. We can do that with self::faker()->boolean(70):
57 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
... lines 25 - 38
'askedAt' => self::faker()->boolean(70) ? self::faker()->dateTimeBetween('-100 days', '-1 minute') : null,
... line 40
];
}
... lines 43 - 55
}
This is cool, but the real problem is that the name and question are always the same. That is definitely not realistic. Let's fix
that: set name to self::faker()->realText() to get several words of "real looking" text:
57 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
'name' => self::faker()->realText(50),
... lines 26 - 40
];
}
... lines 43 - 55
}
57 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
... line 25
'slug' => self::faker()->slug,
... lines 27 - 40
];
}
... lines 43 - 55
}
Finally, for the question text, it can be made much more interesting by using self::faker->paragraphs().
49 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
... lines 25 - 26
'question' => self::faker()->paragraphs(
... lines 28 - 29
),
... lines 31 - 32
];
}
... lines 35 - 47
}
Faker lets you use paragraphs like a property or you can call a function and pass arguments, which are the number of
paragraphs and whether you want them returned as text - which we do - or as an array. For the number of paragraphs, we
can use Faker again! self::faker()->numberBetween(1, 4) and then true to return this as a string.
49 lines src/Factory/QuestionFactory.php
... lines 1 - 19
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
... lines 25 - 26
'question' => self::faker()->paragraphs(
self::faker()->numberBetween(1, 4),
true
),
... lines 31 - 32
];
}
... lines 35 - 47
}
Let's take this for a test drive! Find your terminal and reload the fixtures with:
Oh, but the "real text" for the name is way too long. What I meant to do is pass ->realText(50). Let's reload the fixtures again:
And... there we go! We now have many Question objects and they represent a rich set of unique data. This is why I love
Foundry.
Remove the slug key from getDefaults() and, instead, down here, uncomment this beforeInstantiate() and change it to
afterInstantiate().
54 lines src/Factory/QuestionFactory.php
... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
protected function getDefaults(): array
{
return [
'name' => self::faker()->realText(50),
'question' => self::faker()->paragraphs(
self::faker()->numberBetween(1, 4),
true
),
'askedAt' => self::faker()->boolean(70) ? self::faker()->dateTimeBetween('-100 days', '-1 minute') : null,
'votes' => rand(-20, 50),
];
}
... lines 35 - 52
}
So afterInstantiate(), we want to run this function. Inside, to generate a random slug based off of the name, we can say: if not
$question->getSlug() - in case we set it manually for some reason:
54 lines src/Factory/QuestionFactory.php
... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
... lines 23 - 35
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
->afterInstantiate(function(Question $question) {
if (!$question->getSlug()) {
... lines 42 - 43
}
})
;
}
... lines 48 - 52
}
54 lines src/Factory/QuestionFactory.php
... lines 1 - 6
use Symfony\Component\String\Slugger\AsciiSlugger;
... lines 8 - 20
final class QuestionFactory extends ModelFactory
{
... lines 23 - 35
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
->afterInstantiate(function(Question $question) {
if (!$question->getSlug()) {
$slugger = new AsciiSlugger();
... line 43
}
})
;
}
... lines 48 - 52
}
54 lines src/Factory/QuestionFactory.php
... lines 1 - 6
use Symfony\Component\String\Slugger\AsciiSlugger;
... lines 8 - 20
final class QuestionFactory extends ModelFactory
{
... lines 23 - 35
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
->afterInstantiate(function(Question $question) {
if (!$question->getSlug()) {
$slugger = new AsciiSlugger();
$question->setSlug($slugger->slug($question->getName()));
}
})
;
}
... lines 48 - 52
}
Nice! Let's try it. Move over, reload the fixtures again:
And... go back to the homepage. Let's see: if I click the first one... yes! It works. It has some uppercase letters... which we
could normalize to lowercase. But I'm not going to worry about that because, in a few minutes, we'll add an even better way
of generating slugs across our entire system.
Foundry "State"
Let's try one last thing with Foundry. To have nice testing data, we need a mixture of published and unpublished questions.
We're currently accomplishing that by randomly setting some askedAt properties to null. Instead let's create two different sets
of fixtures: exactly 20 that are published and exactly 5 that are unpublished.
To do this, first remove the randomness from askedAt in getDefaults(): let's always set this.
59 lines src/Factory/QuestionFactory.php
... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
... lines 23 - 27
protected function getDefaults(): array
{
return [
... lines 31 - 35
'askedAt' => self::faker()->dateTimeBetween('-100 days', '-1 minute'),
... line 37
];
}
... lines 40 - 57
}
If we stopped here, we would, of course, have 20 questions that are all published. But now, add a new public function to the
factory: public function unpublished() that returns self.
59 lines src/Factory/QuestionFactory.php
... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
public function unpublished(): self
{
... line 25
}
... lines 27 - 57
}
I totally just made up that name. Inside, return $this->addState() and pass it an array with askedAt set to null.
59 lines src/Factory/QuestionFactory.php
... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
public function unpublished(): self
{
return $this->addState(['askedAt' => null]);
}
... lines 27 - 57
}
Here's the deal: when you call addState(), it changes the default data inside this instance of the factory. Oh, and the return
statement here just helps to return self... which allows method chaining.
To use this, go back to AppFixtures. Start with QuestionFactory::new() - to get a second instance of QuestionFactory:
24 lines src/DataFixtures/AppFixtures.php
... lines 1 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 14 - 15
QuestionFactory::new()
... lines 17 - 18
;
... lines 20 - 21
}
}
then ->unpublished() to change the default askedAt data. You can see why I called the method unpublished(): it makes this
super clear.
24 lines src/DataFixtures/AppFixtures.php
... lines 1 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 14 - 15
QuestionFactory::new()
->unpublished()
... line 18
;
... lines 20 - 21
}
}
24 lines src/DataFixtures/AppFixtures.php
... lines 1 - 9
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
... lines 14 - 15
QuestionFactory::new()
->unpublished()
->createMany(5)
;
... lines 20 - 21
}
}
I love this! It reads like a story: create a new factory, make everything unpublished and create 5.
Let's... even make sure it works! At the terminal, reload the fixtures:
All good! If we dug into the database, we'd find 20 published questions and five unpublished. Foundry can do more -
especially with Doctrine relations and testing - and we'll talk about Doctrine relations in the next tutorial.
But first, the slug property is being set automatically in our fixtures. That's cool... but I'd really love for the slug to automatically
be set to a URL-safe version of the name no matter where we create a Question object. Basically, we shouldn't never need to
worry about setting the slug manually.
So next let's install a bundle that will give our entity Sluggable and Timestampable superpowers.
Chapter 22: Sluggable: Doctrine Extensions
The whole point of the slug is to be a URL-safe version of the name. And, ideally, this wouldn't be something we need to set
manually... or even think about! In a perfect world, we would be able to set the name of a Question, save and something else
would automatically calculate a unique slug from the name right before the INSERT query.
We accomplished this in our fixtures, but only there. Let's accomplish this everywhere.
Hello StofDoctrineExtensionsBundle
To do that, we're going to install another bundle. Google for StofDoctrineExtensionsBundle and find its GitHub page. And
then click over to its documentation, which lives on Symfony.com. This bundle gives you a bunch of superpowers for entities,
including one called Sluggable. And actually, the bundle is just a tiny layer around another library called doctrine extensions.
This is where the majority of the documentation lives. Anyways, let's get the bundle installed. Find your terminal and run:
Contrib Recipes
And, oh, interesting! The install stops and says:
The recipe for this package comes from the contrib repository, which is open to community contributions. Review
the recipe at this URL. Do you want to execute this recipe?
When you install a package, Symfony Flex looks for a recipe for that package... and recipes can live in one of two different
repositories. The first is symfony/recipes, which is the main recipe repository and is closely guarded: it's hard to get recipes
accepted here.
The other repository is called symfony/recipes-contrib. This is still guarded for quality... but it's much easier to get recipe
accepted here. For that reason, the first time you install a recipe from recipes-contrib, Flex asks you to make sure that you
want to do that. So you can say yes or I'm actually going to say P for yes, permanently.
I committed my changes before recording recording, so when this finishes I'll run,
git status
to see what the recipe did! Ok: it enabled the bundle - of course - and it also created a new config file
stof_doctrine_extensions.yaml. Let's go check that out: config/packages/stof_doctrine_extensions.yaml.
5 lines config/packages/stof_doctrine_extensions.yaml
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
# See the official DoctrineExtensions documentation for more details:
https://github.com/Atlantic18/DoctrineExtensions/tree/master/doc/
stof_doctrine_extensions:
default_locale: en_US
8 lines config/packages/stof_doctrine_extensions.yaml
... lines 1 - 2
stof_doctrine_extensions:
default_locale: en_US
orm:
... lines 6 - 8
and then default:, because we want to enable this on our default entity manager. That's... really not important except in edge
cases where you have multiple database connections.
8 lines config/packages/stof_doctrine_extensions.yaml
... lines 1 - 2
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
... lines 7 - 8
8 lines config/packages/stof_doctrine_extensions.yaml
... lines 1 - 2
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
sluggable: true
That's it! Well... sort of. This won't make any real difference in our app yet. But, internally, the sluggable feature is now active.
Before we start using it, in QuestionFactory, remove the code that sets the slug. I'll delete this logic, but keep an example
function for later.
54 lines src/Factory/QuestionFactory.php
... lines 1 - 20
final class QuestionFactory extends ModelFactory
{
... lines 23 - 40
protected function initialize(): self
{
// see https://github.com/zenstruck/foundry#initialization
return $this
//->afterInstantiate(function(Question $question) { });
;
}
... lines 48 - 52
}
The @Gedmo\Slug annotation has one required option called fields={}. Set it to name.
Done! The slug will now be automatically set right before saving to a URL-safe version of the name property.
No errors! And on the homepage... yes! The slug looks perfect. We now never need to worry about setting the slug manually.
Next, let's add two more handy fields to our entity: createdAt and updatedAt. The trick will be to have something automatically
set createdAt when the entity is first inserted and updatedAt whenever it's updated. Thanks to Doctrine extensions, you're
going to love how easy this is.
Chapter 23: Timestampable & Failed Migrations
Ok team: I've got one more mission for us: to add createdAt and updatedAt fields to our Question entity and make sure that
these are automatically set whenever we create or update that entity. This functionality is called timestampable, and Doctrine
Extensions totally has a feature for it.
Activating Timestampable
Start by activating it in the config file: stof_doctrine_extensions.yaml. Add timestampable: true.
9 lines config/packages/stof_doctrine_extensions.yaml
... lines 1 - 2
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
sluggable: true
timestampable: true
Back at the browser, click into the Doctrine Extensions docs and find the Timestampable page. Scroll down to the example...
Ah, this works a lot like Sluggable: add createdAt and updatedAt fields, then put an annotation above each to tell the library
to set them automatically.
... lines 1 - 7
use Gedmo\Timestampable\Traits\TimestampableEntity;
... lines 9 - 12
class Question
{
use TimestampableEntity;
... lines 16 - 134
}
That's it. Hold command or control and click to open that trait. How beautiful is this? It holds the two properties with the ORM
annotations and the Timestampable annotations. It even has getter and setter methods. It's everything we need.
But since this does mean that we just added two new fields to our entity, we need a migration! At your terminal run:
Then go check it out to make sure it doesn't contain any surprises. Yup! It looks good: it adds the two columns.
32 lines migrations/Version20200709153558.php
... lines 1 - 2
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
... lines 9 - 12
final class Version20200709153558 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME NOT
NULL');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question DROP created_at, DROP updated_at');
}
}
And... yikes!
There are two options depending on your situation. First, if you have not deployed your app to production yet, then you can
reset your local database and start over. Why? Because when you eventually deploy, you will not have any questions in the
database yet and so you will not have this error when the migration runs. I'll show you the commands to drop a database in a
minute.
But if you have already deployed to production and your production database does have questions in it, then when you
deploy, this will be a problem. To fix it, we need to be smart.
Let's see... what we need to do is first create the columns but make them optional in the database. Then, with a second
query, we can set the created_at and updated_at of all the existing records to right now. And finally, once that's done, we can
execute another alter table query to make the two columns required. That will make this migration safe.
Modifying a Migration
Ok! Let's get to work. Usually we don't need to modify a migration by hand, but this is one rare case when we do. Start by
changing both columns to DEFAULT NULL.
33 lines migrations/Version20200709153558.php
... lines 1 - 12
final class Version20200709153558 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question ADD created_at DATETIME DEFAULT NULL, ADD updated_at DATETIME
DEFAULT NULL');
... line 24
}
... lines 26 - 31
}
33 lines migrations/Version20200709153558.php
... lines 1 - 12
final class Version20200709153558 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question ADD created_at DATETIME DEFAULT NULL, ADD updated_at DATETIME
DEFAULT NULL');
$this->addSql('UPDATE question SET created_at = NOW(), updated_at = NOW()');
}
... lines 26 - 31
}
Let's start here: we'll worry about making the columns required in another migration.
The big question now is... should we just run our migrations again? Not so fast. That might be safe - and would in this case -
but you need to be careful. If a migration has multiple SQL statements and it fails, it's possible that part of the migration was
executed successfully and part was not. This can leave us in a, sort of, invalid migration state.
It would look like a migration was not executed, when in fact, maybe half of it actually was! Oh, and by the way, if you use
something like PostgreSQL, which supports transactional DDL statements, then this is not a problem. In that case, if any part
of the migration fails, all the changes are rolled back.
Next, I'll temporarily comment out the new trait in Question. That will allow us to reload the fixtures using the old database
structure - the one before the migration. I also need to do a little hack and take the .php off of the new migration file so that
Doctrine won't see it. I'm doing this so that I can easily run all the migrations except for this one.
Let's do it:
Excellent: we're back to the database structure before the new columns. Now load some data:
Beautiful. Back in our editor, undo those changes: put the .php back on the end of the migration filename. And, in Question,
re-add the TimestampableEntity trait.
Now we can properly test the new version of the migration. Do it with:
Go check out the new file. Doctrine: you smartie! Doctrine noticed that the columns were not required in the database and
generated the ALTER TABLE statement needed to fix that.
32 lines migrations/Version20200709155920.php
... lines 1 - 2
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
... lines 9 - 12
final class Version20200709155920 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at
updated_at DATETIME NOT NULL');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE question CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE
updated_at updated_at DATETIME DEFAULT NULL');
}
}
Okay, friends, we did it! We just unlocked huge potential in our app thanks to Doctrine. We know how to create entities,
update entities, generate migrations, persist data, create dummy fixtures and more! The only big thing that we have not talked
about yet is doctrine relations. That's an important enough topic that we'll save it for the next tutorial.
Until then start building and, if you have questions, thoughts, or want to show us what you're building - whether that's a
Symfony app or an extravagant Lego creation, we're here for you down in the comments.