API Platform 4: REST and GraphQL Without Boilerplate in Symfony
AI generated
SF
{ }
Symfony · API Platform · REST · GraphQL
API Platform 4: REST and GraphQL
without boilerplate in Symfony

Anyone who builds a REST or GraphQL API in Symfony by hand ends up writing controllers, serializer configuration, pagination and error handling themselves. API Platform 4 handles all of that declaratively. A PHP class with the right attribute is enough to get a complete API resource with filtering, pagination and OpenAPI documentation.

18 min read Resources · Operations · Security · Providers · GraphQL Symfony 7.x · API Platform 4.x · PHP 8.3+

1. Why API Platform 4 eliminates boilerplate

Building a REST API in Symfony without a framework means writing a controller for every HTTP method, serialization configuration for every format, implementing pagination manually, maintaining OpenAPI documentation by hand and formatting error responses consistently. That is weeks of work before a single line of business logic gets written. API Platform 4 flips that ratio around: the infrastructure emerges declaratively through attributes, and business logic remains the only code the team really needs to write.

The decisive difference from API Platform 2 and 3 lies in the consistent separation of API resource and Doctrine entity. In version 4, a resource is no longer an entity, it is a plain PHP class that can represent any data source. That makes API Platform 4 flexible for projects that have no direct database mapping: legacy systems, microservices or external data sources can be modeled as API resources without touching Doctrine at all. At the same time, Doctrine integration remains fully intact, anyone using Doctrine automatically gets CRUD, filtering and pagination without a single line of manual code.

2. Installation and initial configuration

Installing API Platform 4 in an existing Symfony project happens via Composer. The package ships with the Symfony Flex recipe, which automatically creates all the necessary configuration files: the routing configuration, the API Platform bundle entry in config/bundles.php and the base configuration in config/packages/api_platform.yaml. Anyone using Doctrine additionally installs api-platform/doctrine-orm for automatic filter integration.

The base configuration in api_platform.yaml controls global settings such as the API prefix (/api is the default), enabled formats (JSON-LD, JSON, HAL and CSV are possible), pagination defaults and OpenAPI documentation. For API Platform 4, the format application/ld+json is recommended as the primary format because it carries machine-readable context information. application/json as a fallback makes the API accessible to clients without JSON-LD support too. Swagger UI and ReDoc are enabled by default and immediately deliver interactive API documentation at /api/docs.


<?php
// config/packages/api_platform.yaml -> PHP equivalent as config class (Symfony 7)
// src/ApiPlatform/ApiPlatformConfig.php

// Minimal api_platform.yaml for a new project:
// api_platform:
//   title: 'My API'
//   version: '1.0.0'
//   formats:
//     jsonld: ['application/ld+json']
//     json:   ['application/json']
//   defaults:
//     pagination_items_per_page: 30
//     pagination_maximum_items_per_page: 100

// Installation commands:
// composer require api-platform/core symfony/serializer-pack
// composer require api-platform/doctrine-orm  # only with Doctrine
// composer require nelmio/cors-bundle          # for CORS headers

// Verify installation - API docs must be accessible:
// bin/console debug:router | grep api

// Enable GraphQL (optional, separate package):
// composer require api-platform/graphql

3. Declaring API resources with PHP attributes

The heart of API Platform 4 is the #[ApiResource] attribute. Any PHP class becomes a complete API resource with it, with automatic CRUD endpoints, serialization, deserialization and OpenAPI documentation. For Doctrine entities, the attribute alone on the entity class is enough: API Platform 4 automatically detects all fields, generates the endpoints and wires up the Doctrine repository for database operations. For classes without Doctrine, you supply your own providers and processors instead.

Serialization groups control which fields are visible in which context. The attribute #[Groups(['product:read'])] on a field marks it for read operations, #[Groups(['product:write'])] for write operations. On the resource, you reference these groups with normalizationContext: ['groups' => ['product:read']]. That way, sensitive fields such as purchase prices or internal IDs are automatically hidden from the API response without having to write a custom serializer. Nested resources get their own groups to prevent circular references.


<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new GetCollection(normalizationContext: ['groups' => ['product:list']]),
        new Get(normalizationContext: ['groups' => ['product:read']]),
        new Post(denormalizationContext: ['groups' => ['product:write']]),
        new Put(denormalizationContext: ['groups' => ['product:write']]),
        new Delete(),
    ],
    normalizationContext: ['groups' => ['product:read']],
    denormalizationContext: ['groups' => ['product:write']],
)]
#[ORM\Entity]
class Product
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    #[Groups(['product:list', 'product:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['product:list', 'product:read', 'product:write'])]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    private string $name = '';

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    #[Groups(['product:read', 'product:write'])]
    #[Assert\Positive]
    private string $price = '0.00';

    // Getters and setters omitted for brevity - standard Symfony Entity pattern
    public function getId(): ?int { return $this->id; }
    public function getName(): string { return $this->name; }
    public function setName(string $name): void { $this->name = $name; }
    public function getPrice(): string { return $this->price; }
    public function setPrice(string $price): void { $this->price = $price; }
}

4. Controlling and customizing operations

In API Platform 4, every HTTP operation is its own object: Get, GetCollection, Post, Put, Patch and Delete from the ApiPlatform\Metadata namespace. Each operation can be configured individually, with its own path, serialization context, security expression, validation groups and custom state provider. That enables precise control: the list operation returns only fields for overviews, the detail view returns all fields, and the write operation validates with a different constraint group than the creation operation.

Custom endpoints that have no CRUD semantics are implemented as a Post operation with its own path and its own processor. The classic example: a /api/products/{id}/publish operation that changes a status and triggers an email. The processor receives the deserialized object, executes the business logic and returns the modified object. API Platform 4 handles serialization and the HTTP response automatically, the processor contains nothing but domain logic.

5. Filtering, sorting and pagination

Filtering and sorting are also declared in API Platform 4 via attributes, no controller, no query builder code. The SearchFilter from api-platform/doctrine-orm adds query parameters for text search: ?name=laptop automatically filters via a LIKE statement, ?category.name=electronics traverses relations. The OrderFilter allows sorting via query parameters like ?order[price]=asc. RangeFilter for numeric ranges, DateFilter for timestamps and BooleanFilter for boolean fields are also included in the package and only need to be declared.

Pagination is enabled by default in API Platform 4 and returns Hydra-compliant responses with hydra:totalItems, hydra:view and links to the first, last, next and previous page. Cursor-based pagination via CursorBasedPaginator is available for large datasets where offset pagination becomes too slow. Both approaches can be configured per resource or globally. Anyone who wants to disable pagination for a specific operation sets paginationEnabled: false directly on the operation object.


<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;

#[ApiResource(
    operations: [
        new GetCollection(
            paginationItemsPerPage: 20,
            paginationMaximumItemsPerPage: 100,
        ),
    ],
)]
// Filters declared at class level apply to all collection operations
#[ApiFilter(SearchFilter::class, properties: [
    'name'          => 'partial',   // ?name=laptop -> LIKE %laptop%
    'sku'           => 'exact',     // ?sku=ABC123  -> exact match
    'category.name' => 'ipartial',  // ?category.name=elec -> case-insensitive
])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'price', 'createdAt'])]
#[ApiFilter(RangeFilter::class, properties: ['price'])]  // ?price[gt]=10&price[lt]=100
#[ORM\Entity]
class Product
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

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

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

    // Category relation - nested filter traversal works automatically
    #[ORM\ManyToOne(targetEntity: Category::class)]
    private ?Category $category = null;
}

6. Security: access to resources and operations

The security integration of API Platform 4 is built on Symfony's security component and voter system. At the resource level, the security parameter defines a security expression that applies to all operations: "is_granted('ROLE_USER')" protects the entire resource. At the operation level, this expression can be overridden for individual endpoints, the list operation is public, the detail view requires authentication, the write access requires a specific role. This fine granularity avoids global firewall configurations and keeps access control close to the resource.

The securityPostDenormalize parameter protects operation inputs after deserialization: the incoming object is already available at that point and can be referenced in the security expression. A typical use case: "object.getOwner() == user" checks whether the logged-in user is the owner of the resource, before the database operation is executed. For complex access rules, you implement a Symfony voter that is then invoked via is_granted('EDIT', object) in the security expression. The voter class keeps the check logic cleanly separated from the API Platform configuration.


<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;

// Fine-grained security per operation - different roles per HTTP method
#[ApiResource(
    operations: [
        // Public: anyone can list and read products
        new GetCollection(security: "is_granted('PUBLIC_ACCESS')"),
        new Get(security: "is_granted('PUBLIC_ACCESS')"),

        // Authenticated users can create
        new Post(security: "is_granted('ROLE_USER')"),

        // Owner or admin can update - securityPostDenormalize has access to object
        new Put(
            security: "is_granted('ROLE_ADMIN') or object.getOwner() == user",
            securityMessage: 'Access denied: you are not the owner of this product.',
        ),

        // Only admins can delete
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
)]
class Product
{
    // ... entity fields

    public function getOwner(): ?User
    {
        return $this->owner;
    }
}

// Custom Voter for complex ownership checks - called via is_granted('EDIT', object)
// src/Security/ProductVoter.php
// class ProductVoter extends Voter { ... }

7. Enabling GraphQL out of the box

API Platform 4 ships GraphQL as an optional extra package. After installing api-platform/graphql, the GraphQL endpoint at /api/graphql is immediately active, with no further configuration. All resources declared for REST automatically appear in the GraphQL schema too. Queries for single objects and collections, mutations for creation, update and deletion, as well as subscriptions for real-time updates are all available. GraphiQL, the interactive GraphQL IDE, is accessible by default at /api/graphql.

At the operation level, GraphQL specifics are controlled via the GraphQlOperation attribute. Which queries and mutations are available, which fields appear and which serialization groups are used can be configured independently of the REST operations. That allows the scenario where the REST API is optimized for mobile clients and the GraphQL API serves the internal admin area, both interfaces share the same domain code but differ in field selection and access control. API Platform 4 manages that mapping transparently, without the team having to develop two separate APIs.

8. Custom providers and processors for business logic

The state provider is the entry point for loading data in API Platform 4. When the automatic Doctrine integration is not enough, for example because data comes from an external service or complex business rules apply when loading, you implement your own StateProviderInterface. The provider returns either a single object (for Get operations) or a paginator object (for GetCollection). Calling the built-in Doctrine provider via decoration is possible, in order to extend it with caching or transformation logic.

The state processor is the counterpart for write operations. It receives the deserialized and validated object and is responsible for persistence, event dispatching and notifications. The built-in Doctrine processor calls EntityManager::persist() and flush(), anyone decorating it can trigger their own actions before or after the flush. The subscriber pattern is clearer here than overriding the processor: the processor persists, the event subscriber sends emails. API Platform 4 resolves both through its own event system, which carries operation context, resource and HTTP method as metadata.


<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Order;
use App\Service\OrderNotificationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;

/**
 * Decorates the built-in Doctrine processor to send notifications after order creation.
 */
#[AsDecorator(decorates: 'api_platform.doctrine.orm.state.persist_processor')]
final readonly class OrderPersistProcessor implements ProcessorInterface
{
    public function __construct(
        private ProcessorInterface $inner,
        private OrderNotificationService $notificationService,
        private EntityManagerInterface $entityManager,
    ) {}

    /**
     * Persist the order and dispatch a confirmation notification.
     */
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        if (!$data instanceof Order) {
            return $this->inner->process($data, $operation, $uriVariables, $context);
        }

        // Assign order number before persistence
        $data->assignOrderNumber();

        // Delegate to the built-in Doctrine processor
        $result = $this->inner->process($data, $operation, $uriVariables, $context);

        // Send confirmation only on POST (creation), not on PUT/PATCH
        if ($operation->getMethod() === 'POST') {
            $this->notificationService->sendOrderConfirmation($result);
        }

        return $result;
    }
}

9. API Platform vs. manual implementation

A direct comparison shows where API Platform 4 saves real development time and where a manual implementation is superior. Both approaches have their place, the decision depends on project complexity, team experience and flexibility requirements.

Task Manual (Symfony) API Platform 4 Advantage
CRUD endpoints 5 controller methods per resource 1 PHP attribute 80% less code
OpenAPI documentation Manual with nelmio/api-doc-bundle Generated automatically Always current, no maintenance effort
Filtering QueryBuilder code per filter #[ApiFilter] attribute Declarative, no SQL logic
GraphQL API Separate schema + resolver Generated from REST config One resource, two protocols
Non-Doctrine data Full flexibility Custom provider needed Manual implementation faster

The table shows: API Platform 4 clearly wins for standardized CRUD APIs with Doctrine. For very custom endpoints that have hardly any REST semantics, a hand-written Symfony controller can reach the goal faster than a custom provider with API Platform context. Many projects use both approaches: API Platform 4 for the main resources, manual controllers for specific actions such as password reset or file upload.

Mironsoft

Symfony API development, API Platform integration and backend architecture

Building a REST and GraphQL API with API Platform 4?

We build scalable Symfony APIs with API Platform 4, from resource architecture through security and custom providers to complete OpenAPI and GraphQL documentation for your stack.

API architecture

Resource design, serialization groups and security strategy for scalable Symfony APIs

Custom providers

Custom state providers and processors for complex business logic and external data sources

GraphQL integration

Generate a GraphQL schema from existing REST resources and make it available to frontend teams

10. Summary

API Platform 4 in Symfony enables complete REST and GraphQL APIs through declarative PHP attributes, without manual controller code, without query builder boilerplate and without separate OpenAPI configuration. The #[ApiResource] attribute defines the resource, the #[ApiFilter] attributes declare filtering and sorting, and security expressions control access at the operation level. Custom providers and processors keep business logic cleanly separated from infrastructure and enable extensions through decoration instead of overriding.

The biggest lever lies in consistency: all resources follow the same patterns, every new resource is API-ready within minutes, and OpenAPI documentation as well as the GraphQL schema are always in sync with the implementation. For teams already using Symfony, API Platform 4 is the most direct route to a production-ready API without weeks of infrastructure work.

API Platform 4 in Symfony, the essentials at a glance

Resource declaration

#[ApiResource] on any PHP class is enough for a complete CRUD API with OpenAPI docs, no controller needed.

Filtering & pagination

#[ApiFilter] attributes declare search, sort and range filters. Pagination is enabled by default and Hydra-compliant.

GraphQL included

After installing api-platform/graphql, the GraphQL schema is available automatically, no separate schema file.

Extensibility

Extend custom providers and processors through decoration, built-in behavior stays intact, business logic gets added cleanly.

11. FAQ: API Platform 4 and Symfony REST/GraphQL

1What is API Platform 4?
A PHP framework for REST and GraphQL APIs in Symfony. Generates complete API endpoints, OpenAPI documentation and GraphQL schemas from PHP classes with attributes, no controller code needed.
2Do I have to use Doctrine?
No. Custom state providers allow any data source, external APIs, legacy systems or in-memory data. Doctrine is optional and installed as a separate package.
3Enable GraphQL?
composer require api-platform/graphql, after that /api/graphql is immediately active. All REST resources automatically appear in the GraphQL schema. GraphiQL is included.
4Security on individual operations?
Every operation has a security parameter: security: "is_granted('ROLE_ADMIN')". securityPostDenormalize checks after deserialization and has access to the incoming object.
5Provider vs. processor?
Provider loads data for GET operations. Processor handles data for write operations. Both can extend the built-in Doctrine provider through decoration.
6How does filtering work?
#[ApiFilter] declares filters on the class. SearchFilter for text, OrderFilter for sorting, RangeFilter for ranges. Nested relations like ?category.name are resolved automatically.
7Is JWT authentication possible?
Yes. API Platform is security-agnostic and works with lexik/jwt-authentication-bundle, OAuth2 and Symfony's security component. The user in the security expression refers to the logged-in JWT user.
8Disable individual operations?
Only enter the desired operations in the operations array. If Delete() is missing, there is no DELETE endpoint. Each operation is its own object and can be omitted or configured individually.
9normalizationContext vs. serialization groups?
normalizationContext specifies which groups are active when serializing. Fields with #[Groups(['product:read'])] only appear when that group is active, so the collection returns fewer fields than the detail view.
10Is API Platform 4 production ready?
Yes. Actively developed since 2015, stable version 4 supports PHP 8.2+, Symfony 6.4 and 7.x. Used in production projects worldwide. The upgrade path from version 3 is documented.