◀ Back to blog
PHP / Symfony

Building APIs with Symfony API Platform

Published on 25 Mar 2024· 8 min read
#Symfony#API Platform#REST

API Platform: the API framework for Symfony

API Platform is the reference framework for building modern APIs with Symfony. It automatically generates a REST and GraphQL API from your entities.

In practice, you describe your resources with PHP attributes (which operations are exposed, who can access them, which fields are readable or writable) and API Platform takes care of the rest: routing, serialization, validation, pagination, filters, content negotiation and OpenAPI documentation. The time saved on this repetitive code can go into business rules. This article covers setting up a complete resource, then the points that make the difference in production: serialization groups, security, custom business logic and tests.

Installation and configuration

composer require api
# Automatically creates the API Platform configuration

Thanks to Symfony Flex, the api alias installs API Platform and its recipe: the config/packages/api_platform.yaml file, routes under the /api prefix, and CORS configuration. Once the server is running, /api displays interactive documentation (Swagger UI) generated from your resources. The global configuration lets you set default values for all resources:

# config/packages/api_platform.yaml
api_platform:
    title: 'Catalogue API'
    version: '1.0.0'
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
    defaults:
        pagination_items_per_page: 20
        pagination_maximum_items_per_page: 100
        pagination_client_items_per_page: true

With pagination_client_items_per_page, the client can choose the page size through the itemsPerPage parameter, but never beyond the configured maximum. Without that cap, a single request could ask for the whole table.

Creating an API resource

A resource is a class marked with #[ApiResource]. It is often a Doctrine entity, as here, although it does not have to be. The operations list explicitly declares the exposed operations: anything not listed does not exist in the API.

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
    ],
    paginationItemsPerPage: 20,
)]
#[ORM\Entity]
class Product
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    private string $name;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $price;

    // Getters and setters...
}

This single file produces GET /api/products (paginated collection), GET /api/products/{id} and POST /api/products, restricted to administrators. The price is stored as a decimal and handled as a string in PHP: this is deliberate, to avoid floating-point rounding errors on amounts. Validation constraints (#[Assert\NotBlank]) are applied automatically on writes: invalid data produces a 422 response detailing each violation, field by field.

Filters and search

Filters add query parameters to collections without writing a single line of SQL:

use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;

#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
#[ApiFilter(RangeFilter::class, properties: ['price'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'price'])]
class Product
{
    // ...
}

The client can then call /api/products?name=clavier&price[lt]=100&order[price]=asc. Each filter also appears in the OpenAPI documentation. Two precautions: a partial filter translates into a LIKE '%…%' that cannot use a regular B-tree index and becomes expensive on large tables; and only expose filters on fields that are actually useful, since each one is additional query surface. Recent versions of API Platform also offer a parameter-based approach (QueryParameter) declared directly on the operation.

Serialization Groups

By default, every accessible property is exposed for both reading and writing. That is rarely what you want: the identifier must not be writable, and some internal fields must never leave the server. Serialization groups separate what the API reads from what it accepts:

use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    normalizationContext: ['groups' => ['product:read']],
    denormalizationContext: ['groups' => ['product:write']],
)]
class Product
{
    #[Groups(['product:read'])]
    private ?int $id = null;

    #[Groups(['product:read', 'product:write'])]
    private string $name;

    #[Groups(['product:read', 'product:write'])]
    private string $price;
}

A property without a group is invisible to the API. This is the simplest protection against accidentally exposing a field added to the entity later, such as a purchase price or an internal note. Adopt a naming convention (resource:read, resource:write) and stick to it.

Per-operation security

The security option accepts an expression evaluated before the operation. For item operations, the object variable gives access to the resource concerned, which allows ownership rules:

use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch;

#[ApiResource(
    operations: [
        new Patch(security: "is_granted('ROLE_ADMIN') or object.getOwner() == user"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
)]

For richer rules, delegate to a Symfony Voter with is_granted('PRODUCT_EDIT', object): the authorization logic stays testable and reusable outside the API.

Business logic: State Processors

By default, writes are persisted directly by Doctrine. To add behavior (sending a message, a computation, an external call), API Platform uses State Providers for reads and State Processors for writes. The most common approach is to decorate the Doctrine processor:

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Product;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/** @implements ProcessorInterface<Product, Product> */
final class ProductProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        private LoggerInterface $logger,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Product
    {
        $product = $this->persistProcessor->process($data, $operation, $uriVariables, $context);

        $this->logger->info('Product saved', ['id' => $product->getId()]);

        return $product;
    }
}

You then enable it on the relevant operation: new Post(processor: ProductProcessor::class). The same mechanism lets you expose resources that are not entities, such as a DTO fed by a third-party API, with a dedicated provider and processor. This is often cleaner than exposing the database model directly.

Testing the API

API Platform provides ApiTestCase, a test class built on the Symfony HTTP client, with assertions tailored to JSON responses:

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;

final class ProductApiTest extends ApiTestCase
{
    public function testCollectionIsPublic(): void
    {
        static::createClient()->request('GET', '/api/products');

        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
    }

    public function testUnknownProductReturns404(): void
    {
        static::createClient()->request('GET', '/api/products/999999');

        $this->assertResponseStatusCodeSame(404);
    }
}

Add at least one test per security rule: a user without permissions must receive an error, and an administrator must succeed. That is where the most serious regressions hide.

Advantages

  • Automatically generated OpenAPI documentation
  • JSON-LD and Hydra support
  • Built-in pagination, filters and sorting
  • Automatic validation through Symfony constraints
  • Native GraphQL support

GraphQL support requires installing an additional package, then reuses the same resources, groups and security rules as the REST API. The OpenAPI specification can be exported with php bin/console api:openapi:export to generate clients or feed a contract-testing CI.

Platforms such as CCM Benchmark use API Platform to expose their internal services through standardized APIs.

Pitfalls to avoid

  • Exposing the entity as is: without serialization groups, every new property becomes public. Always define groups, or go through DTOs.
  • Forgetting N+1: a collection that serializes relations can trigger one query per item. Watch the Symfony profiler and add joins (through an API Platform Doctrine extension) when needed.
  • Leaving all default operations: without an operations list, API Platform also exposes PUT, PATCH and DELETE. Declare explicitly what you want.
  • Coding security in controllers: keep it in security attributes and Voters, where it is visible and testable.

API Platform is not the best choice for an API made of a few very specific endpoints unrelated to a resource model: a classic Symfony controller will be simpler there. But as soon as you need to expose a business model as CRUD with pagination, filters, security and documentation, it is one of the most productive ways to do it in PHP.