Modeling Data for an API with Symfony and Doctrine (Part 1/2)
An intro to modeling and managing database updates with Symfony and Doctrine
March 09, 2020
I’ve been preparing a session to introduce co-workers to Symfony. I started by documenting commands to follow during the presentation but decided to kill two birds with one stone by spending the weekend crafting a thorough post for my shiny new blog.
By the end of this post it should be clear how easy and powerful it is to create and maintain your application data with Symfony and Doctrine.
In a future post, we will add fixtures to seed the database with user data, and then install API Platform to add a REST API.
Here’s what we’ll do:
- Create a bare-bones Symfony application
- Install the ORM (Doctrine) dependencies
- Configure the database connection string
- Create the database
- Create a User entity (model)
- Use annotations to customize the table name and declare email unique
- Install the schema directly from the entity class
- Wipe the schema
- Create a migration file and run it to build the users table
- Add additional fields to the User entity
- Create and run another migration file that updates the users table
- Create a Role entity with a many to one relation to a User
- Look at annotations to control how the join table and fields are named
- Create and run another migration to update the database (modify users, add roles & users_roles)
Ready? Let’s begin…
Prerequisites
You will need an environment with PHP, Composer, and a supported database like PostgreSQL, MariaDB, or MySQL installed.
Generate a new Symfony project skeleton
Let’s start by creating a Symfony app with composer.
composer create-project symfony/skeleton users-api
cd users-api/
This is the simplest method to create a Symfony project however there are other ways including using the Symfony CLI with options such as designating the LTS version.
Database Setup
Now it’s time to install symfony/orm-pack.
This includes Doctrine, an ORM with a feature set that rivals ORMs of any language.
composer require symfony/orm-pack
Note:
bin/console
is a relative path from the project root to a PHP script. Some environments may require you to prepend it withphp
. i.e.php bin/console ...
Update .env file with database credentials
When the bundle is installed, it will add a DATABASE_URL
environment var to the .env
file. Update the
value with your database information.
DATABASE_URL=mysql://root:@127.0.0.1:3306/lando_users?serverVersion=5.7
Create the database
If your database server is running and the DATABASE_URL
environment variable is correct, then you should be able
to create the named database by running the following command:
bin/console doctrine:database:create
Before we get started with the data modeling, let’s install symfony/maker-bundle.
This provides the make
command which is Symfony’s CLI tool for scaffolding entities, controllers, etc.
This is only required for development so be sure to use the --dev
flag.
composer require --dev symfony/maker-bundle
Entities
Entities in the context of Symfony and Doctrine are your database models. Doctrine uses a Data Mapper Pattern in which an entity represents a table but clearly separated from the business logic. Entities are typically annotated properties with nothing more than getters and setters.
In contrast, another popular ORM Eloquent uses an Active Record pattern where the data object is more tightly coupled with business logic.
As an example, an Eloquent object is saved with $obj->save()
while a Doctrine object is passed into a service
to be saved. i.e. em->persist($obj)
.
While Doctrine may score some points here in respect to the Single Responsibility Principle, but the way it handles relations and migrations make it stand apart from not only its PHP based peers, but top ORMs in other languages too!
Create User Entity
With Doctrine, your entity class IS your schema! It is pretty useful when you’re able to reference a single class for the schema of your database. Not just for you, but for your IDE and linting tools as well! This is a feature you don’t realize the power of till you’ve worked with it for a while and suddenly don’t have it anymore when moving into another framework.
Running make:entity
command will send you through a dialog which will allow you to add fields to your
model. Don’t worry if you forget a field, or get it wrong, running the command a second time on the same
entity will add to the existing one.
bin/console make:entity User
Create the following fields:
- email (string, 255)
- password (string, 64)
- firstName (string, 64)
- lastName (string, 64)
- created (datetime)
Once you’re done adding fields, press enter and it will have created 2 files:
src/Entity/User.php
: Data model class with properties and getters/setters.src/Repository/UserRepository.php
: Data access class for queries beyond what Doctrine provides by default.
Manually Update src/Entity/User.php
:
id
unsigned:@ORM\Column(type="integer", options={"unsigned":true})
email
unique:@ORM\Column(type="string", length=128, unique=true)
Name the Table
By default, Doctrine will name the table as the name of the entity. In our case, the table would be named user
.
If you prefer the plural version, or an entirely different table name, you can add an annotation at the top of the class.
/**
* @ORM\Table(name="users")
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/
class User
{
...
Create the Schema from Entities
Now it’s time to create the tables in the database.
The following command will create a database schema based on the current data modeling defined in your entities.
bin/console doctrine:schema:create
When developing, it’s likely you will need to re-initialize the database. In this case you can drop before creating the scema again.
bin/console doctrine:schema:drop --force
bin/console doctrine:schema:create
Migrations
Migrations are files containing SQL schema updates, named in a manner they can be ordered chronologically. Each instance tracks the last run migration so they can only the required updates as new files are added.
There are 2 actions you take with migrations:
- Create the migration (Create the code that will update the database)
- Run the migration (Update the database)
Creating a Migration:
Running bin/console make:migration
analyzes the difference between your current
database and the entity file, then automatically generates a migration file containing ONLY the SQL required
to get your database from where it is now, to what it should be based on your entities.
For example: If you have an empty database and run bin/console make:migration
at this point, a migration
file will be created which contains SQL to create the users
table along with each field in Users.php
.
If however your database matched your entity file, and you added a password
field to Users.php
, running
bin/console make:migration
would only contain the SQL to add the password
field.
While other ORMs require you to add table alters and other SQL in migrations manually, Doctrine automatically generates the code based on a diff analysis between your entities and database.
Let’s do it! First, take a look in src/Migrations
. Notice there isn’t any migration files there.
Now run the following command.
bin/console make:migration
Now look in the migration directory again. You should have a file named something like:
src/Migrations/Versionyyyymmddhhmmii.php
. In that file you have up
and down
methods.
If you’ve been following along, you probably don’t have much in there. This is because we had already
built our schema directly with bin/console doctrine:schema:create
, and not a migration.
Let’s fix this:
- Delete the migration file we just generated
- Delete the schema by running:
bin/console doctrine:schema:drop --force
- Generate a new migration file:
bin/console make:migration
Have another look at the migration file and you will see the SQL to create the users
table is there.
Your database is still empty as we haven’t run the migration yet. We only created the migration file.
Running Migrations
Use the following command to run all migration which need to be run:
bin/console doctrine:migrations:migrate
Pro Tip: You can abbreviate the words in each section of the command. i.e.
bin/console doc:mig:mig
. I typically keep aMakefile
which calls these commands as their full version, but this shortcut saves a few strokes and likely many typos when I don’t have to manually type all characters.
Scenario
Up until now it didn’t matter if we had built our schema with doctrine:migrations:migrate
or
bin/console doctrine:schema:create
because we can wipe our dev systems without consequence and go directly
to the latest schema.
Imagine now that our app has been deployed to production.
While we can continue to standup our dev systems with schema:create
, using migrations:migrate
is an absolute
must to update production without errors and data loss.
The scenario at this point is to add an active
field to the User
entity.
Adding Another Field
Let’s add an active
field of the boolean
type.
bin/console make:entity User
The entity has been updated but our database has not.
Add an Index
Too often database indices are an after thought. You should be thinking about them
as you design your models. Think about which fields queries will be based on. In other
words, consider adding indices on fields used in the WHERE
and ORDER BY
clauses of your queries…
or in the case of an ORM, which fields you create conditions on.
Note: While indices can improve performance with queries (reads), they can reduce performance for writes. All this is beyond the scope of this post but I recommend studying more about this topic of you aren’t familiar already.
The most common query on the user table will be based on user id and email.
Consider common queries our application may run against the users table:
# Query a user by id
SELECT * FROM users WHERE id=1;
# Authentication Query
SELECT * FROM users WHERE email='me@example.com' AND password='hashedpass...';
# Active users query orderd by last name
SELECT * FROM users WHERE active=1 ORDER BY last_name;
# Autocomplete query based on last name
SELECT * FROM users WHERE active=1 AND last_name LIKE 'mila%'
The fields of interest here are:
- id
- active
- last_name
The id
field is already indexed because it’s the primary key of our table.
The email
field is already indexed because we added a unique constraint which
adds an index behind the scenes.
This means we need to focus on active
and last_name
.
When searching by last name, it’s likely we’re only going to be interested in active users, so let’s add a combined index of active and last name.
We’ll use the annotation @ORM\Index
nested inside the @ORM\Table
annotation.
The top of your user class should now look something like this:
/**
* @ORM\Table(name="users",
* indexes={
* @ORM\Index(name="user_active_lname", columns={"active", "last_name"})
* })
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
* @ApiResource
*/
class User
{
...
Create Another Migration
Before generating another migration, it is critical that you do this on a copy of the database that is using the production schema.
Remember, a Doctrine migration analyzes the existing database and automatically generates the queries required to make that database match the entities.
bin/console make:migration
Commit that code and be sure to run the migration locally, and on the remote environments once deployed.
bin/console doctrine:migrations:migrate
Creating Relations Between Entities
Now let’s create a Role entity which we will relate to the User entity in a later step.
bin/console make:entity Role
Create the following fields:
- name (string, 64)
- role (string, 16)
Once complete, you will see a new Role.php
in your entities directory.
Adding a Relation
When associating users to roles, we’ll use a ManyToMany
. For this relationship,
an additional table will be required to map user ids to role ids.
Doctrine has a concept of an owning side of a relation, and while it doesn’t
make that much of a difference in a ManyToMany relationship, I prefer in this case
to set the User as the owning side. This is relevant because when using make:entity
,
you add the field to the owning side of the relation.
bin/console make:entity User
- name: roles
- type: relation
- related to:
Role
- relation:
ManyToMany
- Select
yes
when prompted to add ausers
property toRole
.
Once complete, inspect your User.php
entity class and notice the addition of the following
detail which as added to the code:
- The
$roles
property was added with annotation mapping it to the Role entity $this->roles
is initialized in the constructor as anew ArrayCollection()
- Methods
getRoles()
,addRole()
andremoteRole()
have been added. - You will find the same relations have been added to the new
Role
entity.
/**
* @ORM\ManyToMany(targetEntity="App\Entity\Role", inversedBy="users")
*/
private $roles;
public function __construct()
{
$this->roles = new ArrayCollection();
}
...
/**
* @return Collection|Role[]
*/
public function getRoles(): Collection
{
return $this->roles;
}
public function addRole(Role $role): self
{
if (!$this->roles->contains($role)) {
$this->roles[] = $role;
$role->addUser($this);
}
return $this;
}
public function removeRole(Role $role): self
{
if ($this->roles->contains($role)) {
$this->roles->removeElement($role);
$role->removeUser($this);
}
return $this;
}
Doctrine will automatically name the join table and fields, but you do have control over that yourself.
Take a look at the JoinTable
annotation I added above the $roles
property which
defines the join table name, field names. It also adds a unique constraint (index) to the
user to role id relationship.
/**
* @ORM\JoinTable(name="users_roles",
* joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
* inverseJoinColumns={@ORM\JoinColumn(name="role_id", referencedColumnName="id")})
*/
private $roles;
Without this code, it would have created a join table named user_role
. The field
names would have remained the same but this demonstrates where you can modify field names.
Note: User and Role have additional functionality in a traditional Symfony application. This app does not use Symfony’s security features and while we’re not concerned with these features, these could be extended with little effort and be used with the security bundle.
Manually Update src/Entity/Role.php
:
Let’s make a few updates to the Role.php
entity class before updating our schema:
-
Name the table by adding this annotation just above
class
as we did with User.@ORM\Table(name="roles")
-
Add an index on
user_id
under the Table annotation you just added.@ORM\Index(name="ur_uid", columns={"user_id"}),
-
Set the id to be an unsigned int.
id
unsigned:@ORM\Column(type="integer", options={"unsigned":true})
-
Set the
role
field to be unique.role
unique:@ORM\Column(type="string", length=16, unique=true)
Create and Run the Migration
All that is left is to create and run the migration.
First, create the migration file:
# Create the migration file
bin/console make:migration
It’s always a good idea to look over the migration file before committing it to your repository, or running it.
Once you confirm it looks good, run it:
bin/console doctrine:migrations:migrate
That’s it. You can inspect your database and confirm it has the tables defined in your User entity.
Conclusion
There is plenty more to learn about Doctrine, the workflow demonstrated here speaks to the power Doctrine allows in modeling and maintaining consistency between your application and database.
Would you like to see more content like this?
Let me know! Follow and share your thoughts on Twitter. DMs always open.