Loïc Faugeron Technical Blog

Master Symfony2 - part 4: Doctrine 27/08/2014

Deprecated: This series has been re-written - see The Ultimate Developer Guide to Symfony

This is the fourth article of the series on mastering the Symfony2 framework. Have a look at the three first ones:

In the previous articles we created an API allowing us to submit and list quotes:

.
├── app
│   ├── AppKernel.php
│   ├── cache
│   │   └── .gitkeep
│   ├── config
│   │   ├── config_prod.yml
│   │   ├── config_test.yml
│   │   ├── config.yml
│   │   ├── parameters.yml
│   │   ├── parameters.yml.dist
│   │   └── routing.yml
│   ├── logs
│   │   └── .gitkeep
│   └── phpunit.xml.dist
├── composer.json
├── composer.lock
├── src
│   └── Fortune
│       └── ApplicationBundle
│           ├── Controller
│           │   └── QuoteController.php
│           ├── DependencyInjection
│           │   └── FortuneApplicationExtension.php
│           ├── Entity
│           │   ├── QuoteFactory.php
│           │   ├── QuoteGateway.php
│           │   ├── Quote.php
│           │   └── QuoteRepository.php
│           ├── FortuneApplicationBundle.php
│           ├── Resources
│           │   └── config
│           │       └── services.xml
│           └── Tests
│               ├── Controller
│               │   └── QuoteControllerTest.php
│               └── Entity
│                   └── QuoteRepositoryTest.php
└── web
    └── app.php

Here's the repository where you can find the actual code.

In this one we'll use real database persistence using Doctrine ORM, a third party bundle, the command line console and a mocking library.

Note: Symfony2 isn't coupled to any ORM or database library. We could use anything else like PDO, Propel ORM, POMM, or anything you want!

Installing DoctrineBundle

Just like Symfony, Doctrine is composed of many libraries which can be used separately. The two main ones are:

DoctrineBundle registers the library's services into our Dependency Injection Container. It can be installed quickly:

composer require 'doctrine/doctrine-bundle:~1.2'

The bundle doesn't force you to use the ORM (you can simply use the DBAL), so we need to explicitly install it:

composer require 'doctrine/orm:~2.2,>=2.2.3'

The bundle has to be registered in our application:

<?php
// File: app/AppKernel.php

use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Config\Loader\LoaderInterface;

class AppKernel extends Kernel
{
    public function registerBundles()
    {
        return array(
            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
            new Fortune\ApplicationBundle\FortuneApplicationBundle(),
            new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),
        );
    }

    public function registerContainerConfiguration(LoaderInterface $loader)
    {
        $loader->load(__DIR__.'/config/config_'.$this->getEnvironment().'.yml');
    }
}

Its services depend on some configuration parameters, which we will add:

# File: app/config/config.yml
imports:
    - { resource: parameters.yml }
    - { resource: doctrine.yml }

framework:
    secret: %secret%
    router:
        resource: %kernel.root_dir%/config/routing.yml

Next we create the actual configuration:

# File: app/config/doctrine.yml
doctrine:
    dbal:
        driver: pdo_mysql
        host: 127.0.0.1
        port: ~
        dbname: %database_name%
        user: %database_user%
        password: %database_password%
        charset: UTF8

    orm:
        auto_generate_proxy_classes: %kernel.debug%
        auto_mapping: true

Note: the ~ value is equivalent to null in PHP.

The values surrounded by % will be replaced by parameters coming from the DIC. For example, kernel.debug is set by the FrameworkBundle. We'll set the values of the database ones in the following file:

# File: app/config/parameters.yml
parameters:
    secret: hazuZRqYGdRrL8ATdB8kAqBZ

    database_name: fortune
    database_user: root
    database_password: ~

For security reason, this file is not commited. You can update the distributed file though, so your team will know that they need to set a value:

# File: app/config/parameters.yml.dist
parameters:
    secret: ChangeMePlease

    database_name: fortune
    database_user: root
    database_password: ~

Configuring the schema

The first thing we need is to define the schema (tables with their fields), so we'll create this directory:

mkdir src/Fortune/ApplicationBundle/Resources/config/doctrine

And then the configuration file for the Quote entity:

# src/Fortune/ApplicationBundle/Resources/config/doctrine/Quote.orm.yml
Fortune\ApplicationBundle\Entity\Quote:
    type: entity
    repositoryClass: Fortune\ApplicationBundle\Entity\QuoteGateway
    table: quote
    id:
        id:
            type: integer
            generator:
                strategy: AUTO
    fields:
        content:
            type: text
        createdAt:
            type: datetime
            column: created_at

Note: Doctrine uses the word "Repository" with a different meaning than the Repository design pattern (the one with gateway and factory). In our case it corresponds to the gateway.

As you can see, we've added a createdAt attribute to our entity. Let's update its code:

<?php
// File: src/Fortune/ApplicationBundle/Entity/Quote.php

namespace Fortune\ApplicationBundle\Entity;

class Quote
{
    private $id;
    private $content;
    private $createdAt;

    public function __construct($id, $content)
    {
        $this->id = $id;
        $this->content = $content;
        $this->createdAt = new \DateTime();
    }

    public static function fromContent($content)
    {
        return new Quote(null, $content);
    }

    public function getId()
    {
        return $this->id;
    }

    public function getContent()
    {
        return $this->content;
    }

    public function getCreatedAt()
    {
        return $this->createdAt;
    }
}

Note: We've added a named constructor which will prove usefull with the gateway.

Creating the console

Symfony2 provides a powerful Console Component allowing you to create command line utilities. It can be used standalone, or in the full stack framework thanks to the FrameworkBundle. To create the console, we just need to create the following file:

#!/usr/bin/env php
<?php
// File: app/console

set_time_limit(0);

require_once __DIR__.'/../vendor/autoload.php';
require_once __DIR__.'/AppKernel.php';

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;

$input = new ArgvInput();
$kernel = new AppKernel('dev', true);
$application = new Application($kernel);
$application->run($input);

The object ArgvInput contains the input given by the user (command name, arguments and options). Bundles can register commands in the application by fetching them from their Command directory.

We can now create the database and schema easily:

php app/console doctrine:database:create
php app/console doctrine:schema:create

Note: Those are useful when developing the application, but shouldn't be used in production.

Note: If you want to learn more about the Symfony2 Console Component, you can read this article.

Adapting the Gateway

Until now, our QuoteGateway was saving and retrieving the quotes from a file. We'll update it to be a Doctrine Repository:

<?php
// File: src/Fortune/ApplicationBundle/Entity/QuoteGateway.php

namespace Fortune\ApplicationBundle\Entity;

use Doctrine\ORM\EntityRepository;

class QuoteGateway extends EntityRepository
{
    public function insert($content)
    {
        $entityManager = $this->getEntityManager();

        $quote = Quote::fromContent($content);
        $entityManager->persist($quote);
        $entityManager->flush();

        return $quote;
    }
}

The EntityManager object does the actual persistence and will set the quote's ID. The EntityRepository already has a findAll method, so we can remove it.

The last thing we need is to update the DIC's configuration:

<?xml version="1.0" ?>
<!-- File: src/Fortune/ApplicationBundle/Resources/config/services.xml -->

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <service id="fortune_application.quote_factory"
            class="Fortune\ApplicationBundle\Entity\QuoteFactory"
        >
        </service>
        <service id="fortune_application.quote_gateway"
            class="Fortune\ApplicationBundle\Entity\QuoteGateway"
            factory-service="doctrine"
            factory-method="getRepository">
            <argument>FortuneApplicationBundle:Quote</argument>
        </service>
        <service id="fortune_application.quote_repository"
            class="Fortune\ApplicationBundle\Entity\QuoteRepository"
        >
            <argument type="service" id="fortune_application.quote_gateway" />
            <argument type="service" id="fortune_application.quote_factory" />
        </service>
    </services>
</container>

The doctrine service manages the Doctrine Repositories. To manually get a repository you'd need to do somethig like $container->get('doctrine')->getRepository('FortuneApplicationBundle:QuoteGateway'), the factory-service and factory-method attributes allow us to simply call container->get('fortune_application.quote_gateway')`.

Mocking the database

Database operations can be slow however we want our tests to run as fast as possible: this is a good opportunity to use a test double.

PHPUnit comes with its own mocking library, but we'll use a less verbose and more one: Prophecy. First we install the PHPUnit integration of Prophecy:

composer require --dev 'phpspec/prophecy-phpunit:~1.0'

Then we update our test:

<?php
// File: src/Fortune/ApplicationBundle/Tests/Entity/QuoteRepositoryTest.php

namespace Fortune\ApplicationBundle\Tests\Entity;

use Fortune\ApplicationBundle\Entity\Quote;
use Fortune\ApplicationBundle\Entity\QuoteFactory;
use Fortune\ApplicationBundle\Entity\QuoteGateway;
use Fortune\ApplicationBundle\Entity\QuoteRepository;
use Prophecy\PhpUnit\ProphecyTestCase;

class QuoteRepositoryTest extends ProphecyTestCase
{
    const ID = 42;
    const CONTENT = '<KnightOfNi> Ni!';

    private $gateway;
    private $repository;

    public function setUp()
    {
        parent::setUp();
        $gatewayClassname = 'Fortune\ApplicationBundle\Entity\QuoteGateway';
        $this->gateway = $this->prophesize($gatewayClassname);
        $factory = new QuoteFactory();
        $this->repository = new QuoteRepository($this->gateway->reveal(), $factory);
    }

    public function testItPersistsTheQuote()
    {
        $quote = new Quote(self::ID, self::CONTENT);
        $this->gateway->insert(self::CONTENT)->willReturn($quote);
        $this->repository->insert(self::CONTENT);

        $this->gateway->findAll()->willReturn(array($quote));
        $quotes = $this->repository->findAll();
        $foundQuote = $quotes['quotes'][self::ID];

        $this->assertSame(self::CONTENT, $foundQuote['content']);
    }
}

We created a mock of QuoteGateway which returns a quote we created beforehand.

Our changes are finished, let's run the tests:

./vendor/bin/phpunit -c app

No regression detected! We can commit our work:

git add -A
git ci -m 'Added doctrine'

Conclusion

Doctrine allows us to persist the data, its bundle integrates it smoothly into our application and provides us with handy command line tools.

You can have a look at Doctrine Migration, a standalone library allowing you to deploy database changes, it even has a bundle.

In the next article, we'll talk about how to extend the framework using events.