Symfony 2.8 Jobeet Day 3: The Data Model

The Relational Model

The user stories from the previous day describe the main objects of our project: jobs, affiliates, and categories. Here is the corresponding entity relationship diagram:

In addition to the columns described in the stories, we have also added created_at and updated_at columns. We will configure Symfony 2 to set their value automatically when an object is saved or updated.

The Database

To store the jobs, affiliates and categories in the database, Symfony 2 uses Doctrine ORM. To define the database connection parameters you have to edit the app/config/parameters.yml file (for this tutorial we will use MySQL):

# app/config/parameters.yml
parameters:
database_host: localhost
database_name: jobeet
database_user: root
database_password: password

Now that Doctrine knows about your database, you can have it create the database for you (if you did not already created it):

php app/console doctrine:database:create

Creating Entity Classes

For each type of object we need, we will create an entity class (just a simple PHP class with some properties).

src/AppBundle/Entity/Category.php:

namespace AppBundle\Entity;
class Category
{
private $id;
private $name;
}

src/AppBundle/Entity/Job.php:

namespace AppBundle\Entity;
class Job
{
private $id;
private $category;
private $type;
private $company;
private $logo;
private $url;
private $position;
private $location;
private $description;
private $howToApply;
private $token;
private $isPublic;
private $isActivated;
private $email;
private $expiresAt;
private $createdAt;
private $updatedAt;
}

src/AppBundle/Entity/Affiliate.php:

namespace AppBundle\Entity;
class Affiliate
{
private $id;
private $categories;
private $url;
private $email;
private $token;
private $isActive;
private $createdAt;
}

Adding Mapping Information

To tell Doctrine about our objects, we will create “metadata” that will describe how our objects will be stored in the database. We will use annotations for this project but you can also use YAML or XML files to achieve the same result.

src/AppBundle/Entity/Category.php:

namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="category")
*/
class Category
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\Column(type="string", length=100)
*/
private $name;

/**
* @ORM\OneToMany(targetEntity="Job", mappedBy="category")
*/
private $jobs;

/**
* @ORM\ManyToMany(targetEntity="Affiliate", mappedBy="categories")
*/
private $affiliates;
}

src/AppBundle/Entity/Job.php:

namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="job")
*/
class Job
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\ManyToOne(targetEntity="Category", inversedBy="jobs")
* @ORM\JoinColumn(name="category_id", referencedColumnName="id")
*/
private $category;

/**
* @ORM\Column(type="string", length=255)
*/
private $type;

/**
* @ORM\Column(type="string", length=255)
*/
private $company;

/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $logo;

/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $url;

/**
* @ORM\Column(type="string", length=255)
*/
private $position;

/**
* @ORM\Column(type="string", length=255)
*/
private $location;

/**
* @ORM\Column(type="text")
*/
private $description;

/**
* @ORM\Column(type="text")
*/
private $howToApply;

/**
* @ORM\Column(type="string", length=255, unique=true)
*/
private $token;

/**
* @ORM\Column(type="boolean", nullable=true)
*/
private $isPublic;

/**
* @ORM\Column(type="boolean", nullable=true)
*/
private $isActivated;

/**
* @ORM\Column(type="string", length=255)
*/
private $email;

/**
* @ORM\Column(type="datetime")
*/
private $expiresAt;

/**
* @ORM\Column(type="datetime")
*/
private $createdAt;

/**
* @ORM\Column(type="datetime")
*/
private $updatedAt;
}

src/AppBundle/Entity/Affiliate.php:

namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
* @ORM\Table(name="affiliate")
* @ORM\HasLifecycleCallbacks()
*/
class Affiliate
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\ManyToMany(targetEntity="Category", inversedBy="affiliates")
* @ORM\JoinTable(name="affiliates_categories")
*/
private $categories;

/**
* @ORM\Column(type="string", length=255)
*/
private $url;

/**
* @ORM\Column(type="string", length=255)
*/
private $email;

/**
* @ORM\Column(type="string", length=255, unique=true)
*/
private $token;
    /**
* @ORM\Column(type="boolean", nullable=true)
*/
private $isActive;

/**
* @ORM\Column(type="datetime")
*/
private $createdAt;
}

After creating the entities we can validate the mappings with the following command:

php app/console doctrine:schema:validate

This is what you shoud get:

[Mapping]  OK - The mapping files are correct.
[Database] FAIL - The database schema is not in sync with the current mapping file.

Don’t worry about that error for now. We will fix it in a few minutes.

Generating Getters and Setters

Run the following command to generate getters and setters methods automatically:

php app/console doctrine:generate:entities AppBundle

This command makes sure that all the getters and setters are generated for each entity class in your AppBundle. This is a safe command — you can run it over and over again: it only generates getters and setters that don’t exist (i.e. it doesn’t replace your existing methods).

Lifecycle Callbacks

Sometimes, you need to perform an action right before or after an entity is inserted, updated, or deleted. These types of actions are known as “lifecycle” callbacks, as they’re callback methods that you need to execute during different stages of the lifecycle of an entity (e.g. the entity is inserted, updated, deleted, etc).

We already added the created_at and updated_at properties in our Job and Affiliate classes, and it will be great if Doctrine will update them automatically when needed.

To enable the lifecycle callbacks for an entity we need to add a new HasLifecycleCallbacks annotation to our class. We will also add methods to be called when specified by the PrePersist and PreUpdate annotations:

For Job:

/**
* @ORM\Entity()
* @ORM\Table(name="job")
* @ORM\HasLifecycleCallbacks()
*/
class Job
{
...
/**
* @ORM\PrePersist
*/
public function setCreatedAtValue()
{
if(!$this->getCreatedAt())
{
$this->createdAt = new \DateTime();
}
}

/**
* @ORM\PreUpdate
* @ORM\PrePersist
*/
public function setUpdatedAtValue()
{
$this->updatedAt = new \DateTime();
}
}

For Affiliate:

/**
* @ORM\Entity()
* @ORM\Table(name="affiliate")
* @ORM\HasLifecycleCallbacks()
*/
class Affiliate
{
...
/**
* @ORM\PrePersist
*/
public function setCreatedAtValue()
{
if(!$this->getCreatedAt())
{
$this->createdAt = new \DateTime();
}
}
}

Creating the Database Tables/Schema

Now we have usable entity classes with mapping information so Doctrine knows exactly how to persist it. Of course, we don’t yet have the corresponding product table in our database. Fortunately, Doctrine can automatically create all the database tables needed for every known entity in the application. To do this, run:

php app/console doctrine:schema:update --force

This task should only be really used during development. For a more robust method of systematically updating your production database, read about Doctrine migrations.

The Initial Data

The tables have been created in the database but there is no data in them. For any web application, there are three types of data: initial data (this is needed for the application to work, in our case we some initial categories and an admin user), test data (needed for the application to be tested) and user data (created by the users during the normal life of the application).

To populate the database with some initial data we will use DoctrineFixturesBundle. To setup this bundle we have to follow the next steps:

Step 1: Download the Bundle

composer require --dev doctrine/doctrine-fixtures-bundle

At this point it is possible that you will encounter an out of memory error. To fix it, add some swap with the following commands, then try the above command again:

sudo /bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=1024
sudo /sbin/mkswap /var/swap.1
sudo /sbin/swapon /var/swap.1

Step 2: Enable the Bundle

Ddd the following line in the app/AppKernel.php file to enable this bundle only for the dev and test environments:

// app/AppKernel.php
// ...
class AppKernel extends Kernel
{
public function registerBundles()
{
// ...
if (in_array($this->getEnvironment(), array('dev', 'test'))) {
$bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle();
}
return $bundles
}
// ...
}

Now that everything is set up we will create some new classes to load data in a new folder in our bundle: src/AppBundle/DataFixtures/ORM.

First we need some categories. Create the src/AppBundle/DataFixtures/ORM/LoadCategoryData.php file:

namespace AppBundle\DataFixtures\ORM;

use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use AppBundle\Entity\Category;

class LoadCategoryData extends AbstractFixture implements OrderedFixtureInterface
{
public function load(ObjectManager $em)
{
$design = new Category();
$design->setName('Design');

$programming = new Category();
$programming->setName('Programming');

$manager = new Category();
$manager->setName('Manager');

$administrator = new Category();
$administrator->setName('Administrator');

$em->persist($design);
$em->persist($programming);
$em->persist($manager);
$em->persist($administrator);

$em->flush();

$this->addReference('category-design', $design);
$this->addReference('category-programming', $programming);
$this->addReference('category-manager', $manager);
$this->addReference('category-administrator', $administrator);
}

public function getOrder()
{
return 1; // the order in which fixtures will be loaded
}
}

And now some jobs (src/AppBundle/DataFixtures/ORM/LoadJobData.php):

namespace AppBundle\DataFixtures\ORM;

use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use AppBundle\Entity\Job;

class LoadJobData extends AbstractFixture implements OrderedFixtureInterface
{
public function load(ObjectManager $em)
{
$job_sensio_labs = new Job();
$job_sensio_labs->setCategory($em->merge($this->getReference('category-programming')));
$job_sensio_labs->setType('full-time');
$job_sensio_labs->setCompany('Sensio Labs');
$job_sensio_labs->setLogo('sensio-labs.gif');
$job_sensio_labs->setUrl('http://www.sensiolabs.com/');
$job_sensio_labs->setPosition('Web Developer');
$job_sensio_labs->setLocation('Paris, France');
$job_sensio_labs->setDescription('You\'ve already developed websites with symfony and you want to work with Open-Source technologies. You have a minimum of 3 years experience in web development with PHP or Java and you wish to participate to development of Web 2.0 sites using the best frameworks available.');
$job_sensio_labs->setHowToApply('Send your resume to fabien.potencier [at] sensio.com');
$job_sensio_labs->setIsPublic(true);
$job_sensio_labs->setIsActivated(true);
$job_sensio_labs->setToken('job_sensio_labs');
$job_sensio_labs->setEmail('job@example.com');
$job_sensio_labs->setExpiresAt(new \DateTime('2017-10-10'));

$job_extreme_sensio = new Job();
$job_extreme_sensio->setCategory($em->merge($this->getReference('category-design')));
$job_extreme_sensio->setType('part-time');
$job_extreme_sensio->setCompany('Extreme Sensio');
$job_extreme_sensio->setLogo('extreme-sensio.gif');
$job_extreme_sensio->setUrl('http://www.extreme-sensio.com/');
$job_extreme_sensio->setPosition('Web Designer');
$job_extreme_sensio->setLocation('Paris, France');
$job_extreme_sensio->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in.');
$job_extreme_sensio->setHowToApply('Send your resume to fabien.potencier [at] sensio.com');
$job_extreme_sensio->setIsPublic(true);
$job_extreme_sensio->setIsActivated(true);
$job_extreme_sensio->setToken('job_extreme_sensio');
$job_extreme_sensio->setEmail('job@example.com');
$job_extreme_sensio->setExpiresAt(new \DateTime('2017-10-10'));

$em->persist($job_sensio_labs);
$em->persist($job_extreme_sensio);

$em->flush();
}

public function getOrder()
{
return 2; // the order in which fixtures will be loaded
}
}

Once your fixtures have been written, you can load them via the command line by using the following command:

php app/console doctrine:fixtures:load

Now check your database, you should see the data loaded into tables.

The job fixtures file references two images. You can download them from below and put them under the web/uploads/jobs/ directory:

Save this as sensio-labs.gif
Save this as extreme-sensio.gif

Validation

Validation is a very common task in web applications. Data entered in forms needs to be validated. Data also needs to be validated before it is written into a database or passed to a web service.

Symfony ships with a Validator component that makes this task easy and transparent. The goal of validation is to tell you if the data of an object is valid. For this, we will configure a list of rules (called constraints) that the object must follow in order to be valid. These rules can be specified via a number of different formats (YAML, XML, annotations, or PHP).

First you will need to add the following to your entities:

use Symfony\Component\Validator\Constraints as Assert;

Then edit each entity class and add the constraints using annotations (watch for the @Assert annotation).

Category.php

namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
* @ORM\Table(name="category")
*/
class Category
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $name;
    ...
}

Job.php

namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
* @ORM\Table(name="job")
* @ORM\HasLifecycleCallbacks()
*/
class Job
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\ManyToOne(targetEntity="Category", inversedBy="jobs")
* @ORM\JoinColumn(name="category_id", referencedColumnName="id")
* @Assert\NotBlank()
*/
private $category;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $type;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $company;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $logo;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $url;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $position;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $location;

/**
* @ORM\Column(type="text")
* @Assert\NotBlank()
*/
private $description;

/**
* @ORM\Column(type="text")
* @Assert\NotBlank()
*/
private $howToApply;

/**
* @ORM\Column(type="string", length=255, unique=true)
* @Assert\NotBlank()
*/
private $token;

/**
* @ORM\Column(type="boolean", nullable=true)
*/
private $isPublic;

/**
* @ORM\Column(type="boolean", nullable=true)
*/
private $isActivated;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $email;

/**
* @ORM\Column(type="datetime")
* @Assert\NotBlank()
*/
private $expiresAt;

/**
* @ORM\Column(type="datetime")
* @Assert\NotBlank()
*/
private $createdAt;

/**
* @ORM\Column(type="datetime")
* @Assert\NotBlank()
*/
private $updatedAt;
    ...
}

Affiliate.php

namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
* @ORM\Table(name="affiliate")
* @ORM\HasLifecycleCallbacks()
*/
class Affiliate
{
/**
* @ORM\Column(type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;

/**
* @ORM\ManyToMany(targetEntity="Category", inversedBy="affiliates")
* @ORM\JoinTable(name="affiliates_categories")
*/
private $categories;

/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $url;
    /**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank()
*/
private $email;

/**
* @ORM\Column(type="string", length=255, unique=true)
* @Assert\NotBlank()
*/
private $token;

/**
* @ORM\Column(type="datetime")
* @Assert\NotBlank()
*/
private $createdAt;

You can read more about Symfony validation and available constraints here: http://symfony.com/doc/2.8/validation.html

See it in Action in the Browser

Now let’s use some magic! Run at the command prompt:

php app/console generate:doctrine:crud --entity=AppBundle:Job --format=annotation --with-write --no-interaction

This will create a new controller src/AppBundle/Controllers/JobController.php with actions for listing, creating, editing and deleting jobs (and their corresponding templates, form and routes).

We will also need to add a __toString() method to our Category class to be used by the Category drop down from the edit job form:

public function __toString()
{
return $this->getName();
}

To view this in the browser visit the following URL: http://jobeet.local/app_dev.php/job/ (this is the development environment). To make it work in the production environment, you have first to clear the cache:

php app/console cache:clear --env=prod

You can now create and edit jobs. Try to leave a required field blank, or try to enter an invalid date to check the constraints you just configured.

Final Thoughts

That’s all. Today, we have barely written PHP code but we have a working web module for the job model, ready to be tweaked and customized. Remember, no PHP code also means no bugs!

You can find the code from day 3 here: https://github.com/dragosholban/jobeet-sf2.8/tree/day3.


About the Author

Passionate about web & mobile development. Doing this at IntelligentBee for the last 5 years. If you are looking for custom web and mobile app development, please contact us here and let’s have a talk.