API Platform Symfony REST: Complete Tutorial and Interview Questions 2026

Build production-ready REST APIs with API Platform 4 and Symfony 7. Learn State Providers, Processors, filters, and master the most common interview questions about API Platform.

API Platform Symfony REST API tutorial with PHP code

API Platform 4.3 transforms Symfony into a REST API powerhouse with automatic OpenAPI documentation, content negotiation, and a clean architecture separating read and write operations. This tutorial covers setup, advanced patterns, and the interview questions that separate senior candidates from juniors.

API Platform 4 Architecture

API Platform 4 uses State Providers for GET operations and State Processors for POST/PUT/PATCH/DELETE. This separation aligns with CQRS principles and makes testing straightforward.

Installing API Platform on Symfony 7

API Platform installation requires Symfony 7.2 or higher. The bundle integrates with Doctrine ORM by default but supports custom data sources through the Provider/Processor pattern.

bash
# Install API Platform with Symfony Flex
composer require api

# Verify installation
php bin/console debug:router | grep api

The api recipe installs api-platform/symfony along with the serializer, validator, and property-access components. Symfony Flex configures routes automatically under /api.

yaml
# config/packages/api_platform.yaml
api_platform:
    title: 'My API'
    version: '1.0.0'
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
    docs_formats:
        jsonld: ['application/ld+json']
        jsonopenapi: ['application/vnd.openapi+json']
        html: ['text/html']
    defaults:
        stateless: true
        cache_headers:
            vary: ['Content-Type', 'Authorization', 'Accept-Language']

The stateless: true setting disables PHP sessions for all API endpoints, reducing memory overhead and enabling horizontal scaling.

Creating Your First API Resource with Attributes

API Platform 4 uses PHP 8 attributes to declare API resources. Each entity becomes an API endpoint through the #[ApiResource] attribute.

src/Entity/Product.phpphp
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\Validator\Constraints as Assert;

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

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(min: 3, max: 255)]
    private string $name;

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

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

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

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;
        return $this;
    }

    public function getPrice(): string
    {
        return $this->price;
    }

    public function setPrice(string $price): static
    {
        $this->price = $price;
        return $this;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }
}

The security parameter on each operation leverages Symfony's expression language. The is_granted() function checks voter decisions, enabling role-based access control without custom controllers.

Serialization Groups for Response Shaping

Serialization groups control which properties appear in API responses. Different operations can expose different fields from the same entity.

src/Entity/User.phpphp
namespace App\Entity;

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\Serializer\Annotation\Groups;

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

    #[ORM\Column(length: 180)]
    #[Groups(['user:list', 'user:read', 'user:write'])]
    private string $email;

    #[ORM\Column]
    #[Groups(['user:write'])]
    private string $password;

    #[ORM\Column]
    #[Groups(['user:read'])]
    private \DateTimeImmutable $registeredAt;

    #[ORM\Column(type: 'json')]
    #[Groups(['user:read'])]
    private array $roles = [];
}

The normalizationContext controls output (PHP to JSON). The denormalizationContext controls input (JSON to PHP). The password field uses user:write only, preventing it from ever appearing in responses.

State Providers for Custom Data Sources

State Providers fetch data for GET operations. The default ItemProvider and CollectionProvider use Doctrine, but custom providers enable any data source: Elasticsearch, external APIs, or computed values.

src/State/ProductStatsProvider.phpphp
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\ProductRepository;
use App\Dto\ProductStats;

final readonly class ProductStatsProvider implements ProviderInterface
{
    public function __construct(
        private ProductRepository $productRepository
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ProductStats
    {
        $totalProducts = $this->productRepository->count([]);
        $averagePrice = $this->productRepository->getAveragePrice();
        $lowStockCount = $this->productRepository->countLowStock(threshold: 10);

        return new ProductStats(
            totalProducts: $totalProducts,
            averagePrice: $averagePrice,
            lowStockCount: $lowStockCount
        );
    }
}
src/Dto/ProductStats.phpphp
namespace App\Dto;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\ProductStatsProvider;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/products/stats',
            provider: ProductStatsProvider::class
        )
    ]
)]
final readonly class ProductStats
{
    public function __construct(
        public int $totalProducts,
        public float $averagePrice,
        public int $lowStockCount
    ) {}
}

The ProductStats DTO (Data Transfer Object) is not a Doctrine entity. It represents computed data exposed at /api/products/stats. The provider calculates values on each request.

Ready to ace your Symfony interviews?

Practice with our interactive simulators, flashcards, and technical tests.

State Processors for Write Operations

State Processors handle POST, PUT, PATCH, and DELETE operations. Custom processors enable business logic execution before or after persistence.

src/State/UserRegistrationProcessor.phpphp
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final readonly class UserRegistrationProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private UserPasswordHasherInterface $passwordHasher
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): User {
        // $data is the deserialized User entity from the request body
        $hashedPassword = $this->passwordHasher->hashPassword(
            $data,
            $data->getPlainPassword()
        );
        $data->setPassword($hashedPassword);
        $data->eraseCredentials();

        $this->entityManager->persist($data);
        $this->entityManager->flush();

        return $data;
    }
}

Attach the processor to the POST operation on the User entity:

php
#[ApiResource(
    operations: [
        new Post(
            processor: UserRegistrationProcessor::class,
            denormalizationContext: ['groups' => ['user:create']]
        )
    ]
)]

The processor hashes the password before Doctrine persists the entity. The plainPassword property uses a write-only serialization group and is never stored in the database.

Filters for Query Parameters

API Platform provides built-in filters for searching, ordering, and filtering collections. Filters add query parameters to GET collection endpoints.

src/Entity/Article.phpphp
namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;

#[ORM\Entity]
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: [
    'title' => 'partial',
    'author.name' => 'exact',
    'category' => 'exact'
])]
#[ApiFilter(DateFilter::class, properties: ['publishedAt'])]
#[ApiFilter(OrderFilter::class, properties: ['publishedAt', 'title'])]
class Article
{
    // Entity properties...
}

The filter configuration enables these query parameters:

  • GET /api/articles?title=symfony (partial match on title)
  • GET /api/articles?author.name=John (exact match on related entity)
  • GET /api/articles?publishedAt[after]=2026-01-01 (date range)
  • GET /api/articles?order[publishedAt]=desc (sorting)

Custom filters extend AbstractFilter for complex query logic that built-in filters cannot express.

Error Handling and Validation

API Platform integrates with Symfony Validator. Validation errors return 422 Unprocessable Entity with RFC 7807 problem details.

src/Entity/Order.phpphp
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource]
class Order
{
    #[ORM\Column]
    #[Assert\NotBlank(message: 'Order quantity is required')]
    #[Assert\Positive(message: 'Quantity must be greater than zero')]
    #[Assert\LessThanOrEqual(value: 100, message: 'Maximum 100 items per order')]
    private int $quantity;
}

A validation failure returns:

json
{
    "@type": "ConstraintViolationList",
    "status": 422,
    "violations": [
        {
            "propertyPath": "quantity",
            "message": "Quantity must be greater than zero"
        }
    ]
}

Custom validators with class-level constraints handle cross-field validation, such as ensuring an order's end date comes after its start date.

Interview Questions: API Platform Deep Knowledge

Technical interviews probe understanding beyond basic usage. These questions appear frequently for Symfony backend positions.

Q: How does API Platform differ from writing controllers manually?

API Platform generates CRUD operations from entity metadata. A single #[ApiResource] attribute produces GET, POST, PUT, PATCH, and DELETE endpoints with OpenAPI documentation, content negotiation, pagination, and validation. Manual controllers require implementing each feature separately. API Platform reduces boilerplate by 70-80% for standard REST operations while remaining extensible for custom logic through State Providers and Processors.

Q: Explain the difference between State Providers and State Processors.

State Providers handle data retrieval (GET operations). They implement ProviderInterface::provide() and return entities, DTOs, or collections. State Processors handle data mutation (POST, PUT, PATCH, DELETE). They implement ProcessorInterface::process() and receive the deserialized object from the request body. This separation follows CQRS principles: reads and writes have distinct code paths.

Q: How do you prevent exposing sensitive fields in API responses?

Serialization groups control field visibility. Assign sensitive fields (passwords, internal IDs, audit data) to write-only groups or exclude them entirely. Use #[Groups(['admin:read'])] for fields only administrators should see, then configure operation-level normalizationContext to include that group only for admin endpoints.

Q: How does pagination work in API Platform?

API Platform paginates collections by default with 30 items per page. The page query parameter controls offset. Hydra metadata in JSON-LD responses includes hydra:view with first/last/next/previous links. Configure via paginationItemsPerPage, paginationMaximumItemsPerPage, and paginationClientItemsPerPage (allows clients to request different page sizes).

Q: When would you use a DTO instead of exposing the entity directly?

DTOs decouple the API contract from the database schema. Use DTOs when: (1) the API representation differs significantly from the entity structure, (2) multiple entities combine into one response, (3) computed fields appear in responses, (4) input validation differs from entity constraints, or (5) the entity uses Doctrine inheritance that complicates serialization. DTOs also prevent accidental exposure of new entity fields when the schema changes.

Practice these questions with API Platform interview challenges to reinforce understanding.

Testing API Platform Endpoints

API Platform works with Symfony's test framework. ApiTestCase provides methods for making authenticated requests and asserting JSON responses.

tests/Api/ProductTest.phpphp
namespace App\Tests\Api;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Product;

class ProductTest extends ApiTestCase
{
    public function testGetCollection(): void
    {
        $response = static::createClient()->request('GET', '/api/products');

        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
        $this->assertJsonContains(['@context' => '/api/contexts/Product']);
        $this->assertMatchesResourceCollectionJsonSchema(Product::class);
    }

    public function testCreateProduct(): void
    {
        $response = static::createClient()->request('POST', '/api/products', [
            'json' => [
                'name' => 'Test Product',
                'price' => '29.99'
            ],
            'headers' => [
                'Authorization' => 'Bearer ' . $this->getAdminToken()
            ]
        ]);

        $this->assertResponseStatusCodeSame(201);
        $this->assertJsonContains([
            'name' => 'Test Product',
            'price' => '29.99'
        ]);
    }

    public function testCreateProductValidationFails(): void
    {
        static::createClient()->request('POST', '/api/products', [
            'json' => ['name' => 'AB'],  // Too short
            'headers' => ['Authorization' => 'Bearer ' . $this->getAdminToken()]
        ]);

        $this->assertResponseStatusCodeSame(422);
        $this->assertJsonContains([
            'violations' => [
                ['propertyPath' => 'name']
            ]
        ]);
    }
}

The assertMatchesResourceCollectionJsonSchema() method validates responses against the automatically generated JSON Schema, catching regressions when entity structure changes.

Performance Optimization with Eager Loading

N+1 queries degrade collection endpoint performance. API Platform's fetchEager option and Doctrine's query hints solve this.

php
#[ApiResource(
    operations: [
        new GetCollection(
            extraProperties: ['doctrine_orm_fetch_join' => true]
        )
    ]
)]
#[ORM\Entity]
class Order
{
    #[ORM\ManyToOne(fetch: 'EAGER')]
    #[ORM\JoinColumn(nullable: false)]
    private Customer $customer;

    #[ORM\OneToMany(mappedBy: 'order', fetch: 'EAGER')]
    private Collection $items;
}

For complex queries, custom State Providers with optimized DQL outperform automatic eager loading. Measure query counts with Symfony Profiler before and after optimization.

Production-Ready API Platform Configuration

yaml
# config/packages/api_platform.yaml
api_platform:
    title: '%env(API_TITLE)%'
    version: '%env(API_VERSION)%'
    show_webby: false
    
    defaults:
        stateless: true
        cache_headers:
            max_age: 3600
            shared_max_age: 3600
            vary: ['Content-Type', 'Authorization']
        extra_properties:
            standard_put: true
    
    exception_to_status:
        Symfony\Component\Security\Core\Exception\AccessDeniedException: 403
        App\Exception\BusinessException: 400

Disable show_webby (the mascot) in production. The standard_put property ensures PUT replaces resources entirely rather than merging, following RFC 7231 semantics.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

What to Remember About API Platform in 2026

  • State Providers fetch data for GET requests. State Processors handle POST/PUT/PATCH/DELETE. This separation enables clean testing and custom data sources.
  • Serialization groups control which fields appear in responses. Use different groups for list views versus detail views, and write-only groups for passwords.
  • Filters add query parameters for searching, ordering, and date ranges. Built-in filters cover most cases, and custom filters handle complex queries.
  • DTOs decouple API contracts from database schemas. Use them when the response structure differs from the entity or when combining data from multiple sources.
  • The Symfony Serializer handles JSON-to-entity conversion. Understanding its normalizers and context options is essential for advanced API Platform usage.
  • API Platform 4.3 is the current stable release. Version 5.0 (in alpha) requires Symfony 7.4 or 8.0 and drops support for older Symfony versions.
  • Documentation at api-platform.com covers edge cases not addressed here. The Doctrine ORM integration documentation explains entity relationship handling in detail.
Daily challenge

Can you spot the bug in Symfony?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on August 25, 2026

Tags

#api-platform
#symfony
#rest-api
#php
#tutorial

Share

Related articles