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

Table of Contents

This repository contains a Todo-Backend example implementation made with Symfony 6 (LTS) using the api-platform project.

The current documentation is best viewed in https://todobackend-symfony6-c029e2.gitlab.io/README.html.

1. Test locally

Clone the project's Git repo (see https://gitlab.com/olberger/todobackend-symfony6), 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 :

1.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.

2. 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 6 and api-platform.

3. History

A previous version developped for Symfony 4 may be seen at https://gitlab.com/olberger/todobackend-symfony4.

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.

4.1. Initialisation of the project

No need for full-fledged web app:

composer --no-ansi create-project symfony/skeleton:"6.4.*" todobackend-symfony6
Creating a "symfony/skeleton:6.4.*" project at "./todobackend-symfony6"
Installing symfony/skeleton (v6.4.99)
  - Installing symfony/skeleton (v6.4.99): Extracting archive
Created project in /home/olivier/git/gitlab.com/olberger/tests-todobackends/todobackend-symfony6
Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 0 updates, 0 removals
  - Locking symfony/flex (v2.8.1)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 0 updates, 0 removals
  - Installing symfony/flex (v2.8.1): Extracting archive
Generating autoload files
1 package you are using is looking for funding.
Use the `composer fund` command to find out more!

Run composer recipes at any time to see the status of your Symfony recipes.

Loading composer repositories with package information
Restricting packages listed in "symfony/symfony" to "6.4.*"
Updating dependencies
Lock file operations: 30 installs, 0 updates, 0 removals
  - Locking psr/cache (3.0.0)
  - Locking psr/container (2.0.2)
  - Locking psr/event-dispatcher (1.0.0)
  - Locking psr/log (3.0.2)
  - Locking symfony/cache (v6.4.24)
  - Locking symfony/cache-contracts (v3.6.0)
  - Locking symfony/config (v6.4.24)
  - Locking symfony/console (v6.4.24)
  - Locking symfony/dependency-injection (v6.4.24)
  - Locking symfony/deprecation-contracts (v3.6.0)
  - Locking symfony/dotenv (v6.4.24)
  - Locking symfony/error-handler (v6.4.24)
  - Locking symfony/event-dispatcher (v6.4.24)
  - Locking symfony/event-dispatcher-contracts (v3.6.0)
  - Locking symfony/filesystem (v6.4.24)
  - Locking symfony/finder (v6.4.24)
  - Locking symfony/framework-bundle (v6.4.24)
  - Locking symfony/http-foundation (v6.4.24)
  - Locking symfony/http-kernel (v6.4.24)
  - Locking symfony/polyfill-intl-grapheme (v1.32.0)
  - Locking symfony/polyfill-intl-normalizer (v1.32.0)
  - Locking symfony/polyfill-mbstring (v1.32.0)
  - Locking symfony/polyfill-php83 (v1.32.0)
  - Locking symfony/routing (v6.4.24)
  - Locking symfony/runtime (v6.4.24)
  - Locking symfony/service-contracts (v3.6.0)
  - Locking symfony/string (v6.4.24)
  - Locking symfony/var-dumper (v6.4.24)
  - Locking symfony/var-exporter (v6.4.24)
  - Locking symfony/yaml (v6.4.24)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 30 installs, 0 updates, 0 removals
  - Installing symfony/runtime (v6.4.24): Extracting archive
  - Installing psr/cache (3.0.0): Extracting archive
  - Installing symfony/cache-contracts (v3.6.0): Extracting archive
  - Installing symfony/polyfill-mbstring (v1.32.0): Extracting archive
  - Installing symfony/polyfill-intl-normalizer (v1.32.0): Extracting archive
  - Installing symfony/polyfill-intl-grapheme (v1.32.0): Extracting archive
  - Installing symfony/string (v6.4.24): Extracting archive
  - Installing symfony/deprecation-contracts (v3.6.0): Extracting archive
  - Installing psr/container (2.0.2): Extracting archive
  - Installing symfony/service-contracts (v3.6.0): Extracting archive
  - Installing symfony/console (v6.4.24): Extracting archive
  - Installing symfony/dotenv (v6.4.24): Extracting archive
  - Installing psr/event-dispatcher (1.0.0): Extracting archive
  - Installing symfony/event-dispatcher-contracts (v3.6.0): Extracting archive
  - Installing symfony/routing (v6.4.24): Extracting archive
  - Installing symfony/polyfill-php83 (v1.32.0): Extracting archive
  - Installing symfony/http-foundation (v6.4.24): Extracting archive
  - Installing symfony/event-dispatcher (v6.4.24): Extracting archive
  - Installing symfony/var-dumper (v6.4.24): Extracting archive
  - Installing psr/log (3.0.2): Extracting archive
  - Installing symfony/error-handler (v6.4.24): Extracting archive
  - Installing symfony/http-kernel (v6.4.24): Extracting archive
  - Installing symfony/finder (v6.4.24): Extracting archive
  - Installing symfony/filesystem (v6.4.24): Extracting archive
  - Installing symfony/var-exporter (v6.4.24): Extracting archive
  - Installing symfony/dependency-injection (v6.4.24): Extracting archive
  - Installing symfony/config (v6.4.24): Extracting archive
  - Installing symfony/cache (v6.4.24): Extracting archive
  - Installing symfony/framework-bundle (v6.4.24): Extracting archive
  - Installing symfony/yaml (v6.4.24): Extracting archive
Generating autoload files
27 packages you are using are looking for funding.
Use the `composer fund` command to find out more!

Symfony operations: 4 recipes (4b97d49db602544762d53fd416ec9767)
  - Configuring symfony/flex (>=2.4): From github.com/symfony/recipes:main
  - Configuring symfony/framework-bundle (>=6.4): From github.com/symfony/recipes:main
  - Configuring symfony/console (>=5.3): From github.com/symfony/recipes:main
  - Configuring symfony/routing (>=6.2): From github.com/symfony/recipes:main
Executing script cache:clear [OK]
Executing script assets:install public [OK]
              
 What's next? 
              

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

 symfony/framework-bundle  instructions:

  * Run your application:
    1. Go to the project directory
    2. Create your code repository with the git init command
    3. Download the Symfony CLI at https://symfony.com/download to install a development web server

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

No security vulnerability advisories found.

No security vulnerability advisories found.

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-symfony6/
php -S localhost:8000 -t public

Cf. http://localhost: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
    
    ./composer.json has been updated
    Running composer update symfony/orm-pack
    Loading composer repositories with package information
    Restricting packages listed in "symfony/symfony" to "6.4.*"
    Updating dependencies
    Lock file operations: 17 installs, 0 updates, 0 removals
      - Locking doctrine/collections (2.3.0)
      - Locking doctrine/dbal (3.10.1)
      - Locking doctrine/deprecations (1.1.5)
      - Locking doctrine/doctrine-bundle (2.15.1)
      - Locking doctrine/doctrine-migrations-bundle (3.4.2)
      - Locking doctrine/event-manager (2.0.1)
      - Locking doctrine/inflector (2.1.0)
      - Locking doctrine/instantiator (2.0.0)
      - Locking doctrine/lexer (3.0.1)
      - Locking doctrine/migrations (3.9.3)
      - Locking doctrine/orm (3.5.2)
      - Locking doctrine/persistence (4.0.0)
      - Locking doctrine/sql-formatter (1.5.2)
      - Locking symfony/doctrine-bridge (v6.4.24)
      - Locking symfony/orm-pack (v2.4.1)
      - Locking symfony/polyfill-php84 (v1.32.0)
      - Locking symfony/stopwatch (v6.4.24)
    Writing lock file
    Installing dependencies from lock file (including require-dev)
    Package operations: 17 installs, 0 updates, 0 removals
      - Downloading doctrine/migrations (3.9.3)
      - Installing symfony/polyfill-php84 (v1.32.0): Extracting archive
      - Installing doctrine/deprecations (1.1.5): Extracting archive
      - Installing doctrine/collections (2.3.0): Extracting archive
      - Installing doctrine/inflector (2.1.0): Extracting archive
      - Installing doctrine/instantiator (2.0.0): Extracting archive
      - Installing doctrine/lexer (3.0.1): Extracting archive
      - Installing symfony/stopwatch (v6.4.24): Extracting archive
      - Installing doctrine/event-manager (2.0.1): Extracting archive
      - Installing doctrine/dbal (3.10.1): Extracting archive
      - Installing doctrine/migrations (3.9.3): Extracting archive
      - Installing doctrine/sql-formatter (1.5.2): Extracting archive
      - Installing doctrine/persistence (4.0.0): Extracting archive
      - Installing symfony/doctrine-bridge (v6.4.24): Extracting archive
      - Installing doctrine/orm (3.5.2): Extracting archive
      - Installing doctrine/doctrine-bundle (2.15.1): Extracting archive
      - Installing doctrine/doctrine-migrations-bundle (3.4.2): Extracting archive
      - Installing symfony/orm-pack (v2.4.1)
    Generating autoload files
    41 packages you are using are looking for funding.
    Use the `composer fund` command to find out more!
    
    Symfony operations: 3 recipes (6badd6a9863edf36749f3c768fcc2d86)
      - Configuring doctrine/deprecations (>=1.0): From github.com/symfony/recipes:main
      - Configuring doctrine/doctrine-bundle (>=2.10): From github.com/symfony/recipes:main
      -  WARNING  doctrine/doctrine-bundle (>=2.10): From github.com/symfony/recipes:main
        The recipe for this package contains some Docker configuration.
    
        This may create/update compose.yaml or update Dockerfile (if it exists).
    
        Do you want to include Docker configuration from recipes?
        [y] Yes
        [n] No
        [p] Yes permanently, never ask again for this project
        [x] No permanently, never ask again for this project
        (defaults to y): 
      - Configuring doctrine/doctrine-migrations-bundle (>=3.1): From github.com/symfony/recipes:main
      - Unpacked symfony/orm-pack
    Executing script cache:clear [OK]
    Executing script assets:install public [OK]
                  
     What's next? 
                  
    
    Some files have been created and/or updated to configure your new packages.
    Please review, edit and commit them: these files are yours.
    
     doctrine/doctrine-bundle  instructions:
    
      * Modify your DATABASE_URL config in .env
    
      * Configure the driver (postgresql) and
        server_version (16) in config/packages/doctrine.yaml
    
    No security vulnerability advisories found.
    
  2. As advised, change the DATABASE_URL config in the .env file to use SQLite (see results in .env) :

    --- ../tests-todobackends/todobackend-symfony6/.env	2025-08-14 16:41:04.023566646 +0200
    +++ .env	2025-08-13 15:02:37.254222718 +0200
    @@ -26,5 +26,6 @@ APP_SECRET=6516abcf9ebc16f08291cea3e806e200
     # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
     # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
     # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
    -DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
    +#DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
    +DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.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-symfony6/var/data_dev.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
    
    ./composer.json has been updated
    Running composer update symfony/maker-bundle
    Loading composer repositories with package information
    Restricting packages listed in "symfony/symfony" to "6.4.*"
    Updating dependencies
    Lock file operations: 3 installs, 0 updates, 0 removals
      - Locking nikic/php-parser (v5.6.1)
      - Locking symfony/maker-bundle (v1.64.0)
      - Locking symfony/process (v6.4.24)
    Writing lock file
    Installing dependencies from lock file (including require-dev)
    Package operations: 3 installs, 0 updates, 0 removals
      - Downloading nikic/php-parser (v5.6.1)
      - Installing symfony/process (v6.4.24): Extracting archive
      - Installing nikic/php-parser (v5.6.1): Extracting archive
      - Installing symfony/maker-bundle (v1.64.0): Extracting archive
    Generating autoload files
    42 packages you are using are looking for funding.
    Use the `composer fund` command to find out more!
    
    Symfony operations: 1 recipe (48a97bb175ee23b2d0355875a7a6af42)
      - Configuring symfony/maker-bundle (>=1.0): From github.com/symfony/recipes:main
    Executing script cache:clear [OK]
    Executing script assets:install public [OK]
                  
     What's next? 
                  
    
    Some files have been created and/or updated to configure your new packages.
    Please review, edit and commit them: these files are yours.
    
    No security vulnerability advisories found.
    Using version ^1.64 for symfony/maker-bundle
    
  5. Then use the maker helper to create the model entity (Todo). Pay attention to the renaming needed for the order attribute which is a reserved keyword:

    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

  1. Create the database tables:

    bin/console --no-ansi doctrine:schema:create
    

4.3. Add API Platform

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

    composer --no-ansi req api
    
    ./composer.json has been updated
    Running composer update api-platform/api-pack
    Loading composer repositories with package information
    Restricting packages listed in "symfony/symfony" to "6.4.*"
    Updating dependencies
    Lock file operations: 46 installs, 0 updates, 0 removals
      - Locking api-platform/api-pack (v1.4.0)
      - Locking api-platform/doctrine-common (v4.1.20)
      - Locking api-platform/doctrine-orm (v4.1.20)
      - Locking api-platform/documentation (v4.1.20)
      - Locking api-platform/http-cache (v4.1.20)
      - Locking api-platform/hydra (v4.1.20)
      - Locking api-platform/json-schema (v4.1.20)
      - Locking api-platform/jsonld (v4.1.20)
      - Locking api-platform/metadata (v4.1.20)
      - Locking api-platform/openapi (v4.1.20)
      - Locking api-platform/serializer (v4.1.20)
      - Locking api-platform/state (v4.1.20)
      - Locking api-platform/symfony (v4.1.20)
      - Locking api-platform/validator (v4.1.20)
      - Locking doctrine/common (3.5.0)
      - Locking nelmio/cors-bundle (2.5.0)
      - Locking phpdocumentor/reflection-common (2.2.0)
      - Locking phpdocumentor/reflection-docblock (5.6.2)
      - Locking phpdocumentor/type-resolver (1.10.0)
      - Locking phpstan/phpdoc-parser (2.2.0)
      - Locking psr/clock (1.0.0)
      - Locking psr/link (2.0.1)
      - Locking symfony/asset (v6.4.24)
      - Locking symfony/clock (v6.4.24)
      - Locking symfony/expression-language (v6.4.24)
      - Locking symfony/orm-pack (v2.4.1)
      - Locking symfony/password-hasher (v6.4.24)
      - Locking symfony/polyfill-uuid (v1.32.0)
      - Locking symfony/property-access (v6.4.24)
      - Locking symfony/property-info (v6.4.24)
      - Locking symfony/security-bundle (v6.4.24)
      - Locking symfony/security-core (v6.4.24)
      - Locking symfony/security-csrf (v6.4.24)
      - Locking symfony/security-http (v6.4.24)
      - Locking symfony/serializer (v6.4.24)
      - Locking symfony/serializer-pack (v1.3.0)
      - Locking symfony/translation-contracts (v3.6.0)
      - Locking symfony/twig-bridge (v6.4.24)
      - Locking symfony/twig-bundle (v6.4.24)
      - Locking symfony/type-info (v7.3.2)
      - Locking symfony/uid (v6.4.24)
      - Locking symfony/validator (v6.4.24)
      - Locking symfony/web-link (v6.4.24)
      - Locking twig/twig (v3.21.1)
      - Locking webmozart/assert (1.11.0)
      - Locking willdurand/negotiation (3.1.0)
    Writing lock file
    Installing dependencies from lock file (including require-dev)
    Package operations: 46 installs, 0 updates, 0 removals
      - Installing symfony/translation-contracts (v3.6.0): Extracting archive
      - Installing symfony/validator (v6.4.24): Extracting archive
      - Installing twig/twig (v3.21.1): Extracting archive
      - Installing symfony/twig-bridge (v6.4.24): Extracting archive
      - Installing symfony/twig-bundle (v6.4.24): Extracting archive
      - Installing symfony/serializer (v6.4.24): Extracting archive
      - Installing symfony/property-info (v6.4.24): Extracting archive
      - Installing symfony/property-access (v6.4.24): Extracting archive
      - Installing phpstan/phpdoc-parser (2.2.0): Extracting archive
      - Installing webmozart/assert (1.11.0): Extracting archive
      - Installing phpdocumentor/reflection-common (2.2.0): Extracting archive
      - Installing phpdocumentor/type-resolver (1.10.0): Extracting archive
      - Installing phpdocumentor/reflection-docblock (5.6.2): Extracting archive
      - Installing symfony/serializer-pack (v1.3.0)
      - Installing symfony/password-hasher (v6.4.24): Extracting archive
      - Installing symfony/security-core (v6.4.24): Extracting archive
      - Installing symfony/security-http (v6.4.24): Extracting archive
      - Installing symfony/security-csrf (v6.4.24): Extracting archive
      - Installing psr/clock (1.0.0): Extracting archive
      - Installing symfony/clock (v6.4.24): Extracting archive
      - Installing symfony/security-bundle (v6.4.24): Extracting archive
      - Installing symfony/orm-pack (v2.4.1)
      - Installing symfony/expression-language (v6.4.24): Extracting archive
      - Installing symfony/asset (v6.4.24): Extracting archive
      - Installing nelmio/cors-bundle (2.5.0): Extracting archive
      - Installing willdurand/negotiation (3.1.0): Extracting archive
      - Installing psr/link (2.0.1): Extracting archive
      - Installing symfony/web-link (v6.4.24): Extracting archive
      - Installing symfony/type-info (v7.3.2): Extracting archive
      - Installing api-platform/metadata (v4.1.20): Extracting archive
      - Installing api-platform/validator (v4.1.20): Extracting archive
      - Installing api-platform/state (v4.1.20): Extracting archive
      - Installing api-platform/serializer (v4.1.20): Extracting archive
      - Installing symfony/polyfill-uuid (v1.32.0): Extracting archive
      - Installing symfony/uid (v6.4.24): Extracting archive
      - Installing api-platform/json-schema (v4.1.20): Extracting archive
      - Installing api-platform/openapi (v4.1.20): Extracting archive
      - Installing api-platform/jsonld (v4.1.20): Extracting archive
      - Installing api-platform/documentation (v4.1.20): Extracting archive
      - Installing api-platform/hydra (v4.1.20): Extracting archive
      - Installing api-platform/http-cache (v4.1.20): Extracting archive
      - Installing api-platform/symfony (v4.1.20): Extracting archive
      - Installing doctrine/common (3.5.0): Extracting archive
      - Installing api-platform/doctrine-common (v4.1.20): Extracting archive
      - Installing api-platform/doctrine-orm (v4.1.20): Extracting archive
      - Installing api-platform/api-pack (v1.4.0)
    Generating autoload files
    65 packages you are using are looking for funding.
    Use the `composer fund` command to find out more!
    
    Symfony operations: 6 recipes (b1b32769d05247584e33158a380da54d)
      - Configuring symfony/validator (>=5.3): From github.com/symfony/recipes:main
      - Configuring symfony/twig-bundle (>=6.4): From github.com/symfony/recipes:main
      - Configuring symfony/security-bundle (>=6.4): From github.com/symfony/recipes:main
      - Configuring nelmio/cors-bundle (>=1.5): From github.com/symfony/recipes:main
      - Configuring symfony/uid (>=6.2): From github.com/symfony/recipes:main
      - Configuring api-platform/symfony (>=4.0): From github.com/symfony/recipes:main
      - Unpacked api-platform/api-pack
      - Unpacked symfony/orm-pack
      - Unpacked symfony/serializer-pack
    Executing script cache:clear [OK]
    Executing script assets:install public [OK]
    
     What's next? 
    
    
    Some files have been created and/or updated to configure your new packages.
    Please review, edit and commit them: these files are yours.
    
     api-platform/symfony  instructions:
    
      * Your API is almost ready:
        1. Create your first API resource in src/ApiResource;
        2. Go to /api to browse your API
    
      * Using MakerBundle? Try php bin/console make:entity --api-resource 
    
      * To enable the GraphQL support, run composer require api-platform/graphql,
        then browse /api/graphql.
    
      * Read the documentation at https://api-platform.com/docs/
    
    No security vulnerability advisories found.
    
  2. Then declare the Todo entities as to be handled through the API:

    @@ -2,10 +2,12 @@
     
     namespace App\Entity;
     
    +use ApiPlatform\Metadata\ApiResource;
     use App\Repository\TodoRepository;
     use Doctrine\ORM\Mapping as ORM;
     
     #[ORM\Entity(repositoryClass: TodoRepository::class)]
    +#[ApiResource]
     class Todo
     {
         #[ORM\Id]
    

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": "Collection",
  "totalItems": 0,
  "member": []
}

That's it for the working API-Platform default features.

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. Fixing the "application/json" type not supported for POST

When the client tries to create a Todo with a POST of plain JSON, it'll fail with:


POST http://127.0.0.1:8000/api/todos
FAILED

415: Unsupported Media Type ({"@context":"\/api\/contexts\/Error","@id":"\/api\/errors\/415","@type":"Error","title":"An error occurred","detail":"The content-type \u0022application\/json\u0022 is not supported. Supported MIME types are \u0022application\/ld+json\u0022.","status":415,"type":"\/errors\/415","trace":[{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/vendor\/api-platform\/state\/Provider\/ContentNegotiationProvider.php","line":48,"function":"getInputFormat","class":"ApiPlatform\\State\\Provider\\ContentNegotiationProvider","type":"-\u003E"},{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/vendor\/api-platform\/symfony\/Controller\/MainController.php","line":83,"function":"provide","class":"ApiPlatform\\State\\Provider\\ContentNegotiationProvider","type":"-\u003E"},{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/vendor\/symfony\/http-kernel\/HttpKernel.php","line":181,"function":"__invoke","class":"ApiPlatform\\Symfony\\Controller\\MainController","type":"-\u003E"},{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/vendor\/symfony\/http-kernel\/HttpKernel.php","line":76,"function":"handleRaw","class":"Symfony\\Component\\HttpKernel\\HttpKernel","type":"-\u003E"},{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/vendor\/symfony\/http-kernel\/Kernel.php","line":197,"function":"handle","class":"Symfony\\Component\\HttpKernel\\HttpKernel","type":"-\u003E"},{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/vendor\/symfony\/runtime\/Runner\/Symfony\/HttpKernelRunner.php","line":35,"function":"handle","class":"Symfony\\Component\\HttpKernel\\Kernel","type":"-\u003E"},{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/vendor\/autoload_runtime.php","line":29,"function":"run","class":"Symfony\\Component\\Runtime\\Runner\\Symfony\\HttpKernelRunner","type":"-\u003E"},{"file":"\/home\/olivier\/git\/gitlab.com\/olberger\/todobackend-symfony6\/public\/index.php","line":5,"function":"require_once"}],"description":"The content-type \u0022application\/json\u0022 is not supported. Supported MIME types are \u0022application\/ld+json\u0022."})

This error 500 displays confirms: Serialization for the format "jsonld" is not supported.

This needs to be fixed by changing config/packages/api_platform.yaml:

@@ -5,3 +5,10 @@ api_platform:
         stateless: true
         cache_headers:
             vary: ['Content-Type', 'Authorization', 'Origin']
+    formats:
+        # JSON first as it's the default for TodoBackend
+        json:     ['application/json']
+        # Then HTML for the swagger API docs
+        #html:     ['text/html']
+        jsonld:   ['application/ld+json']
+        

4.4.3. Adding default value for the 'completed' attribute

We'll change the default values of the completed and todo_order attributes for newly created items, to solve the following error which will happen at runtime:

An exception occurred while executing a query: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: todo.completed

Here's a proposed change in src/Entity/Todo.php:

@@ -19,10 +19,10 @@ class Todo
     private ?string $title = null;
 
     #[ORM\Column]
-    private ?bool $completed = null;
+    private ?bool $completed = false;
 
     #[ORM\Column]
-    private ?int $todo_order = null;
+    private ?int $todo_order = 0;
 
     public function getId(): ?int
     {

4.4.4. Adding DELETE on collection

The test suite will attempt to delete all items, which will report the issue: No route found for "DELETE http://127.0.0.1:8000/api/todos": Method Not Allowed (Allow: GET, POST)

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 deleteAll() 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. Add a custom Controller as a handler for DELETE operations on collections:

    <?php
    // src/Controller/TodoDeleteCollection.php
    
    namespace App\Controller;
    
    use App\Repository\TodoRepository;
    
    use App\Entity\Todo;
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Response;
    
    /**
     * Custom operation handler for DELETE on collections 
     * see https://api-platform.com/docs/symfony/controllers/
     */
    class TodoDeleteCollection extends AbstractController
    {
        /**
         * @var TodoRepository used for deleting all items
         */
        private $entityRepository;
    
        public function __construct(TodoRepository $entityRepository)
        {
            $this->entityRepository = $entityRepository;
        }
    
        public function __invoke(): Response
        {
            $this->entityRepository->deleteAll();
            return new Response();
        }
    }
    
  3. Finally, add the custom DELETE configuration for the ApiResource annotation of api-platform :

    // src/Entity/Todo.php
    
    //...
    
    use ApiPlatform\Metadata\ApiResource;
    use ApiPlatform\Metadata\Delete;
    use ApiPlatform\Metadata\Get;
    use ApiPlatform\Metadata\GetCollection;
    use ApiPlatform\Metadata\HttpOperation;
    use ApiPlatform\Metadata\Patch;
    use ApiPlatform\Metadata\Post;
    use App\Controller\TodoDeleteCollection;
    use ApiPlatform\OpenApi\Model\Operation;
    
    /**
     * Todo items
     *
     * We add a custom DELETE operation (see https://api-platform.com/docs/core/operations#creating-custom-operations-and-controllers)
     * 
     */
    #[ORM\Entity(repositoryClass: TodoRepository::class)]
    #[ApiResource(
        operations: [
            new Get(),
            new Delete(),
            new HttpOperation(method: HttpOperation::METHOD_DELETE, uriTemplate: '/todos', controller: TodoDeleteCollection::class,
                openapi:  new Operation(summary: 'Removes all Todo resources.')),
            new GetCollection(),
            new Post(),
            new Patch(),
        ])]
    class Todo
    {
    
    // ...
    

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

4.4.5. 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.6. 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\CustomApiNormalizer':
      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/CustomApiNormalizer.php

<?php
// src/Serializer/CustomApiNormalizer
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;

/**
 * Add url property to the Todo items
 *
 * see 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
 */
final class CustomApiNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{

    private $normalizer;

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

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

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

    public function normalize($object, $format = null, array $context = [])
    {
        $data = $this->normalizer->normalize($object, $format, $context);
        if (is_array($data)) {
            $url = $this->router->generate('_api_/todos/{id}{._format}_get', [
                'id' => $object->getId()
            ], UrlGeneratorInterface::ABSOLUTE_URL);
            $data['url'] = $url;
        }
        
        return $data;
    }

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

    public function denormalize($data, $class, $format = null, array $context = [])
    {
        return $this->normalizer->denormalize($data, $class, $format, $context);
    }
    
    public function setSerializer(SerializerInterface $serializer)
    {
        if($this->normalizer instanceof SerializerAwareInterface) {
            $this->normalizer->setSerializer($serializer);
        }
    }
}

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

4.4.7. Supporting PATCH request on Todo items

The PATCH method should be allowed on items, so we change the ApiResource to declare a Patch item operation, as specifed above.

4.4.8. 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

5.1. Deploying on heroku

See "Deploying on heroku" in https://gitlab.com/olberger/todobackend-symfony4/-/blob/master/README.org, which may provide some hints

5.2. Deploy on shared hosting with just FTP access

The app should be working on shared hosting with just FTP access (i. e. without composer available through SSH, for instance).

Sources :

  • https://medium.com/@runawaycoin/deploying-symfony-4-application-to-shared-hosting-with-just-ftp-access-e65d2c5e0e3d
  • https://symfony.com/doc/6.4/deployment.html
    1. Prepare the static files to be deployed:

      The following commands should generate the necessary files to be uploaded:

      composer dump-env prod
      
      APP_ENV=prod composer install --no-dev --optimize-autoloader
      
      APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
      
      composer dump-autoload --optimize --no-dev --classmap-authoritative
      
      composer require symfony/apache-pack
      
    2. Upload the files using ftp to the Web server, in order to get files split in corresponding dirs. Usually:
      • some sort of public_html/ dir which will contain contents of the public/ sources dir (the .htaccess, the index.php and accompanying bundles/ resources (needed for the OpenAPI client interface of API-Platform)
      • some private dir which isn't served by the Web server, which will contain the rest of the code and data for the application. For instance here symfony/todobackend-symfony6/ which will list:

        .env.local.php
        bin/
        composer.json
        config/
        src/
        templates/
        var/ (recreated empty)
        vendor/
        
        
    3. Adjust paths and other bits in order to make the Symfony kernel work:
      • Modify index.php to change some bits:
        • the path to the Symfony code of the app. For instance, imagine that the app is available at http://www.example.com/todobackend/index.php, with that index.php present in public_html/todobackend/ on the Web server, then the following change is needed, from:

          require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

          into:

          require_once dirname(__DIR__).'/../symfony/todobackend-symfony6/vendor/autoload_runtime.php';

        • next, for some reason, the environment variables don't seem to be passed to the script correctly in my case, so I changed:

          Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

          to :

          Kernel('prod', (bool) false);

          You may set it to : Kernel('prod', (bool) true); in case of debugging needs…

      • Modify .env.local.php to set 'CORS_ALLOW_ORIGIN' => '*',
      • etc.

Author: Olivier Berger

Created: 2025-08-14 jeu. 17:13

Validate