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.
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.
At this point, if you navigate to /api
, you should see your API’s swagger documentation setup for you automatically.
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 –
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 ?
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.
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)]
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.
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!
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.
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 –
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
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.
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.
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.
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 –
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
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…
- Step-by-Step tutorial to handle File Uploads with API Platform.
References
- https://symfonycasts.com/screencast/api-platform
- https://stackoverflow.com/questions/symfony-api-platform-operation-route-prefix
- https://api-platform.com/docs/core/
- https://symfony.com/doc/current/the-fast-track/en/26-api.html
- https://github.com/api-platform/api-platform
- https://www.kaherecode.com/tutorial/developper-une-api-rest-avec-symfony-et-api-platform