Todo-Backend example implementation with Symfony 4 and api-platform

Table of Contents

This repository contains a Todo-Backend example implementation made with Symfony 4 using the api-platform project.

1 Testing it live

You can see a working version at https://still-ravine-70063.herokuapp.com/.

It provides the following endpoints :

It can be tested with the Todo-Backend tools at http://www.todobackend.com/ :

2 Test locally

Clone the project's Git repo (see https://gitlab.com/olberger/todobackend-symfony4), and start it on port 8000, for instance :

php -S 127.0.0.1:8000 -t public

Then connect to http://localhost:8000/ or http://localhost:8000/todos in your browser. The app serves HTML if requested, which documents its use, giving example requests for use with cURL for instance. RTFM ;-)

You can then check the compatibility with the TodoBackend test suite :

2.1 Passing TodoBackend tests

You can test for todo-backend API compliance, by cloning the repository's code from https://github.com/TodoBackend/todo-backend-js-spec.

You may then test using :

cd todo-backend-js-spec
php -S localhost:8080

You can then connect to http://localhost:8080/?http://127.0.0.1:8000/api/todos in your browser.

3 Motivation

We've been devising the teaching materials for a class on Web apps development, taught in PHP with Symfony.

I thought it would be great to illustrate the course with some ToDo app which could be compared to other implementations, and found the TodoBackend project.

Instead of just doing some implementation for our colleagues and students, I thought I could as well do one that could be useful to others. Also, this took me hours to find the most suitable ways to make it work with Symfony 4 and api-pltform.

Note that there used to be https://github.com/oegnus/symfony2-todobackend for an older variant of Symfony, which I used for inspiration.

4 How it was built

Here are some very raw notes I took when implementing it.

At the time of writing, this works with Symfony 4.0.9.

4.1 Initialisation of the project

No need for full-fledged web app:

composer --no-ansi create-project symfony/skeleton todobackend-symfony4
Installing symfony/skeleton (v4.0.6)
  - Installing symfony/skeleton (v4.0.6): Loading from cache
Created project in todobackend-symfony4
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 21 installs, 0 updates, 0 removals
  - Installing symfony/flex (v1.0.80): Loading from cache
  - Installing symfony/polyfill-mbstring (v1.8.0): Loading from cache
  - Installing symfony/console (v4.0.9): Loading from cache
  - Installing symfony/routing (v4.0.9): Loading from cache
  - Installing symfony/http-foundation (v4.0.9): Loading from cache
  - Installing symfony/yaml (v4.0.9): Loading from cache
  - Installing symfony/framework-bundle (v4.0.9): Loading from cache
  - Installing symfony/http-kernel (v4.0.9): Loading from cache
  - Installing symfony/event-dispatcher (v4.0.9): Loading from cache
  - Installing psr/log (1.0.2): Loading from cache
  - Installing symfony/debug (v4.0.9): Loading from cache
  - Installing symfony/finder (v4.0.9): Loading from cache
  - Installing symfony/filesystem (v4.0.9): Loading from cache
  - Installing psr/container (1.0.0): Loading from cache
  - Installing symfony/dependency-injection (v4.0.9): Loading from cache
  - Installing symfony/config (v4.0.9): Loading from cache
  - Installing psr/simple-cache (1.0.1): Loading from cache
  - Installing psr/cache (1.0.1): Loading from cache
  - Installing symfony/cache (v4.0.9): Loading from cache
  - Installing symfony/dotenv (v4.0.9): Loading from cache
Writing lock file
Generating autoload files
Symfony operations: 4 recipes (4fa98d7dbee89e614151a1249a8999f2)
=1.0): From github.com/symfony/recipes:master
=3.3): From github.com/symfony/recipes:master
=3.3): From github.com/symfony/recipes:master
=4.0): From github.com/symfony/recipes:master
Executing script cache:clear [OK]
Executing script assets:install --symlink --relative public [OK]

Some files may have been created or updated to configure your new packages.
Please review, edit and commit them: these files are yours.

              
 What's next? 
              

  * Run your application:
    1. Change to the project directory
    2. Execute the php -S 127.0.0.1:8000 -t public command;
    3. Browse to the http://localhost:8000/ URL.

       Quit the server with CTRL-C.
       Run composer require server --dev for a better web server.

  * Read the documentation at https://symfony.com/doc

Note you may do the same withouth the –no-ansi, which is used here for documentation generation purposes.

The generated project is testable with:

cd todobackend-symfony4/
php -S 127.0.0.1:8000 -t public

Cf. http://127.0.0.1:8000 for the default Symfony page.

4.2 Adding the model of the Todo application with Doctrine

Cf. https://symfony.com/doc/current/doctrine.html for docs explaining the following.

  1. add the doctrine flex recipe:

    composer --no-ansi require doctrine
    
    Using version ^1.0 for symfony/orm-pack
    ./composer.json has been updated
    Loading composer repositories with package information
    Updating dependencies (including require-dev)
    Package operations: 20 installs, 0 updates, 0 removals
      - Installing ocramius/package-versions (1.3.0): Loading from cache
      - Installing zendframework/zend-eventmanager (3.2.1): Loading from cache
      - Installing zendframework/zend-code (3.3.0): Loading from cache
      - Installing ocramius/proxy-manager (2.2.0): Loading from cache
      - Installing doctrine/lexer (v1.0.1): Loading from cache
      - Installing doctrine/inflector (v1.3.0): Loading from cache
      - Installing doctrine/collections (v1.5.0): Loading from cache
      - Installing doctrine/cache (v1.7.1): Loading from cache
      - Installing doctrine/annotations (v1.6.0): Loading from cache
      - Installing doctrine/common (v2.8.1): Loading from cache
      - Installing doctrine/dbal (v2.7.1): Loading from cache
      - Installing doctrine/migrations (v1.7.2): Loading from cache
      - Installing symfony/doctrine-bridge (v4.0.9): Loading from cache
      - Installing doctrine/doctrine-cache-bundle (1.3.3): Loading from cache
      - Installing jdorn/sql-formatter (v1.2.17): Loading from cache
      - Installing doctrine/doctrine-bundle (1.9.1): Loading from cache
      - Installing doctrine/doctrine-migrations-bundle (v1.3.1): Loading from cache
      - Installing doctrine/instantiator (1.1.0): Loading from cache
      - Installing doctrine/orm (v2.6.1): Loading from cache
      - Installing symfony/orm-pack (v1.0.5): Loading from cache
    Writing lock file
    Generating autoload files
    Symfony operations: 4 recipes (21343349407f11ef7a645912700137a4)
    =1.0): From github.com/symfony/recipes:master
    =1.3.3): From auto-generated recipe
    =1.6): From github.com/symfony/recipes:master
    =1.2): From github.com/symfony/recipes:master
    ocramius/package-versions:  Generating version class...
    ocramius/package-versions: ...done generating version class
    Executing script cache:clear [OK]
    Executing script assets:install --symlink --relative public [OK]
    
    Some files may have been created or updated to configure your new packages.
    Please review, edit and commit them: these files are yours.
    
    
     Database Configuration 
    
    
      * Modify your DATABASE_URL config in .env
    
      * Configure the driver (mysql) and
        server_version (5.7) in config/packages/doctrine.yaml
    
  2. As advised, change the DATABASE_URL config in the .env file to use SQLite (see results in .env) :

    --- .env.dist	2018-05-04 15:43:28.616980415 +0200
    +++ .env	2018-05-04 16:06:03.082486709 +0200
    @@ -17,5 +17,5 @@
     # Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
     # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
     # Configure your db driver and server_version in config/packages/doctrine.yaml
    -DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name
    +DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db
     ###< doctrine/doctrine-bundle ###
    
  3. Then create the database :

    bin/console --no-ansi doctrine:database:create
    
    Created database /home/olivier/git/gitlab.com/olberger/tests-todobackends/todobackend-symfony4/var/data.db for connection named default
    
  4. Then use the maker tool to add a Todo entity with its properties to the application model:

    composer --no-ansi require maker --dev
    
    Using version ^1.4 for symfony/maker-bundle
    ./composer.json has been updated
    Loading composer repositories with package information
    Updating dependencies (including require-dev)
    Package operations: 2 installs, 0 updates, 0 removals
      - Installing nikic/php-parser (v4.0.1): Loading from cache
      - Installing symfony/maker-bundle (v1.4.4): Loading from cache
    Writing lock file
    Generating autoload files
    ocramius/package-versions:  Generating version class...
    ocramius/package-versions: ...done generating version class
    Symfony operations: 1 recipe (a7c0ed916d629e82dac70251050bc1f9)
    =1.0): From github.com/symfony/recipes:master
    Executing script cache:clear [OK]
    Executing script assets:install --symlink --relative public [OK]
    
    Some files may have been created or updated to configure your new packages.
    Please review, edit and commit them: these files are yours.
    
  5. Then use the maker helper:

    php bin/console make:entity
    
    Class name of the entity to create or update (e.g. FierceKangaroo):
    > Todo
    
    created: src/Entity/Todo.php
    created: src/Repository/TodoRepository.php
    
    Entity generated! Now let's add some fields!
    You can always add more fields later manually or by re-running this command.
    
    New property name (press <return> to stop adding fields):
    > title
    
    Field type (enter ? to see all types) [string]:
    > 
    
    Field length [255]:
    > 
    
    Can this field be null in the database (nullable) (yes/no) [no]:
    > 
    
    updated: src/Entity/Todo.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > completed
    
    Field type (enter ? to see all types) [string]:
    > boolean
    
    Can this field be null in the database (nullable) (yes/no) [no]:
    > 
    
    updated: src/Entity/Todo.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > order
    
    [ERROR] Name "order" is a reserved word.                                                          
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > todo_order
    
    Field type (enter ? to see all types) [string]:
    > integer
    
    Can this field be null in the database (nullable) (yes/no) [no]:
    > 
    
    updated: src/Entity/Todo.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > 
    
     Success! 
    
    Next: When you're ready, create a migration with make:migration
    

    Note that the order attribute isn't allowed by Doctrine, so we'll call it todo_order instead. Later, we'll adjust this so that the API recognizes it as order by changing getters and setters operations

    This generates a src/Entity/Todo.php file with the corresponding @ORM annotations

  6. Apply the migrations :
composer require migrations
bin/console make:migration
php bin/console doctrine:migrations:migrate

bin/console –no-ansi doctrine:schema:update –force

4.3 Add API Platform

  1. Install the api-platform (https://github.com/api-platform/api-platform)

    composer --no-ansi req api
    
Using version ^1.1 for api-platform/api-pack
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 21 installs, 0 updates, 0 removals
  - Installing symfony/translation (v4.0.9): Loading from cache
  - Installing symfony/validator (v4.0.9): Loading from cache
  - Installing twig/twig (v2.4.8): Loading from cache
  - Installing symfony/twig-bridge (v4.0.9): Loading from cache
  - Installing symfony/twig-bundle (v4.0.9): Loading from cache
  - Installing symfony/inflector (v4.0.9): Loading from cache
  - Installing symfony/property-access (v4.0.9): Loading from cache
  - Installing symfony/security (v4.0.9): Loading from cache
  - Installing symfony/security-bundle (v4.0.9): Loading from cache
  - Installing symfony/expression-language (v4.0.9): Loading from cache
  - Installing symfony/asset (v4.0.9): Loading from cache
  - Installing webmozart/assert (1.3.0): Loading from cache
  - Installing phpdocumentor/reflection-common (1.0.1): Loading from cache
  - Installing phpdocumentor/type-resolver (0.4.0): Loading from cache
  - Installing phpdocumentor/reflection-docblock (4.3.0): Loading from cache
  - Installing nelmio/cors-bundle (1.5.4): Loading from cache
  - Installing willdurand/negotiation (v2.3.1): Loading from cache
  - Installing symfony/serializer (v4.0.9): Loading from cache
  - Installing symfony/property-info (v4.0.9): Loading from cache
  - Installing api-platform/core (v2.2.5): Loading from cache
  - Installing api-platform/api-pack (1.1.0): Loading from cache
Writing lock file
Generating autoload files
ocramius/package-versions:  Generating version class...
ocramius/package-versions: ...done generating version class
Symfony operations: 5 recipes (4e65598197d35ac240a72e14b210a0df)
=3.3): From github.com/symfony/recipes:master
=3.3): From github.com/symfony/recipes:master
=3.3): From github.com/symfony/recipes:master
=1.5): From github.com/symfony/recipes:master
=2.1): From github.com/symfony/recipes:master
Executing script cache:clear [OK]
Executing script assets:install --symlink --relative public [OK]

Some files may have been created or updated to configure your new packages.
Please review, edit and commit them: these files are yours.
  1. Then declare the Todo entities as to be handled through the API:

    --- a/src/Entity/Todo.php
    +++ b/src/Entity/Todo.php
    @@ -4,8 +4,11 @@ namespace App\Entity;
    
     use Doctrine\ORM\Mapping as ORM;
    
    +use ApiPlatform\Core\Annotation\ApiResource;
    +
     /**
      * @ORM\Entity(repositoryClass="App\Repository\TodoRepository")
    + * @ApiResource
      */
     class Todo
     {
    

You may then test the API at: http://127.0.0.1:8000/api in your browser

Here's the JSON-LD produced by default on the empty database:

curl -s -X GET "http://127.0.0.1:8000/api/todos" -H  "accept: application/ld+json" | jq -M
{
  "@context": "/api/contexts/Todo",
  "@id": "/api/todos",
  "@type": "hydra:Collection",
  "hydra:member": [],
  "hydra:totalItems": 0
}

4.4 Tweaking compliance with the TodoBackend API

The goal will be to test that the test suite works, for instance with http://www.todobackend.com/specs/index.html?http://127.0.0.1:8000/api/todos

But that requires CORS support, so we'll test locally first

4.4.1 Installing the test suite and running it

See Passing TodoBackend tests for instructions on how to run the test suite locally.

4.4.2 Adding default value for completed attribute

We'll change the default values of the completed and todo_order attributes for newly created items:

private $completed = false;

private $todo_order = 0;

4.4.3 Adding DELETE on collection

The DELETE on the collection is added with a custom operation handler TodoDelete, which uses a method of the TodoRepository :

We'll add a custom operation handler for DELETE on collections, following advice at https://api-platform.com/docs/core/operations#recommended-method :

  1. Add a new method to the Todo Repository class which deletes all entries:

    class TodoRepository extends ServiceEntityRepository
    {
       // ...
       /**
         * Delete all instances of Todo
         * 
         * @return mixed|\Doctrine\DBAL\Driver\Statement|array|NULL
         */
        public function deleteAll()
        {
            $isDeleted = $this->createQueryBuilder("todo")
                ->delete()
                ->getQuery()->execute();
    
            return $isDeleted;
        }
    
    }
    
  2. <?php
    // src/Controller/TodoDelete.php
    
    namespace App\Controller;
    
    use App\Repository\TodoRepository;
    
    use App\Entity\Todo;
    
    class TodoDelete
    {
        /**
         * @var TodoRepository used for deleting all items
         */
        private $entityRepository;
    
        public function __construct(TodoRepository $entityRepository)
        {
            $this->entityRepository = $entityRepository;
        }
    
        public function __invoke()
        {
            if( $this->entityRepository->deleteAll() )
            {
                return array();
            }
        }
    }
    
  3. Add the custom DELETE configuration for the @ApiResource annotation of api-platform :

    /** @ApiResource(collectionOperations={
     *     "get",
     *     "post",
     *     "delete"={
     *         "method"="DELETE",
     *         "path"="/todos",
     *         "controller"="App\Controller\TodoDelete",
     *     }
     *  })
    ...
    

    Note that you need to explicitely declare the default methods GET and POST that are necessary too, in addition to the new DELETE method, as in the example above.

4.4.4 Changing default content-type produced to JSON

The default behaviour of the api-platform API, when the collection index is requested, is to respond with JSON-LD, like :

{
  "@context": "/api/contexts/Todo",
  "@id": "/api/todos",
  "@type": "hydra:Collection",
  "hydra:member": [],
  "hydra:totalItems": 0
}

This leads to a problem with the API test suite, reporting something like:

after a DELETE the api root responds to a GET with a JSON representation of an empty array

AssertionError: expected { Object (@context, @id, ...) } to deeply equal []

We'll then change the default content-type, by modifying the formats like the following in config/packages/api_platform.yaml so that JSON is the default:

api_platform:
  ...
  formats:
    json:     ['application/json']
    html:     ['text/html']
    jsonld:   ['application/ld+json']

4.4.5 Adding an url property for the Todo items

In the specs of the TodoBackend API, the JSON representation of Todo items should include a url attribute, which point to the URI of the resource, which is computed by the Router's URL generation method.

We'll then complement the normalization of the items to JSON by adding a custom ApiNormalizer in src/Serializer/ApiNormalizer, following the guidelines of https://api-platform.com/docs/core/content-negotiation#writing-a-custom-normalizer.

Note that we don't apply instructions at https://api-platform.com/docs/core/serialization/#decorating-a-serializer-and-add-extra-data since we care for JSON and not JSON-LD here.

We do so by modifying config/services.yaml to add :

services:
  ...
  'App\Serializer\ApiNormalizer':
      arguments:
        - '@api_platform.serializer.normalizer.item'
        - '@router'

/This injects the Router argument that will be needed to generate the route's URL.

And we'readding the corresponding class in src/Serializer/ApiNormalizer.php

<?php
// src/Serializer/ApiNormalizer
namespace App\Serializer;

use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;

final class ApiNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{

    private $decorated;

    /**
     * @var Router injected to generate the URL from the path
     */
    private $router;

    public function __construct(NormalizerInterface $decorated, Router $router)
    {
        if (! $decorated instanceof DenormalizerInterface) {
            throw new \InvalidArgumentException(sprintf('The decorated normalizer must implement the %s.', DenormalizerInterface::class));
        }

        $this->decorated = $decorated;

        $this->router = $router;
    }

    public function supportsNormalization($data, $format = null)
    {
        return $this->decorated->supportsNormalization($data, $format);
    }

    public function normalize($object, $format = null, array $context = [])
    {
        $data = $this->decorated->normalize($object, $format, $context);
        if (is_array($data)) {
            $url = $this->router->generate('api_todos_get_item', [
                'id' => $object->getId()
            ], UrlGeneratorInterface::ABSOLUTE_URL);
            $data['url'] = $url;
        }

        return $data;
    }

    public function supportsDenormalization($data, $type, $format = null)
    {
        return $this->decorated->supportsDenormalization($data, $type, $format);
    }

    public function denormalize($data, $class, $format = null, array $context = [])
    {
        return $this->decorated->denormalize($data, $class, $format, $context);
    }

    public function setSerializer(SerializerInterface $serializer)
    {
        if($this->decorated instanceof SerializerAwareInterface) {
            $this->decorated->setSerializer($serializer);
        }
    }
}

The normalize() method accesses the router to generate the URL corresponding to the GET on todo items (api_todos_get_item) generated by api-platform (bin/console debug:route), storing it in the url attribute.

4.4.6 Supporting PATCH request on Todo items

The PATCH method should be allowed on items, so we change the @ApiResource to declare a PATCH itemOperation, as follows (we must also explicitely declare GET and DELETE):

/**
 * ...
 * @ApiResource(
 *  ...
 *  itemOperations={
 *          "get",
 *          "patch"={
 *              "method"="PATCH"
 *              },
 *          "delete"
 *  })
 */
class Todo
{

4.4.7 Fixing the order naming

Final step is to fix the naming of the order attribute, instead of todo_order.

Well just rename the getTodoOrder() and setTodoOrder() methods of the Todo class to (resp.) getOrder() and setOrder().

That's all about it. You should be able to run http://www.todobackend.com/client/index.html?http://127.0.0.1:8000/api/todos now

5 Deploying on heroku

Here are a few notes about the hardest part maybe that was the tuning necessary for hosting that API on heroku. Feel free to suggest complements.

5.1 Support CORS

The test suite will now check that the CORS headers are supported. Test with : http://www.todobackend.com/specs/index.html?http://127.0.0.1:8000/api/todos

which should complain.

I added the nelmio/cors-bundle which is supposed to be well integrated with api-platform :

composer --no-ansi req cors
Using version ^1.5 for nelmio/cors-bundle
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Nothing to install or update
Writing lock file
Generating autoload files
ocramius/package-versions:  Generating version class...
ocramius/package-versions: ...done generating version class
Executing script cache:clear [OK]
Executing script assets:install --symlink --relative public [OK]

I then changed the config/packages/nelmio_cors.yaml in the following way which is a variant of https://github.com/nelmio/NelmioCorsBundle since defaults didn't work well:

nelmio_cors:
    defaults:
        allow_credentials: false
        allow_origin: []
        allow_headers: []
        allow_methods: []
        expose_headers: []
        max_age: 0
        hosts: []
        # Important since allow_origin contains '*'
        origin_regex: false
    paths:
        '^/':
            allow_origin: ['*']
            allow_headers: ['X-Custom-Auth', 'Origin', 'Content-Type', 'Accept']
            allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'PATCH', 'OPTIONS']
            max_age: 3600
            # This may not be needed ?
            forced_allow_origin_value: '*'

This made the CORS headers work locally with http://www.todobackend.com/specs/index.html?http://127.0.0.1:8000/api/todos, with the Symfony dev environment, using the php -S Web server.

5.2 Tuning Heroku for REST API

I had to tweak the Web server configuration (see Procfile and nginx_app.conf files) to make CORS work when deploying on heroku… probably far from perfect. It worked locally, but I guess the heroku reverse proxy messes around… or the PHP config… or nginx… anyway, it works now, with this custom config.

Here's a quick reminder of what I changed for deployment:

heroku config:set APP_ENV=prod
#heroku config:set "CORS_ALLOW_ORIGIN=*"
heroku config:set "DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db"

Author: Olivier Berger

Created: 2018-05-14 lun. 16:05

Validate