Loïc Faugeron Technical Blog

The Ultimate Developer Guide to Symfony - API Example 24/03/2016

Reference: This article is intended to be as complete as possible and is kept up to date.

TL;DR: Practice makes Better.

In this guide we've explored the main standalone libraries (also known as "Components") provided by Symfony to help us build applications:

We've also seen how HttpKernel enabled reusable code with Bundles, and the different ways to organize our application tree directory.

In this article, we're going to put all this knowledge in practice by creating a "fortune" project with an endpoint that allows us to submit new fortunes.

In the next articles we'll also create for this application:

Create the project

The first step is to create our project. For this example we'll use the Standard Edition:

composer create-project symfony/framework-standard-edition fortune

This will ask us some configuration questions (e.g. database credentials), allowing us to set up everything in one step.

Note: Nothing prevents us from adding new libraries (e.g. Assert), replacing the ones provided by default (e.g. replacing Doctrine with Pomm) or remove the ones we don't need (e.g. Swiftmailer if we don't need emailing).

To begin with a clean slate we'll need to remove some things:

cd fortune
echo '' >> app/config/routing.yml
rm -rf src/AppBundle/Controller/* tests/AppBundle/Controller/* app/Resources/views/*

Then we're going to install PHPUnit locally:

composer require --dev phpunit/phpunit:5.2 --ignore-platform-reqs

We're now ready to begin.

Create the Controller

We'll first start by writing a functional test for our new endpoint:

<?php
// tests/AppBundle/Controller/Api/FortuneControllerTest.php

namespace Tests\AppBundle\Controller\Api;

use Symfony\Component\HttpFoundation\Request;

class FortuneControllerTest extends \PHPUnit_Framework_TestCase
{
    private $app;

    protected function setUp()
    {
        $this->app = new \AppKernel('test', false);
    }

    /**
     * @test
     */
    public function it_cannot_submit_fortunes_without_content()
    {
        $headers = array(
            'CONTENT_TYPE' => 'application/json',
        );
        $request = Request::create('/api/v1/fortunes', 'POST', array(), array(), array(), $headers, json_encode(array(
        )));

        $response = $this->app->handle($request);

        self::assertSame(422, $response->getStatusCode(), $response->getContent());
    }

    /**
     * @test
     */
    public function it_cannot_submit_fortunes_with_non_string_content()
    {
        $headers = array(
            'CONTENT_TYPE' => 'application/json',
        );
        $request = Request::create('/api/v1/fortunes', 'POST', array(), array(), array(), $headers, json_encode(array(
            'content' => 42,
        )));

        $response = $this->app->handle($request);

        self::assertSame(422, $response->getStatusCode(), $response->getContent());
    }

    /**
     * @test
     */
    public function it_submits_new_fortunes()
    {
        $headers = array(
            'CONTENT_TYPE' => 'application/json',
        );
        $request = Request::create('/api/v1/fortunes', 'POST', array(), array(), array(), $headers, json_encode(array(
            'content' => 'Hello',
        )));

        $response = $this->app->handle($request);

        self::assertSame(201, $response->getStatusCode(), $response->getContent());
    }
}

With functional tests, we're only interested in making sure all components play well together, so checking the response status code (201 is succesfully created, 422 is a validation error) is sufficient.

Note: 400 BAD REQUEST is only used if there's a syntax error in the Request (e.g. invalid JSON).

Let's run the tests:

vendor/bin/phpunit

They fail, with a 404 NOT FOUND response. That's because we don't have any controllers, so let's fix that:

<?php
// src/AppBundle/Controller/Api/FortuneController.php

namespace AppBundle\Controller\Api;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class FortuneController
{
    public function submit(Request $request)
    {
        return new Response('', 201);
    }
}

Having a controller is no good without routing configuration:

# app/config/routing.yml

submit_new_fortunes_endpoint:
    path: /api/v1/fortunes
    defaults:
        _controller: app.api.fortune_controller:submit
    methods:
        - POST

In this configuration, _controller is set to call the submit method of the app.api.fortune_controller service. Here's how to define this service:

# app/config/services.yml

services:
    app.api.fortune_controller:
        class: 'AppBundle\Controller\Api\FortuneController'

Now let's try again our tests:

rm -rf var/cache/test
vendor/bin/phpunit

Note: We need to remove the cache to take into account the new configuration.

The last test (happy scenario) pass! We'll have to fix the first two ones (unhappy scenario) later.

We can now call directly our endpoint:

php -S localhost:2501 -t web &
curl -i -X POST localhost:2501/app.php/api/v1/fortunes -H 'Content-Type: application/json' -d '{"content":"Nobody expects the spanish inquisition!"}'
killall -9 php

We should successfully get a 201 CREATED.

Create the logic

So now we have an endpoint that does nothing. Let's fix it by creating the logic. Our first step will be to write a unit test for a class that will do a basic validation of the input:

<?php
// tests/AppBundle/Service/SubmitNewFortuneTest.php

namespace Tests\AppBundle\Service;

use AppBundle\Service\SubmitNewFortune;

class SubmitNewFortuneTest extends \PHPUnit_Framework_TestCase
{
    const CONTENT = "Look, matey, I know a dead parrot when I see one, and I'm looking at one right now.";

    /**
     * @test
     */
    public function it_has_a_content()
    {
        $submitNewFortune = new SubmitNewFortune(self::CONTENT);

        self::assertSame(self::CONTENT, $submitNewFortune->content);
    }

    /**
     * @test
     */
    public function it_fails_if_the_content_is_missing()
    {
        $this->expectException(\DomainException::class);

        new SubmitNewFortune(null);
    }

    /**
     * @test
     */
    public function it_fails_if_the_content_is_not_a_string()
    {
        $this->expectException(\DomainException::class);

        new SubmitNewFortune(42);
    }
}

Note: You need PHPUnit 5.2 to be able to use expectException.

Our SubmitNewFortune will check that we submitted a stringy content. Let's run the tests:

vendor/bin/phpunit

Note: If we had used phpspec to write our unit tests, it would have created an empty SubmitNewFortune class for us. There's nothing wrong with using both PHPUnit and phpspec, (the first for functional tests and the second for unit tests).

The tests fail because the actual class doesn't exist yet. We need to write it:

<?php
// src/AppBundle/Service/SubmitNewFortune.php

namespace AppBundle\Service;

class SubmitNewFortune
{
    public $content;

    public function __construct($content)
    {
        if (null === $content) {
            throw new \DomainException('Missing required "content" parameter', 422);
        }
        if (false === is_string($content)) {
            throw new \DomainException('Invalid "content" parameter: it must be a string', 422);
        }
        $this->content = $content;
    }
}

Let's run the tests again:

vendor/bin/phpunit

This time they pass.

Validating the input parameters isn't enough, we now need to execute some logic to actually submit new quotes. This can be done in a class that handles SubmitNewFortune:

<?php
// tests/AppBundle/Service/SubmitNewFortuneHandlerTest.php

namespace Tests\AppBundle\Service;

use AppBundle\Service\SaveNewFortune;
use AppBundle\Service\SubmitNewFortune;
use AppBundle\Service\SubmitNewFortuneHandler;

class SubmitNewFortuneHandlerTest extends \PHPUnit_Framework_TestCase
{
    const CONTENT = "It's just a flesh wound.";

    private $submitNewFortuneHandler;
    private $saveNewFortune;

    protected function setUp()
    {
        $this->saveNewFortune = $this->prophesize(SaveNewFortune::class);
        $this->submitNewFortuneHandler = new SubmitNewFortuneHandler(
            $this->saveNewFortune->reveal()
        );
    }

    /**
     * @test
     */
    public function it_submits_new_fortunes()
    {
        $submitNewFortune = new SubmitNewFortune(self::CONTENT);

        $this->saveNewFortune->save(array(
            'content' => self::CONTENT
        ))->shouldBeCalled();

        $this->submitNewFortuneHandler->handle($submitNewFortune);
    }
}

Let's run the tests:

vendor/bin/phpunit

They're telling us to create SubmitNewFortuneHandler:

<?php
// src/AppBundle/Service/SubmitNewFortuneHandler.php

namespace AppBundle\Service;

class SubmitNewFortuneHandler
{
    private $saveNewFortune;

    public function __construct(SaveNewFortune $saveNewFortune)
    {
        $this->saveNewFortune = $saveNewFortune;
    }

    public function handle(SubmitNewFortune $submitNewFortune)
    {
        $newFortune = array(
            'content' => $submitNewFortune->content,
        );

        $this->saveNewFortune->save($newFortune);
    }
}

This should fix this specific error:

vendor/bin/phpunit

Now our tests are telling us to create SaveNewFortune:

<?php
// src/AppBundle/Service/SaveNewFortune.php

namespace AppBundle\Service;

interface SaveNewFortune
{
    public function save(array $newFortune);
}

Let's see if it did the trick:

vendor/bin/phpunit

Yes it did! To sum up what we've done in this section:

Wiring

We're going to use Doctrine DBAL to actually save new fortunes in a database. This can be done by creating an implementation of SaveNewFortune:

<?php
// src/AppBundle/Service/Bridge/DoctrineDbalSaveNewFortune.php

namespace AppBundle\Service\Bridge;

use AppBundle\Service\SaveNewFortune;
use Doctrine\DBAL\Driver\Connection;

class DoctrineDbalSaveNewFortune implements SaveNewFortune
{
    private $connection;

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
    }

    public function save(array $newFortune)
    {
        $queryBuilder = $this->connection->createQueryBuilder();
        $queryBuilder->insert('fortune');
        $queryBuilder->setValue('content', '?');
        $queryBuilder->setParameter(0, $newFortune['content']);
        $sql = $queryBuilder->getSql();
        $parameters = $queryBuilder->getParameters();
        $statement = $this->connection->prepare($sql);
        $statement->execute($parameters);
    }
}

This was the last class we needed to write. We can now use SubmitNewFortune in our controller:

<?php
// src/AppBundle/Controller/Api/FortuneController.php

namespace AppBundle\Controller\Api;

use AppBundle\Service\SubmitNewFortune;
use AppBundle\Service\SubmitNewFortuneHandler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class FortuneController
{
    private $submitNewFortuneHandler;

    public function __construct(SubmitNewFortuneHandler $submitNewFortuneHandler)
    {
        $this->submitNewFortuneHandler = $submitNewFortuneHandler;
    }

    public function submit(Request $request)
    {
        $submitNewFortune = new SubmitNewFortune(
            $request->request->get('content')
        );
        $this->submitNewFortuneHandler->handle($submitNewFortune);

        return new Response('', 201);
    }
}

Note: In the controller, we extract Request (input) parameters and put them in SubmitNewFortune which is going to validate them. We then simply call SubmitNewFortuneHandler to take care of the logic associated to SubmitNewFortune.

Now all that's left to do is wire everything together using Dependency Injection:

# app/config/services.yml

services:
    app.api.fortune_controller:
        class: 'AppBundle\Controller\Api\FortuneController'
        arguments:
            - '@app.submit_new_fortune_handler'

    app.submit_new_fortune_handler:
        class: 'AppBundle\Service\SubmitNewFortuneHandler'
        arguments:
            - '@app.save_new_fortune'

    app.save_new_fortune:
        alias: app.bridge.doctrine_dbal_save_new_fortune

    app.bridge.doctrine_dbal_save_new_fortune:
        class: 'AppBundle\Service\Bridge\DoctrineDbalSaveNewFortune'
        arguments:
            - '@database_connection'

Let's run the tests:

rm -rf var/cache/test
vendor/bin/phpunit

They currently fail with 500 INTERNAL SERVER ERROR. To get an idea of what's going on, we need to have a look at our logs:

grep CRITICAL var/logs/test.log | tail -n 1 # Get the last line containing "CRITICAL", which is often cause by 500

This is what we got:

[2016-03-24 19:31:32] request.CRITICAL: Uncaught PHP Exception DomainException: "Missing required "content" parameter" at /home/foobar/fortune/src/AppBundle/Service/SubmitNewFortune.php line 13 {"exception":"[object] (DomainException(code: 422): Missing required \"content\" parameter at /home/foobar/fortune/src/AppBundle/Service/SubmitNewFortune.php:13)"} []

It looks like we don't get any data in the request attribute from Request. That's because PHP doesn't populate $_POST when we send JSON data. We can fix it by creating an EventListener that will prepare the Request for us:

<?php
// src/AppBundle/EventListener/JsonRequestContentListener.php

namespace AppBundle\EventListener;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

/**
 * PHP does not populate $_POST with the data submitted via a JSON Request,
 * causing an empty $request->request.
 *
 * This listener fixes this.
 */
class JsonRequestContentListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();
        $hasBeenSubmited = in_array($request->getMethod(), array('PATCH', 'POST', 'PUT'), true);
        $isJson = (1 === preg_match('#application/json#', $request->headers->get('Content-Type')));
        if (!$hasBeenSubmited || !$isJson) {
            return;
        }
        $data = json_decode($request->getContent(), true);
        if (JSON_ERROR_NONE !== json_last_error()) {
            $event->setResponse(new Response('{"error":"Invalid or malformed JSON"}', 400, array('Content-Type' => 'application/json')));
        }
        $request->request->add($data ?: array());
    }
}

Our listener needs to be registered in the Dependency Injection Container:

# app/config/services.yml

services:
    app.api.fortune_controller:
        class: 'AppBundle\Controller\Api\FortuneController'
        arguments:
            - '@app.submit_new_fortune_handler'

    app.submit_new_fortune_handler:
        class: 'AppBundle\Service\SubmitNewFortuneHandler'
        arguments:
            - '@app.save_new_fortune'

    app.save_new_fortune:
        alias: app.bridge.doctrine_dbal_save_new_fortune

    app.bridge.doctrine_dbal_save_new_fortune:
        class: 'AppBundle\Service\Bridge\DoctrineDbalSaveNewFortune'
        arguments:
            - '@database_connection'

    app.json_request_content_listener:
        class: 'AppBundle\EventListener\JsonRequestContentListener'
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

This should fix our error:

rm -rf var/cache/test
vendor/bin/phpunit
grep CRITICAL var/logs/test.log | tail -n 1

We still get a 500, but this time for the following reason:

[2016-03-24 19:36:09] request.CRITICAL: Uncaught PHP Exception Doctrine\DBAL\Exception\ConnectionException: "An exception occured in driver: SQLSTATE[08006] [7] FATAL:  database "fortune" does not exist" at /home/foobar/fortune/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractPostgreSQLDriver.php line 85 {"exception":"[object] (Doctrine\\DBAL\\Exception\\ConnectionException(code: 0): An exception occured in driver: SQLSTATE[08006] [7] FATAL:  database \"fortune\" does not exist at /home/foobar/fortune/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractPostgreSQLDriver.php:85, Doctrine\\DBAL\\Driver\\PDOException(code: 7): SQLSTATE[08006] [7] FATAL:  database \"fortune\" does not exist at /home/foobar/fortune/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOConnection.php:47, PDOException(code: 7): SQLSTATE[08006] [7] FATAL:  database \"fortune\" does not exist at /home/foobar/fortune/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOConnection.php:43)"} []

The database doesn't exist. It can be created with the following command, provided by Doctrine:

bin/console doctrine:database:create

Let's take this opportunity to also create the table:

bin/console doctrine:query:sql 'CREATE TABLE fortune (content TEXT);'

Let's re-run the tests:

vendor/bin/phpunit

Hooray! We can now submit new fortunes by calling our endpoint:

rm -rf var/cache/prod
php -S localhost:2501 -t web &
curl -i -X POST localhost:2501/app.php/api/v1/fortunes -H 'Content-Type: application/json' -d '{"content":"What... is the air-speed velocity of an unladen swallow?"}'
killall -9 php

We can see our fortunes in the database:

bin/console doctrine:query:sql 'SELECT * FROM fortune;'

We still have two failing tests though. That's because we don't catch our DomainExceptions. This can be fixed in an EventListener:

<?php
// src/AppBundle/EventListener/ExceptionListener.php

namespace AppBundle\EventListener;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;

class ExceptionListener
{
    public function onKernelException(GetResponseForExceptionEvent $event)
    {
        $exception = $event->getException();
        if (!$exception instanceof \DomainException) {
            return;
        }
        $event->setResponse(new Response(json_encode(array(
            'error' => $exception->getMessage(),
        )), $exception->getCode(), array('Content-Type' => 'application/json')));
    }
}

It then needs to be registered as a service:

# app/config/services.yml

services:
    app.api.fortune_controller:
        class: 'AppBundle\Controller\Api\FortuneController'
        arguments:
            - '@app.submit_new_fortune_handler'

    app.submit_new_fortune_handler:
        class: 'AppBundle\Service\SubmitNewFortuneHandler'
        arguments:
            - '@app.save_new_fortune'

    app.save_new_fortune:
        alias: app.bridge.doctrine_dbal_save_new_fortune

    app.bridge.doctrine_dbal_save_new_fortune:
        class: 'AppBundle\Service\Bridge\DoctrineDbalSaveNewFortune'
        arguments:
            - '@database_connection'

    app.json_request_content_listener:
        class: 'AppBundle\EventListener\JsonRequestContentListener'
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

    app.exception_listener:
        class: 'AppBundle\EventListener\ExceptionListener'
        tags:
            - { name: kernel.event_listener, event: kernel.exception, method: onKernelException }

Finally we run the tests:

rm -rf var/cache/test
vendor/bin/phpunit

All green!

Conclusion

To create a new endpoint, we need to:

We might need to create some event listeners (to populate $request->request when receiving JSON content, or to convert exceptions to responses).

The endpoint's logic is then up to us, it doesn't have to be done in a "Symfony" way. For example we can:

You can find the code on Github: Fortune - API example