Building a REST API with Symfony and API platform

One of the basic building blocks of a project is to have a nice resilient API. In this guide, we will show you how you could setup a fully functional REST API with Symfony and API platform which conforms to the Open API specification. As a bonus, you will also get auto-generated documentation via Swagger. Lets jump to see what exactly we need to get started…


Rest API with Symfony and API platform

1. Requirements


The requirements to setup a Rest API with Symfony are –

  • Composer – used to download dependencies used by Symfony
  • A Symfony project [OR you can setup a new Symfony project as described below]

For this tutorial, we will use a fresh installation of Symfony. (We use Symfony version 5.3.2, it being the latest as of July 2021)


2. Setup Symfony

There are 2 ways to setup a new Symfony project.

A. Using Symfony Installer

Download the Symfony binary/installer.
Then initialize a new project by executing the following in your terminal

# [optional] to check your php version, php extensions
symfony check:requirements

# Install a new Symfony project 
symfony new my_rest_api

B. Using Composer

# Install a new Symfony project 
composer create-project symfony/skeleton my_rest_api

The installation should generate a Symfony project within the folder “my_rest_api“.
To run the project

# If you have the Symfony binary, you can use the built-in server,
symfony server:start
# Browse to http://localhost:8000/

# If you're using your own web-server (like Apache)
# ensure your server is running and your webroot points to your project
# Browse to http://localhost/public/
# or to http://localhost/my_rest_api/public/ (depending on your webroot)

Doing this should show you the welcome screen for Symfony. If you see this, your installation has gone right.
Symfony installed


3. Setup API-platform

Now we will go ahead and setup API-platform.

To install API-platform, execute the following in the terminal

composer require api

That’s it! Thanks to composer, the latest version (v2.6 as of July 2021) with all required dependencies will be installed automatically and you will see the following screen which will prompt you to configure your database and create your API.

Api platform post installation

At this point, if you navigate to /api, you should see your API’s swagger documentation setup for you automatically.

Swagger docs

As expected, it will be empty since we haven’t defined any API endpoints yet. The route /api was configured by API-platform and can be edited from the file /config/routes/api_platform.yaml. Here’s a small snippet of the same –

api-platform config

Now lets move ahead to configure our database.


4. Configure Database

For demonstration purpose, we will use a new database named “dcuniverse“.
Open up your .env file located at the root of the project and update your DATABASE_URL.
Mine looks something like this:

# "root" is my mysql username
# "password" is my mysql password
# "dcuniverse" is the name of the database I wish to use
# "8.0" is my mysql server's version
# "127.0.0.1:3306" is where my mysql server is running (3306 is the default port for mysql)
DATABASE_URL="mysql://root:password@127.0.0.1:3306/dcuniverse?serverVersion=8.0"

Note: You can also create a .env.local which is a copy of .env. The difference is that the settings defined in .env are overriden by those defined in .env.local

The database “dcuniverse” doesn’t exist yet. So, we need to create it. It can be done in many ways, for example, using phpmyadmin or mysql console or even doctrine.
We’re gonna use doctrine to do it.

# create your database
php ./bin/console doctrine:database:create

# output: Created database `dcuniverse` for connection named default

If the connection to your Sql server was established correctly, a new database will be created, otherwise you should see an error in your terminal.


5. Create your API endpoints

Now, to the main event – Creating an API endpoint. As an example, lets assume we want to create an API that returns some details about superheroes. To do this, we start by creating a new entity named Superheroes.php.

You can either use the SymfonyMakerBundle to generate an Entity if you’d like
OR use the following code snippet –

We have 5 fields/properties (id, name, slug, featured and created_at).

The most important thing to note is the inclusion of @ApiResource which declares this entity to be used as an API.

// src/Entity/superheroes.php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
use ApiPlatform\Core\Annotation\ApiResource;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource()
 */
class superheroes
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    private ?int $id = null;

    /**
     * @ORM\Column(length=70)
     * @Assert\NotBlank()
     */
    public string $name;

    /**
     * @ORM\Column(length=70, unique=true)
     * @Assert\NotBlank()
     */
    public string $slug;

    /**
     * @ORM\Column(type="boolean")
     */
    public bool $featured = false;

    /**
     * @ORM\Column(type="datetime")
     */
    public ?\DateTime $created_at = null;


    /******** METHODS ********/

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

    /**
     * Prepersist gets triggered on Insert
     * @ORM\PrePersist
     */
    public function updatedTimestamps()
    {
        if ($this->created_at == null) {
            $this->created_at = new \DateTime('now');
        }
    }

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

Now that our entity is ready, its time to update your database. So head back to your terminal and execute the following to create a superheroes table.

# To check the raw sql query before executing
php bin/console doctrine:schema:update --dump-sql

# To execute the SQL query
php bin/console doctrine:schema:update --force
# output: [OK] Database schema updated successfully! 

This should have created a new table in your database.


Now, if you head back to the route /api, boom, you’ll find 6 new endpoints as well as database schemas.
Fantastic, isn’t it ?
superheroes api docs

Lets see if we can post any data to the newly created API. Click on “POST” which should open up more details about posting. And there, at the right side, you’d find a “Try it out” button.

Superheroes post method try it out

Lets actually try it out then by clicking on this button. Provide some random data in the available input fields and once you’re done, hit the “Execute” button.

[Note: If you see, we use createdAt: null while it expects a datetime string. But setting it as null works as well because we have used Doctrine’s PrePersist lifecycle callback which gets executed each time a new record is inserted (line 59 in the above Superheroes Entity code snippet)]

Superheroes api post input field

This will make a curl request with the necessary headers (json-ld format by default) and the input data as the payload. The response of the curl request is also shown below it (which returns a 201 response code). Whaaat!!! Thrilling right! It just worked.

superheroes api - post curl results

Nonetheless, I wanted to verify the database via phpmyadmin to ensure a new record was truly inserted, and Voila! There it was! The newly created record, staring at me!

superheroes api - post phpmyadmin results

That’s how simple it was to create a fully functional REST API. Feel free to try out the other API end-points too! Just by modifying the headers, you can get results in the desired format. Lets see how we can get the reponse in different formats.


6. Retrieve data in different formats

API-platform is capable of returning data in several formats of which only 2 are enabled

  • Json-Ld (default)
  • Json

It uses Json-Ld by default. So how do you get data in Json format.
Before we get to that, try visiting the route, /api/superheroes/1. You might expect the details of the first record to be shown, however, you’d be disappointed to land on the API-Swagger documentation instead. Note that this is completely normal. Why?

That’s because API platform returns a response in the format specified in either the Accept HTTP header or as an extension appended to the URL. When you navigate to /api/superheroes/1, the accept headers sent by your browser default to 'accept: text/html' and hence you were shown the API’s documentation.


To get the response in JSON,
– Via your browser, visit the route /api/superheroes/1.json
– Via curl, set the headers to -H 'accept: application/json'

Doing that will yeild the results in the expected format.

Symfony Api platform response in json format


To get the response in JSON-LD,
– Via your browser, visit the route /api/superheroes/1.jsonld
– Via curl, set the headers to -H 'accept: application/ld+json'

That should give you a similar output as this –
Symfony API platform JSONLD format


API platform is also capable of delivering data in other formats like CSV, YAML, XML or even GraphQL. Further configuration is needed to enable these formats.


7. Excluding Properties from your API endpoint

What if you did not want to expose all properties of your entity via the API ? Let’s understand how using our superheroes entity. As an example, we will make the created_at property optional while using the POST (/api/superheroes) API endpoint.

To do that, we are going to update our superheroes.php entity as shown below:

// src/Entity/superheroes.php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   normalizationContext={"groups" = {"read"}},
 *   denormalizationContext={"groups" = {"write"}}
 * )
 */
class superheroes
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     * @Groups({"read"})
     */
    private ?int $id = null;

    /**
     * @ORM\Column(length=70)
     * @Assert\NotBlank()
     * @Groups({"read", "write"})
     */
    public string $name;

    /**
     * @ORM\Column(length=70, unique=true)
     * @Assert\NotBlank()
     * @Groups({"read", "write"})
     */
    public string $slug;

    /**
     * @ORM\Column(type="boolean")
     * @Groups({"read", "write"})
     */
    public bool $featured = false;

    /**
     * @ORM\Column(type="datetime")
     * @Groups({"read"})
     */
    public ?\DateTime $created_at = null;


// /******** METHODS ********/
// ...

So what’s happening here ? The API Platform uses Symfony’s Serializer and allows you to set the $context variable which is an associative array that has a groups key allowing you to choose which properties of the entity are exposed during the normalization (read) and denormalization (write) processes.

You see that we create 2 groups read and write for normalization and denormalization respectively (You can use any group names you wish). Now, all the properties except created_at have both the groups. The created_at property is given only the “read” group which means this property now becomes read-only and that you will NOT be able to manipulate its value via the POST API endpoint.

This might not work just as yet, because we’re using annotations and so we need to enable them to be used with Symfony’s serializer.

# config/packages/framework.yaml
framework:
    serializer: { enable_annotations: true }

Also, ensure that the package doctrine/annotations is up to date. So, head to your terminal,

# update doctrine/annotations to the latest version  
composer req doctrine/annotations

Now if you visit your api (http://localhost:8000/api), and open up the POST Superheroes endpoint, you should see something like this
API platform readonly property

As expected, the created_at property is missing because it has become read-only.
Note: If you have issues see this page, ensure you refreshed your cache.

# refresh cache
php bin/console cache:clear --no-warmup --env=dev
php bin/console cache:warmup --env=dev

# or if you're using source-code available on github
make refresh

Similarly, you can make a property write-only as well. If you try and remove the “read” group associated with the featured property, you will notice that when you visit the GET superheroes API http://localhost:8000/api/superheroes/1.json, the featured property will not be shown. You will however be able to write to it using the POST endpoint. (Again, refreshing the cache might be needed to see the expected results)


8. Adding a Route prefix

Adding a route prefix is pretty simple. All you need to do is to add the correct annotation. Going by the superheroes example, we have added an attribute "route_prefix"="/dc" as seen below

// src/Entity/superheroes.php
<?php
// ...

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   attributes={"route_prefix"="/dc"}
 * )
 */
class superheroes
{
// ...

That’s it. Doing that will update all your superheroes entity routes with the specified prefix.

Api platform - Add route prefix


9. Exposing certain API endpoints

If you notice, 6 endpoints are exposed by default as seen in the image above(get, post, get, put, delete, patch).

What if you didn’t want to expose the patch and put endpoints ?

To do that, we need to specify the itemOperations manually. We exclude the endpoints that we do not want to be exposed and include only the ones we need. So we want “get”, “post” and “delete”. If you notice in the code snippet below, we have also excluded the “post” endpoint from the itemOperations even though we need it. This is because “POST” is not an itemOperation but a collectionOperation. There are 2 collection operations included by default (“GET” and “POST”). Since it is already included by default, we are not required to do any modification. To learn more about itemOperations and collectionOperations, you can refer the official docs.

// src/Entity/superheroes.php
<?php
// ...

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   itemOperations={"get", "delete"}
 * )
 */
class superheroes
{
// ...

If you now visit your API documentation, you should see only the get, delete and post API endpoints as expected.

expose certain API endpoints alone

Note: If you do not wish to expose the POST endpoint, then you would have to update the collectionOperations like this – collectionOperations={"get"} i.e expose only the GET collection operation. By default, both get and post collectionOperations are exposed.


10. Exposing New API endpoints

Many a times, we need to expose a new route that’s different than the default ones exposed by the API platform. We’ll show you how to expose a new endpoint by modifying our “superheroes” entity.

API platform by default lets us retrieve a superhero only by ID (GET /api/superheroes/{id}). As an example, let’s say that we want to be able to retrieve a superhero by its slug as well. To do so, we will have to expose a new “GET” API endpoint. The endpoint that we’re aiming for is “GET /api/superhero/{slug}.

How do we go about this ? We do that by creating a custom route/operation and its controller. So, lets update our superheroes entity with the necessary fields.

// src/Entity/superheroes.php
<?php
// ...
use App\Controller\SuperheroBySlug;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   normalizationContext={"groups" = {"read"}},
 *   denormalizationContext={"groups" = {"write"}},
 *   itemOperations={
 *     "get",
 *     "patch",
 *     "delete",
 *     "put",
 *     "get_by_slug" = {
 *       "method" = "GET",
 *       "path" = "/superhero/{slug}",
 *       "controller" = SuperheroBySlug::class,
 *       "read"=false,
 *     },
 *   }
 * )
 */
class superheroes
{
// ....

As seen above, we created a new itemOperation named “get_by_slug” which requires 3 mandatory properties (method, path, controller). We set our desired method, path and controller. The controller handles the request. This controller does not exist as yet, but we will create it shortly. We also set "read"=false because we wish to bypass the automatic retrieval of the entity.
By default, API platform tries to retrieve an entity by its ID. In our case, it will not work because we will be providing a slug and not an ID. Hence, we are going to retrieve the entity ourselves in the controller. Lets create this so called controller.

// src/Controller/SuperheroBySlug.php
<?php

namespace App\Controller;

use App\Entity\superheroes;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
class SuperheroBySlug extends AbstractController
{
    public function __invoke(string $slug)
    {
        $superhero = $this->getDoctrine()
            ->getRepository(superheroes::class)
            ->findBy(
                ['slug' => $slug],
            );

        if (!$superhero) {
            throw $this->createNotFoundException(
                'No superhero found for this slug'
            );
        }

        return $superhero;
    }
}

The only job of our controller is to retrieve the correct entity by its slug and return it. After that, the response passes through all the default event handlers of the API platform before being finally returned to the client.

At this point, if you head to /api, you should see your new endpoint. When you try to execute the query, you will realize that it doesn’t really work. The new endpoint still expects an id parameter and it doesn’t pass the slug at all.

new API endpoint (without openAPI context)

This is a problem with the swagger documentation that is generated automatically and not with the new endpoint itself. We’ll see how to fix this shortly.

To confirm that it works, an alternate way to test the new endpoint is with curl

# make a curl request
curl -X 'GET' 'http://localhost:8000/api/superhero/superman' -H 'accept: application/json'

# Response
# [{"id":1,"name":"Clark Kent","slug":"superman","featured":true,"created_at":"2021-06-25T19:14:56+02:00"}]

As expected, you should get the linked record as the response.


Getting back to why the query didn’t work on the documentation page, its because the OpenApi context (Swagger/your documentation) needs to be updated. The documentation gets generated correctly for all the default endpoints but a custom endpoint, requires a little intervention. So we will have to update our documentation by modifying the code as shown below –

// src/Entity/superheroes.php
<?php
// ...

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   normalizationContext={"groups" = {"read"}},
 *   denormalizationContext={"groups" = {"write"}},
 *   itemOperations={
 *     "get",
 *     "patch",
 *     "delete",
 *     "put",
 *     "get_by_slug" = {
 *       "method" = "GET",
 *       "path" = "/superhero/{slug}",
 *       "controller" = SuperheroBySlug::class,
 *       "read"=false,
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "slug",
 *             "in" = "path",
 *             "description" = "The slug of your superhero",
 *             "type" = "string",
 *             "required" = true,
 *             "example"= "superman",
 *           },
 *         },
 *       },
 *     },
 *   }, 
 * )
 */
class superheroes
{
// ....

Now, head back to your documentation page (/api) to see the updated documentation. It should look like this –

Get by slug endpoint - API platform

If you try to execute the request, you will most certainly have the expected response 😉

Note: Currently there exists a bug within the documentation that expects the ID parameter (even though there is no Id to pass). For now, you can ignore it as its presence does not affect your endpoint in any way. I have reported the bug and it should be fixed soon.

Finally, you can update your API’s configuration with its version, name, description and many other parameters as described in the next section.


API Platform Configuration options

Feel free to update the configuration of your API (config/packages/api_platform.yaml). This is what mine looks like
Api platform configuration options

Find the complete list of all the configuration options here.

That’s how simple is it to get an API up and running. Hope it helps.


The entire source code of the project is available on Github. After cloning the repository, be sure to checkout the tag “v1.0.0. (i.e. git checkout -b restApiTutorial v1.0.0) to check/test the code. Hope that helps 🙂


Going Further…


References

Leave a Reply