DEV Community

aasawari sahasrabuddhe
aasawari sahasrabuddhe

Posted on

Building REST APIs with API Platform, Symfony, and MongoDB

Today's web applications rely on large datasets and require real-time database interaction. Developers often face the challenge of efficiently managing and scaling CRUD (Create, Read, Update, Delete) operations while maintaining flexibility, performance, and security. However, building a standard API often comes with significant challenges: documenting endpoints, mapping them to the database, handling data transformation, and ensuring validation—all while maintaining performance, scalability, and security.

Building an application with API Platform provides a robust, flexible, and user-friendly solution for creating modern APIs. When seamlessly integrated with MongoDB, it empowers developers to efficiently handle CRUD operations while maintaining scalability and ease of use.

In this tutorial, we will use API Platform with Symfony to build REST APIs that perform CRUD operations on the MongoDB database. To connect the application with MongoDB, we will use MongoDB Atlas.

Prerequisites

  • A free Atlas cluster—register to create your first free cluster
  • Docker installed

Creating a Symfony project

To start with the Symfony project using API Platform, follow the steps outlined below:

  1. Create the template project, which generates all the necessary files for you. To do so, generate a GitHub repository with your choice of name using the api-platform repository template. You can refer to the screenshot below to fill in the entries.

Image showing steps to clone the GitHUb repository

  1. Once the repository is created, you can clone it using the command below.
git clone <URL to your repository>  
Enter fullscreen mode Exit fullscreen mode

Once you have the code on your local machine, we will edit the application to connect with MongoDB.

To learn more, you can follow the steps mentioned in the Getting Started with API Platform documentation for Symfony.

In the next section, we'll understand how to connect your application to the MongoDB Atlas cluster and perform the CRUD operations on the collections inside the cluster.

Connecting your application with MongoDB Atlas

After you have your sample application running correctly, the next step is to connect with MongoDB Atlas. To do so, we will first need to create a free Atlas cluster and get the connection string.

To get the connection string, click on Connect and then select the appropriate driver with the correct version. You will see a screen like the one below; copy the connection string and keep it safe with you.

Image showing steps to get the Atlas connection String

Once you have the connection string, the next step is to update the Dockerfile and install the PHP extensions. The Dockerfile to install the extensions is available inside the api/ folder.

Update the file with the code below to install PHP extensions.

Add the below code changes to the Dockerfile you will need to update:

RUN apt-get update && apt-get install --no-install-recommends -y \
    libcurl4-openssl-dev \
    libssl-dev \
    && pecl install mongodb \
    && docker-php-ext-enable mongodb
Enter fullscreen mode Exit fullscreen mode

After the update, run the command from the root folder of the project to install the extensions. In this case, go to .

docker compose build --no-cache
Enter fullscreen mode Exit fullscreen mode

Update the .env file with the connection string and database name.

MONGODB_URL=<Atlas URI>
MONGODB_DB=Test
Enter fullscreen mode Exit fullscreen mode

Update the compose.yaml file with the connection string as:

services:
  php:
    image: ${IMAGES_PREFIX:-}app-php
    depends_on:
      - pwa
    environment:
      MONGODB_URL: <Atlas URI>
Enter fullscreen mode Exit fullscreen mode

After the extensions are installed, we need to start the containers and install the ODM bundle.

docker compose up --wait
Enter fullscreen mode Exit fullscreen mode

Once the containers are up and ready, execute the below commands to install the ODM bundle.

docker compose exec php \
 composer require doctrine/mongodb-odm-bundle api-platform/doctrine-odm  
Enter fullscreen mode Exit fullscreen mode

The mongodb-odm-bundle is a bundle (a modular package of code) that integrates MongoDB ODM into Symfony. This library provides a PHP object mapping functionality for MongoDB.

Performing CRUD operations

Once you are all set with making the MongoDB Atlas connections, the next step is to create REST APIs to perform CRUD operations.

In this tutorial, we are using a simple collection named Restaurants, which will have the following field values:

{
    "name": "The Gourmet Spot",
    "address": {
      "building": "123",
      "street": "Elm Street",
      "zipcode": "12345"
      },
    "borough": "Manhattan",
    "cuisine": "Italian"
}
Enter fullscreen mode Exit fullscreen mode

Once the document structure is decided, the next step is to create the Document and the Controller class.

The Document class is created at api/src/Document/Restaurant.php.

Copy the below code in the Restaurant.php class. This class has all the field values with all the getter and setter methods.

<?php

namespace App\Document;

use ApiPlatform\Metadata\ApiResource;
use App\Repository\RestaurantRepository;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;

#[ApiResource]
#[Document(collection: 'restaurants')]
class Restaurant
{
    #[Id]
    public string $id;

    #[Field]
    public string $name;

    #[Field]
    public array $address;

    #[Field]
    public string $borough;

    #[Field]
    public string $cuisine;
}
Enter fullscreen mode Exit fullscreen mode

The Address.php will look like:

<?php

namespace App\Document;

use Doctrine\ODM\MongoDB\Mapping\Annotations\EmbeddedDocument;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;

#[EmbeddedDocument]
class Address
{
    #[Field]
    public string $building;

    #[Field]
    public string $street;

    #[Field]
    public string $zipcode;
}
Enter fullscreen mode Exit fullscreen mode

Once the code is all set, run the below command to run the complete application.

HTTP_PORT=8080 HTTPS_PORT=8443 docker-compose up 
Enter fullscreen mode Exit fullscreen mode

Testing the CRUD operations

The next step is to access the access APIs that have been created. The below URI takes you to the swagger of the API platform to test the REST API calls.

https://localhost:8443/docs
Enter fullscreen mode Exit fullscreen mode

API Platform natively supports the Open API (formerly Swagger) API documentation format. It also integrates a customized version of Swagger UI, a nice tool to display the API documentation in a user-friendly way.

This page at the below URI will look like the following:

Image showing screenshot for the API platform swagger

Let's test these REST APIs.

Create

To test the CREATE API, go to the POST method and place the JSON as:

{
    "name": "The Gourmet Spot",
    "address": {
      "building": "123",
      "street": "Elm Street",
      "zipcode": "12345"
      },
   "borough": "Manhattan",
   "cuisine": "Italian"
}
Enter fullscreen mode Exit fullscreen mode

Click on "Try it out" and you will see the below document has been inserted into the collection:

Screenshot of the swagger representing the POST request

To verify, navigate to the Atlas cluster and check if the data has been inserted in the test.Restaurants collection. The screenshot below shows that the data has been inserted into the database.

Screenshot of Atlas UI representing that data has been inserted

Read

To get all the documents from the collection, you can simply use the GET API to get all the documents. To get a specific document from the collection, run the GET API as specified.

Screenshot of swagger representing GET request

Update

There are two operations to update an existing document. PUT replaces the full document and PATCH will update only the properties that are sent. Usually, only PATCH is used, which is why the PUT operation is disabled by default. For more information, you can look into the documentation for API Platform Operations.

To test this API, we have updated the above JSON document as:

{
    "name": "The Gourmet Spot",
    "address": {
      "building": "123",
      "street": "Elm Street",
      "zipcode": "12345"
      },
    "borough": "New York",
    "cuisine": "Spanish"
}
Enter fullscreen mode Exit fullscreen mode

The API results in:

Screenshot of swagger representing PUT request

The document is also updated in the Atlas cluster.

Screenshot of Atlas UI representing the update of the document

Delete

Finally, to delete specific restaurant information with an _id, we run the API call as:

Screenshot of swagger representing DELETE request

The above tests cover the basic CRUD operations: Create, Read, Update, Delete. But the API platform goes way beyond that. It has a ton of features that let you build more powerful and dynamic APIs with minimal work.

Let's see each of these in detail in the next section.

Beyond CRUD with API Platform

The API platform simplifies creating and enforcing validations so your data integrity rules are applied consistently without having to write a lot of custom code. And it's great at advanced search too, so you can execute complex queries with ease. It also allows you to apply filters, perform regex validations, and much more.

Performing validations on the field

If you wish to put validations on the field to be required values, we set the field values as #[Assert\NotBlank] before the field values. For example, in the code below, we have marked the name, borough, and cuisine as the required values.

class Restaurant
{
    #[Id]
    public string $id;

    #[Field]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public string $name;

    #[EmbedOne(targetDocument: Address::class)]
    #[Assert\Valid]
    public ?Address $address;

    #[Field]
    #[Assert\NotBlank]
    public string $borough;

    #[Field]
    #[Assert\NotBlank]
    public string $cuisine;
}
Enter fullscreen mode Exit fullscreen mode

As a result, if a request is sent with any of the missing values, an HTTP error will be returned. The screenshot below shows an example of a name missing from the POST request.

Screenshot of swagger representing POST request with required field as empty

We need to add the code to the Validators to add this validation. This code should be available inside the api/src/Validator/Constraints/ folder.

<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

final class MinimalPropertiesValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint): void
    {
        if (array_diff(['name', 'cuisine', 'bourough'], $value)) {
            $this->context->buildViolation($constraint->message)->addViolation();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And:

<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

#[Attribute]
class MinimalProperties extends Constraint
{
    public $message = 'The product must have the minimal properties required ("name", "cuisine", "bourough")';
}
Enter fullscreen mode Exit fullscreen mode

This code defines a custom validation logic in Symfony. The MinimalPropertiesValidator class is responsible for validating that an array contains the required keys: name, cuisine, and borough.

The validate method checks if any of these keys are missing using array_diff, and if the validation fails, it triggers a violation with an error message.

The MinimalProperties class acts as the custom constraint, with its message property holding the error text displayed upon failure.

Similarly, if you wish to add the validator for the ZIP Code provided in the Address field values, add the below code in the api/src/Validator/Constraints/ folder as:

<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

final class ValidZipcodeValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint): void
{
        if (!$constraint instanceof ValidZipCode) {
            throw new InvalidArgumentException(sprintf('Expected instance of %s, got %s.', ValidZipCode::class, get_class($constraint)));}

        if (!preg_match('/^[0-9]{5}$/', $value)) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $value)
                ->addViolation();}
    }
}
Enter fullscreen mode Exit fullscreen mode

And:

<?php

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

#[Attribute]
class ValidZipCode extends Constraint
{
    public $message = 'The zipcode "{{ value }}" is not valid. It must be exactly 5 digits.';
}
Enter fullscreen mode Exit fullscreen mode

In the above code, ValidZipcodeValidator creates the validation that the ZIP Code can only be numeric and only five characters will be allowed. For any other ZIP Code, it should throw the error as mentioned in the ValidZipCode class.

You also need to declare the function in the Address.php as:

#[Field]
#[ValidZipCode]
public string $zipcode;
Enter fullscreen mode Exit fullscreen mode

To test this, we can send the POST request as:

{
    "name": "Dim Sum Express",
    "address": {
      "building": "404",
      "street": "Chinatown Blvd",
      "zipcode": "abcnn"
    },
    "borough": "Manhattan",
    "cuisine": "Chinese"
}
Enter fullscreen mode Exit fullscreen mode

The above request should result in an HTTP error with code 422 and a message saying:

The zipcode \"abcnn\" is not valid. It must be exactly 5 digits
Enter fullscreen mode Exit fullscreen mode

The below screenshot displays the error message with the POST request.

Screenshot of swagger representing POST request invalid ZipCode value

Adding filters using API Platform

The API platform allows you to apply filters and sort criteria on the collections. The search filter supports exact, partial, start, end, and word_start matching strategies. You can read more about filters from the API platform documentation on search filters.

In our case, we will apply search filters to the name and the cuisine fields using the below code inside the Document class.

#[ApiFilter(
    SearchFilter::class,
    properties: [
        'name' => 'ipartial',  // The "ipartial" strategy will use a case-insensitive partial match
        'cuisine' => 'exact',  // The "exact" strategy will use an exact match
    ])
]
Enter fullscreen mode Exit fullscreen mode

As the comment suggests, we have a partial filter that's case-sensitive for the name, and an exact match filter on the cuisine fields.

To test this, we have sample data already being stored inside the collection as:

db.restaurants.find()
[
  {
    _id: ObjectId('6750aa7acda8e992af0c97b4'),
    name: 'The Gourmet Spot',
    address: { building: '123', street: 'Elm Street', zipcode: '12345' },
    borough: 'Manhattan',
    cuisine: 'Italian'
  },
  {
    _id: ObjectId('6750b8406868c9f4fb012be5'),
    name: 'Burger Bliss',
    address: { building: '789', street: 'Main Street', zipcode: '54321' },
    borough: 'Queens',
    cuisine: 'American'
  },
  {
    _id: ObjectId('6750b8516868c9f4fb012be8'),
    name: 'Curry Delight',
    address: { building: '202', street: 'Spice Lane', zipcode: '45678' },
    borough: 'Staten Island',
    cuisine: 'Indian'
  },
  {
    _id: ObjectId('6750b85e6868c9f4fb012beb'),
    name: 'Pasta Paradise',
    address: { building: '303', street: 'Olive Way', zipcode: '11223' },
    borough: 'Manhattan',
    cuisine: 'Italian'
  },
  {
    _id: ObjectId('6750b86e6868c9f4fb012bee'),
    name: 'Pizza Kingdom',
    address: { building: '606', street: 'Pizza Lane', zipcode: '77889' },
    borough: 'Queens',
    cuisine: 'Italian'
  }
]
Enter fullscreen mode Exit fullscreen mode

Now, to apply the search filter, we send the GET request with the cuisine name as Italian and we should see all restaurant with cuisine: 'Italian'.

Screenshot of swagger representing GET request with filtered values only for Cuisine as Italian

Similarly, we send a GET request with a partial name as "name": "Pasta...", and we should have a restaurant with the name "Pasta Paradise."

Screenshot of swagger representing GET request with partial name as only Pasta

You can create more API calls based on the requirement by extending the code available in the GitHub repository and following the API Platform documentation.

Conclusion

In conclusion, we've seen how API Platform lets you quickly create a REST API to perform CRUD operations on a MongoDB database. This framework also lets you add features to the API, such as data validation and query filters, while keeping the code highly comprehensible and scalable. Developers can work in a local environment using Docker and Atlas.

The tutorial also demonstrates how to establish the connection, configure the environment, and perform basic CRUD operations using a simple example, highlighting the flexibility and ease of working with Symfony's API Platform.

To explore more, consider diving into the advanced features of API Platform, optimizing MongoDB queries, or experimenting with additional CRUD operations to meet your specific application needs. If you wish to learn more, you can visit the documentation for API Platform and MongoDB.

For further questions, please reach out to the MongoDB Community Forum, and to learn more, explore the MongoDB Developer Center for more interesting articles.

Top comments (2)

Collapse
 
ashah99 profile image
ashah99

Nice work. How long your link to this article remains available? Do you work with React?

Collapse
 
aasawari_sahasrabuddhe_3c profile image
aasawari sahasrabuddhe

It is available now. It wont be removed.
I don't work in React directly, but would be happy to help if there is anything needed.