Creating a Web App with Symfony 4: Doctrine Entities

In this tutorial we’re going to start building a resume builder web app. We’ll be building off of the code that we wrote in the previous tutorials. If you haven’t been following along and want to get a head start, you can download the starting code here. Though it’s recommended to start from the beginning if your completely new to Symfony. We’ll be using everything we learned in the previous tutorials to make this web app happen.

First we will update our User entity to include some extra details that normally appear on a resume, like address, phone and name, etc. Then we’ll create some entities that will hold any of the information we’d need on our resume. We’re going to be delving into creating relationship properties with Doctrine along with a special package that makes creating certain properties on our entities a lot easier.

With that said, let’s get started!

Step 1: Update our User Entity

Currently our User entity really only has an email and password. You need to have your name on your resume, so let’s add that. Open a terminal and navigate to your project’s directory. Then type:

php bin/console make:entity

When it asks which entity, type User. Even though we already have an entity called User, Symfony knows to only update it rather than create a new one. We’re going to add a first_name and last_name field. They should be strings, 255 length and they can be null.

When your done adding those 2 fields, we need to make a migration and update our database to include those fields. To make a migration type in your terminal:

php bin/console make:migration

Then to run the migration type:

php bin/console doctrine:migrations:migrate

We went over migrations in the first tutorial, but if you don’t remember, they are essentially a way of updating your database. Symfony looks at your entities, compares them to your database and creates the queries needed to get your database to match.

Step 2: Address Entity

For our resume builder we’re going to make a separate entity for the user’s address. You could technically add extra fields to the User entity but it’s better practice to separate this kind of information. Our entities should describe a single thing and an address is different enough from a user that it should get it’s own separate entity.

We’ll create our Address entity using the terminal as usual so type:

php bin/console make:entity

When it asks for the name, type Address.

We’re going to add the following fields: line1, line2, city, state and postal_code.

Each of those should be string fields. For line1, line2 and city allow 255 characters. For state allow 2 characters and for postal_code allow 10. None of the fields should be null.

SIDE NOTE:
You might be asking: why are we making the postal_code a string? If you are, great question. That’s because while they are usually numeric (in the US at least), some postal codes begin with a 0, which would be removed when they are stored in your database. So for instance 02453, would get saved as 2453 in your database. Also, some countries outside the US have letters in their postal codes. So to accommodate everywhere, it’s best to store a postal code as a string instead of a number.

The last field that we need to add is user. This is going to be a special type of field. When it asks, type relation. It will ask which class it’s related to. For that, type User.

Next it will ask the relationship type. For our app, one User can only have one Address. So the relationship is OneToOne. The property should not be allowed to be null.

Then it will ask if you want to be able to access the address from the User entity. For that, type yes. This will make adding/editing a user’s address a little easier. For field name inside User, type address.

And that’s all the fields for now.

If you open your src/Entity folder you should see the Address.php file was created. Take a look, it has all the fields we added with setters and getters. If you open the User.php file, you’ll see the address property was added along with a setter and getter method for the address. Sweet!

Step 3: Phone Entity

These days people usually will have a home phone number and a mobile phone number. So it’s probably best to separate phone out from Address. We could have a home_phone and mobile_phone field on the user but this would be a similar issue with having all address fields on the user. So to start, create a new entity using the terminal. You should know the command by now but if you don’t:

php bin/console make:entity

We’ll call it Phone. For fields, we’re going to have a name, phone and user field. The name and phone field will be strings and the user will be a relation, like the Address entity has. For name, allow 255 characters, for phone allow 10 characters. None of the fields should be null.

The user relation is going to be set up a little differently than the address one. When it asks which entity it’s related to type User.

For the relationship type we’re going to choose ManyToOne. That’s because a user is going to be able to have many phone numbers. And a phone number is only allowed to belong to a single user.

When it asks about the Phone.user field being null, type no. And when it asks if you want to add a new property on User to access Phone objects, type yes. For the field name in User, type phones.

Next it will ask a new question about orphan removal. This basically means that if you update a phone to have no user, the phone automatically gets deleted. Yes, we want this since we want every phone to always belong to a User.

And those are the fields for the Phone entity.

Open the src/Entity/Phone.php file and you’ll see the entity we just created. It should look like a normal entity. If you open your User entity file, you’ll see the phones property was added as expected. But if you scroll down to the methods that were added, they aren’t just a getter and setter. We have a getPhones, addPhone and removePhone. That’s because of the type of relationship our Phone entities have with our User entites. It’s not like an address where there’s a single address for each user. You can add multiple phones to the user, so that’s why Symfony creates the methods like that.

Step 4: Resume Entity

Next we’ll work on a few entities that will work to put our resume together. We’ll make a Resume entity, a ResumeSection entity and a ResumeSectionItem entity. The Resume entity will be a kind of parent entity that will consist of many ResumeSection entities. ResumeSectionItems will contain the details. So for example, a ResumeSection entity might be titled Education and then that will contain a bunch of ResumeSectionItems where someone went to school. I’ve decided to split it up this way to allow someone to have a bunch of different types of resumes and to let someone have control over the types of sections they want on their resume.

With that said, let’s get started with creating those entities!

First up is the Resume entity. In your terminal type:

php bin/console make:entity

For class name type Resume. Then we’re going to have 2 fields. One for the name of the resume and the other is the user. The name will be a string with 255 characters and not null.

For the user property, we want to set it up just like we did with the Phone entity. Users can have many resumes. So choose ManyToOne for the relation type. The user field on the Resume entity should not be null. And yes, we want to add a property on the User entity to access/update Resume objects. Use resumes as the property name. For the question that asks about orphaned objects, choose yes.

Those are all the fields we will add to the Resume entity for now. In the next step we’re going to add a few more properties, but will do it in a different way than we have been.

Step 5: Created At, Updated At and Deleted At Properties

Before we create and run the migration, I want to show you one more helpful package. It’s called StofDoctrineExtensionsBundle. There are a bunch of things that this package can do for us but there are 2 main ones that we’ll use for this tutorial. First, it adds a way to easily have your entities be timestamped, meaning they get a createdAt and an updatedAt property with a date/time attached to them. Now we could set this up as we would with an entity property, but this package just makes doing that a lot faster. Secondly, it makes it easier to have entities that can be soft deleted which adds a deletedAt date/time field to your entities. If you want to read more about what else you can do with this package click here.

To install the package, in your terminal type:

composer require stof/doctrine-extensions-bundle

Before it completes installing it will ask if you want to execute this recipe. That’s because the package isn’t an “official” symfony package. So for security reasons, it will ask you to confirm that you actually want to install it. Choose yes to install it.

Next we need to configure it a little to get the timestamps and soft deletes to work. Open up the config/packages/stof_doctrine_extensions.yaml file. Update it to look like the following:

# 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
    orm:
        default:
            softdeleteable: true
            timestampable: true
doctrine:
    orm:
        entity_managers:
            default:
                filters:
                    softdeleteable:
                        class: Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter
                        enabled: true

Remember to make sure that each indention is 4 spaces or your app will break. Essentially the first part that we added, the stof_doctrine_extensions > orm part, enables the two extensions. The doctrine part adds a filter for doctrine to use when querying our database. It makes it so anything that has a date and time in the deleted column doesn’t show up in your results.

Next we need to update our entity. So open src/Entity/Resume.php. At the beginning of the class definition, just above the private $id comments line, add the following bit of code:

use TimestampableEntity;
use SoftDeleteableEntity;

And we need to import them at the top of the file with the following:

use Gedmo\Timestampable\Traits\TimestampableEntity;
use Gedmo\SoftDeleteable\Traits\SoftDeleteableEntity;

These are traits that come with the package. The TimestampableEntity trait adds a createdAt property and an updatedAt property. It also adds getters and setters for each. The createdAt and updatedAt are expected to be DateTime PHP objects.

The SoftDeleteableEntity adds a deletedAt property along with a setter and getter. It also adds an isDeleted() method which returns true or false depending on if the entity is deleted.

TIP:
If you want to take a closer look at these traits they are located in the vendor/gedmo/doctrine-extensions/lib folder. Each trait has its own folder, then under for example the SoftDeletable folder is a Traits folder and in there is a file called SoftDeletableEntity.php. The same goes for the TimestampableEntity except it’s in the Timestampable folder.

There’s one other thing we need to add to get the soft deletes to work. In the annotation comment above the class Resume line, add this additional annotation:

@Gedmo\SoftDeleteable()

We need to add the following import for that to work:

use Gedmo\Mapping\Annotation as Gedmo;

So just to be sure, the top portion of your Resume.php file should look something like:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Gedmo\SoftDeleteable\Traits\SoftDeleteableEntity;
use Gedmo\Mapping\Annotation as Gedmo;

/**
 * @ORM\Entity(repositoryClass="App\Repository\ResumeRepository")
 * @Gedmo\SoftDeleteable()
 */
class Resume
{

   use TimestampableEntity;
   use SoftDeleteableEntity;

Ok, now that we have all of that set up, let’s create the migration. So in your terminal type:

php bin/console make:migration

And then run the migration with the following command:

php bin/console doctrine:migrations:migrate

If you look at the table in phpMyAdmin or MySQL Workbench, you should see that the created_at, updated_at and deleted_at properties are included in the table schema. Also, if this is the first time you’re looking at the table schemas since we created the other entities, you’ll notice that even though we called our property user on the entity, it’s added it to the table schema as user_id. Doctrine does some behind the scenes magic to figure out that our user property relates to a user_id field in the database.

Now that we know about timestampable and soft deleteable, update your other entities to have those fields as well. It’s always helpful, especially when you’re trying to debug things, to know when a record was created or last updated. Using soft deletes is more of a personal preference of mine, I wouldn’t say you have to use it, but it helps against accidentally deleting something. You simply set the deleted_at field back to null as opposed to recreating the record.

Once you update your other entities, create the migration but don’t run it yet. We need to tweak the migration file a little. Since we have users already in our database, an error is going to occur if we were to run the migration. When it tries to add the created_at and updated_at fields, that field is required to not be null. But since there is no valid date set for the pre-existing records, an error is going to occur.

So what we need to do is modify the query that alters the user table so it has a default date. Then we will run the modified migration so the queries are ran and our tables get updated. Then we will create another new migration which will reverse the default value. This works because there is no default value set in our entity so Symfony will see that the entity and database do not match. It might sound a little confusing, but once we actually do it you should hopefully be able to understand a little more. Here is what your migration file should look like after it was auto-generated from Symfony:

<?php declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20181121011115 extends AbstractMigration
{
   public function up(Schema $schema) : void
   {
       // this up() migration is auto-generated, please modify it to your needs
       $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

       $this->addSql('ALTER TABLE address ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME NOT NULL, ADD deleted_at DATETIME DEFAULT NULL');
       $this->addSql('ALTER TABLE phone ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME NOT NULL, ADD deleted_at DATETIME DEFAULT NULL');
       $this->addSql('ALTER TABLE user ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME NOT NULL, ADD deleted_at DATETIME DEFAULT NULL');
   }

   public function down(Schema $schema) : void
   {
       // this down() migration is auto-generated, please modify it to your needs
       $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

       $this->addSql('ALTER TABLE address DROP created_at, DROP updated_at, DROP deleted_at');
       $this->addSql('ALTER TABLE phone DROP created_at, DROP updated_at, DROP deleted_at');
       $this->addSql('ALTER TABLE user DROP created_at, DROP updated_at, DROP deleted_at');
   }
}

The only thing that really would be different is the name of the class. We need to modify this query:

ALTER TABLE user ADD created_at DATETIME NOT NULL, ADD updated_at DATETIME NOT NULL, ADD deleted_at DATETIME DEFAULT NULL

Change it to be the following:

ALTER TABLE user ADD created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ADD updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, ADD deleted_at DATETIME DEFAULT NULL

This essentially uses the current date and time as the default value for the created_at and updated_at fields. Now run the migration like so:

php bin/console doctrine:migrations:migrate

It should go through without any errors. Next run another make:migration command:

php bin/console make:migration

It should generate a file that looks similar to this:

<?php declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20181121013005 extends AbstractMigration
{
   public function up(Schema $schema) : void
   {
       // this up() migration is auto-generated, please modify it to your needs
       $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

       $this->addSql('ALTER TABLE user 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->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

       $this->addSql('ALTER TABLE user CHANGE created_at created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CHANGE updated_at updated_at DATETIME DEFAULT CURRENT_TIMESTAMP');
   }
}

All this migration is really going to do is remove the default value on the two date fields. Again that’s because we modified the original query to create the created_at and updated_at fields a little differently than how they are represented in our entity.

Run this migration again with:

php bin/console doctrine:migrations:migrate

And your database and entities should now be in sync. Again, normally you wouldn’t have to do this but since we already had users in our user table, we needed to take this little detour to get everything synced up correctly.

So now you know that you are able to modify your migration files if you have to. Just know that if you go to modify one, any changes that you make to the query that aren’t represented in the entity, will be reversed in any future created migrations.

With that said, let’s move onto creating our last two entities for this app.

Step 6: ResumeSection Entity

Create the ResumeSection entity like you would any other, type in your terminal:

php bin/console make:entity

Call the class ResumeSection. If you remember from the beginning of the tutorial, this is going to hold the different sections of a resume like Education, Work History, etc. So for properties we will have title, position and resume. The position property will let us know what order the section should be displayed once we get to the frontend portion of our app. And the resume property lets us know which resume the section belongs to.

For title, it will be a string, with 255 characters and not null.

For the position property, make the type an integer and the field cannot be null.

For the resume property, make it a relation and the class it relates to is Resume. For the relationship type choose ManyToOne. Each Resume is going to have many ResumeSections. The resume property should not be null and yes, we do want the Resume entity to access/update ResumeSection objects. Use the default resumeSections name for the property on the Resume entity. And yes, we want orphan removal.

That’s the ResumeSection entity finished. Next up is ResumeSectionItems.

Step 7: Resume Section Items

Let’s create another entity so in your terminal type:

php bin/console make:entity

Call it ResumeSectionItem. It’s going to have a content, position and resumeSection property. The field type for content should be text and it can’t be null.

The position property is the same as the ResumeSection entity so it’s an integer and can’t be null.

The resumeSection property will be a relation type. It will relate to the ResumeSection entity and it is a ManyToOne relationship again. There will be many ResumeSectionItems in a single ResumeSection. The resumeSection property shouldn’t be null and we do want to add a property to the ResumeSection entity to access/update ResumeSectionItems. Call the property resumeSectionItems and we do want to delete orphaned objects.

Lastly, you should update the ResumeSection and ResumeSectionItems to be timestampable and softdeleteable like we did in the other entities. When you’re done, create a new migration with:

php bin/console make:migration

And run it with:

php bin/console doctrine:migrations:migrate

Our database and entities are now in sync. One other thing we will do is configure ResumeSection and ResumeSectionItems to use a sortable feature that also comes with the StofDoctrineExtensionsBundle. First we need to enable it in the config/packages/stof_doctrine_extensions.yaml file. Under the stof_doctrine_extensions > orm > default section, we need to set sortable to true. Copy the same way we turned timestampable and softdeletable on. To be sure, this is what that section of the config should look like:

stof_doctrine_extensions:
    default_locale: en_US
    orm:
        default:
            softdeleteable: true
            timestampable: true
            sortable: true

Once that’s set, we need to modify our entities. We’ll start with ResumeSection.

Above the $position property we need to add another annotation:

@Gedmo\SortablePosition

And above the $resume property add this annotation:

@Gedmo\SortableGroup

Your properties should look something like this:

  /**
    * @Gedmo\SortablePosition
    * @ORM\Column(type="integer")
    */
   private $position;

   /**
    * @Gedmo\SortableGroup
    * @ORM\ManyToOne(targetEntity="App\Entity\Resume", inversedBy="resumeSections")
    * @ORM\JoinColumn(nullable=false)
    */
   private $resume;

The SortablePosition tells the extension which property to use as the sort order or position. The SortableGroup annotation tells the extension which property to use as essentially the parent object. So for example, since we’re using resume as the group, if the position changes for any of the sections in a resume it will only affect the sections in that specific resume. It won’t affect the other resumes that are saved.

Next we need to modify the ResumeSectionRepository in the src/Repository folder. The sortable extension has a special repository class that gives some helpful methods that we can use. So rather than extending ServiceEntityRepository, change it to extend SortableRepository. Add the following use statement to import the SortableRepository class:

use Gedmo\Sortable\Entity\Repository\SortableRepository;

Then we need to change the constructor. The constructor definition for the SortableRepository looks like this:

public function __construct(EntityManagerInterface $em, ClassMetadata $class)

So we need to get an EntityManagerInterface and a ClassMetadata object. To do that use the following bit of code:

public function __construct(RegistryInterface $registry)
   {
       $entityClass = ResumeSection::class;
       $manager     = $registry->getManagerForClass($entityClass);

       parent::__construct($manager, $manager->getClassMetadata($entityClass));
   }

SIDE NOTE:
SortableRepository and ServiceEntityRepository both extend the same class: EntityRepository. The way I figured out how to rework the ResumeSectionRepository after changing it to extend SortableRepository was by looking at the ServiceEntityRepository class. The constructor in ServiceEntityRepository does essentially the same thing that we’re doing in our ResumeSectionRepository. It uses the $registry parameter to get the items needed for the EntityRepository constructor.

While we’re in the Repository directory, let’s make the same update to the ResumeSectionItemRepository class. Change the class it’s extending to SortableRepository. Import it with this line at the top of the file:

use Gedmo\Sortable\Entity\Repository\SortableRepository;

Then change the constructor to read like this:

public function __construct(RegistryInterface $registry)
   {
       $entityClass = ResumeSectionItem::class;
       $manager     = $registry->getManagerForClass($entityClass);

       parent::__construct($manager, $manager->getClassMetadata($entityClass));
   }

Next let’s add the SortablePosition and SortableGroup annotations to the ResumeSectionItem entity. Your properties should end up looking like this:

/**
 * @Gedmo\SortablePosition
 * @ORM\Column(type="integer")
 */
private $position;

/**
 * @Gedmo\SortableGroup
 * @ORM\ManyToOne(targetEntity="App\Entity\ResumeSection", inversedBy="resumeSectionItems")
 * @ORM\JoinColumn(nullable=false)
 */
private $resumeSection;

Once that’s finished we are done with our entities! If you noticed we haven’t actually coded that much. A lot of what you can do with Symfony is able to be generated through your terminal. Then there’s some configuration you need to do and you’re all set!

Hopefully you learned some new things with Symfony like using the timestampable, softdeleateable and sortable features. As well, hopefully you have a decent understanding of adding relationships to your entities. Next time we’re going to build out the services to manage these entities that we created and then later build the API endpoints.

If you want to be notified when the next part in this tutorial is posted sign up for the newsletter below. If you have any questions or feedback let me know in the comments!