Symfony 7: API Platform and Best Practices
Complete guide to building professional REST APIs with Symfony 7 and API Platform 4. State Providers, Processors, validation, and serialization explained.

API Platform 4 radically transforms REST and GraphQL API creation with Symfony 7. This new version brings a rethought philosophy: clear separation of concerns, simplified State Providers and Processors, and native integration of the Symfony Object Mapper. Building a professional API has never been more intuitive.
Version 4.2 introduces the JSON Streamer for performance gains up to +32% RPS, a redesigned filter system, and Mutators to customize operations without touching the core. Symfony 7 and 8 support is native.
Installation and Initial Configuration
API Platform installs in a few commands with Symfony Flex. The default configuration covers most use cases while remaining fully customizable.
# terminal
# Create a new Symfony project with API Platform
composer create-project symfony/skeleton my-api
cd my-api
# Install API Platform with Doctrine ORM
composer require api
# Verify installation
php bin/console debug:router | grep apiSymfony Flex automatically configures routes, OpenAPI documentation, and the Swagger UI interface accessible at /api.
# config/packages/api_platform.yaml
api_platform:
title: 'My API'
version: '1.0.0'
# Supported response formats
formats:
jsonld: ['application/ld+json']
json: ['application/json']
# OpenAPI documentation
swagger:
versions: [3]
# Default pagination
defaults:
pagination_items_per_page: 30
pagination_maximum_items_per_page: 100This configuration defines serialization formats, global pagination, and API documentation metadata.
Creating a Simple API Resource
The #[ApiResource] attribute exposes a Doctrine entity as a REST resource. API Platform automatically generates CRUD endpoints, OpenAPI documentation, and basic validations.
<?php
// src/Entity/Book.php
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\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
// Explicit definition of available operations
operations: [
new GetCollection(),
new Post(),
new Get(),
new Put(),
new Patch(),
new Delete(),
],
// Default ordering for collections
order: ['publishedAt' => 'DESC'],
// Pagination configuration for this resource
paginationItemsPerPage: 20
)]
class Book
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Title is required')]
#[Assert\Length(min: 2, max: 255)]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
private ?string $description = null;
#[ORM\Column(length: 13, unique: true)]
#[Assert\Isbn]
private ?string $isbn = null;
#[ORM\Column]
private ?\DateTimeImmutable $publishedAt = null;
// Getters and setters...
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
public function getIsbn(): ?string
{
return $this->isbn;
}
public function setIsbn(string $isbn): static
{
$this->isbn = $isbn;
return $this;
}
public function getPublishedAt(): ?\DateTimeImmutable
{
return $this->publishedAt;
}
public function setPublishedAt(\DateTimeImmutable $publishedAt): static
{
$this->publishedAt = $publishedAt;
return $this;
}
}This entity generates six endpoints: GET /api/books, POST /api/books, GET /api/books/{id}, PUT /api/books/{id}, PATCH /api/books/{id}, and DELETE /api/books/{id}.
API Platform natively supports UUID v7 as identifiers. This approach improves security (non-predictable identifiers) and performance (natural sorting by creation date).
Serialization Groups for Controlling Exposed Data
Serialization groups precisely define which properties are exposed for reading (normalization) and writing (denormalization). This separation is essential for API security and flexibility.
<?php
// src/Entity/User.php
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\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use App\State\UserPasswordHasher;
#[ORM\Entity]
#[ORM\Table(name: '`user`')]
#[UniqueEntity('email')]
#[ApiResource(
operations: [
new GetCollection(),
// Custom processor for password hashing
new Post(processor: UserPasswordHasher::class,
validationContext: ['groups' => ['Default', 'user:create']]),
new Get(),
new Put(processor: UserPasswordHasher::class),
new Patch(processor: UserPasswordHasher::class),
new Delete(),
],
// Properties exposed when reading
normalizationContext: ['groups' => ['user:read']],
// Properties accepted when writing
denormalizationContext: ['groups' => ['user:create', 'user:update']],
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['user:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['user:read', 'user:create', 'user:update'])]
private ?string $email = null;
#[ORM\Column]
private ?string $password = null;
// Never exposed when reading, only when writing
#[Assert\NotBlank(groups: ['user:create'])]
#[Groups(['user:create', 'user:update'])]
private ?string $plainPassword = null;
#[ORM\Column(length: 100)]
#[Groups(['user:read', 'user:create', 'user:update'])]
private ?string $fullName = null;
#[ORM\Column(type: 'json')]
#[Groups(['user:read'])]
private array $roles = [];
#[ORM\Column]
#[Groups(['user:read'])]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
// UserInterface implementation
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function getPassword(): string
{
return $this->password;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
// Getters and setters...
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
public function getFullName(): ?string
{
return $this->fullName;
}
public function setFullName(string $fullName): static
{
$this->fullName = $fullName;
return $this;
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
}With this configuration, plainPassword is never exposed in responses but can be sent during creation or updates.
Ready to ace your Symfony interviews?
Practice with our interactive simulators, flashcards, and technical tests.
State Processors for Business Logic
State Processors intercept persistence operations to add business logic. API Platform 4 simplifies their creation through attribute-based dependency injection.
<?php
// src/State/UserPasswordHasher.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Processor that hashes password before persistence
* @implements ProcessorInterface<User, User>
*/
final class UserPasswordHasher implements ProcessorInterface
{
public function __construct(
// Injection of standard Doctrine processor
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private UserPasswordHasherInterface $passwordHasher,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
{
// Hash password if provided
if ($data->getPlainPassword()) {
$hashedPassword = $this->passwordHasher->hashPassword(
$data,
$data->getPlainPassword()
);
$data->setPassword($hashedPassword);
$data->eraseCredentials();
}
// Delegate persistence to standard processor
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}This composition pattern allows adding any logic (sending emails, events, logging) while preserving standard persistence behavior.
Processor with Conditional Logic
A processor can adapt its behavior based on operation type.
<?php
// src/State/BookProcessor.php
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\Service\NotificationService;
use App\Service\SearchIndexer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Book, Book|void>
*/
final class BookProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private NotificationService $notifications,
private SearchIndexer $searchIndexer,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
// Deletion: use remove processor
if ($operation instanceof DeleteOperationInterface) {
$this->searchIndexer->remove($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
// Creation: set publication date
if ($operation instanceof Post) {
$data->setPublishedAt(new \DateTimeImmutable());
}
// Standard persistence
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Post-processing: indexing and notification
$this->searchIndexer->index($result);
if ($operation instanceof Post) {
$this->notifications->notifyNewBook($result);
}
return $result;
}
}State Providers for Custom Data Sources
State Providers fetch data from any source: external APIs, cache, files, or complex business logic.
<?php
// src/State/PopularBooksProvider.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Book;
use App\Repository\BookRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Provider that returns popular books with caching
* @implements ProviderInterface<Book>
*/
final class PopularBooksProvider implements ProviderInterface
{
public function __construct(
private BookRepository $bookRepository,
private CacheInterface $cache,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
// 5-minute cache for popular books
return $this->cache->get('popular_books', function (ItemInterface $item) {
$item->expiresAfter(300);
return $this->bookRepository->findPopular(limit: 10);
});
}
}This provider is used on a dedicated operation.
<?php
// src/Entity/Book.php (excerpt)
use App\State\PopularBooksProvider;
#[ApiResource(
operations: [
// ... other operations
new GetCollection(
uriTemplate: '/books/popular',
provider: PopularBooksProvider::class,
paginationEnabled: false,
),
],
)]
class Book
{
// ...
}Advanced Validation with Dynamic Groups
API Platform natively integrates Symfony's Validator component. Validation groups can vary based on operation or context.
<?php
// src/Entity/Article.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
// Strict validation on creation
new Post(validationContext: ['groups' => ['Default', 'article:create']]),
// More lenient validation for updates
new Put(validationContext: ['groups' => ['Default', 'article:update']]),
],
)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 10, max: 255)]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
// Minimum 500 characters on creation
#[Assert\Length(min: 500, groups: ['article:create'])]
// Minimum 100 characters for updates
#[Assert\Length(min: 100, groups: ['article:update'])]
private ?string $content = null;
#[ORM\Column(length: 50)]
#[Assert\NotBlank(groups: ['article:create'])]
#[Assert\Choice(choices: ['draft', 'published', 'archived'])]
private ?string $status = 'draft';
#[ORM\Column(nullable: true)]
// Required only if status is "published"
#[Assert\NotBlank(groups: ['article:publish'])]
private ?\DateTimeImmutable $publishedAt = null;
// Getters and setters...
}Dynamic Validation with a Service
For complex validation rules, a custom group generator offers total flexibility.
<?php
// src/Validator/ArticleGroupsGenerator.php
namespace App\Validator;
use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
final class ArticleGroupsGenerator implements ValidationGroupsGeneratorInterface
{
public function __construct(
private Security $security,
) {
}
public function __invoke(object $object): array
{
assert($object instanceof Article);
$groups = ['Default'];
// Admins have fewer restrictions
if ($this->security->isGranted('ROLE_ADMIN')) {
$groups[] = 'admin';
return $groups;
}
// Additional validation if publishing
if ($object->getStatus() === 'published') {
$groups[] = 'article:publish';
}
return $groups;
}
}Complex validations can impact performance. For bulk imports, consider temporarily disabling certain validations or using asynchronous constraints.
Filters for Flexible Queries
API Platform 4.2 completely rethinks the filter system with clear separation of concerns. Filters allow API clients to search and sort data.
<?php
// src/Entity/Product.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ApiResource]
// Text search on name and description
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'partial', // LIKE %value%
'description' => 'partial',
'category.name' => 'exact', // Search on relation
'sku' => 'exact', // Exact match
])]
// Range filtering
#[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])]
// Date filtering
#[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
// Boolean filtering
#[ApiFilter(BooleanFilter::class, properties: ['isActive', 'isFeatured'])]
// Customizable sorting
#[ApiFilter(OrderFilter::class, properties: [
'name',
'price',
'createdAt',
], arguments: ['orderParameterName' => 'sort'])]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 50, unique: true)]
private ?string $sku = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private ?string $price = null;
#[ORM\Column]
private ?int $stock = null;
#[ORM\Column]
private ?bool $isActive = true;
#[ORM\Column]
private ?bool $isFeatured = false;
#[ORM\ManyToOne(targetEntity: Category::class)]
private ?Category $category = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
// Getters and setters...
}These filters automatically generate OpenAPI documentation and enable queries like:
GET /api/products?name=phone&price[gte]=100&price[lte]=500&isActive=true&sort[price]=ascRelations and Subresources
API Platform elegantly handles entity relations with serialization options and subresources.
<?php
// src/Entity/Author.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity]
#[ApiResource(
normalizationContext: ['groups' => ['author:read']],
)]
class Author
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['author:read', 'book:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['author:read', 'book:read'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['author:read'])]
private ?string $biography = null;
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Book::class)]
#[Groups(['author:read'])]
private Collection $books;
public function __construct()
{
$this->books = new ArrayCollection();
}
// Getters and setters...
}<?php
// src/Entity/Book.php (with relation)
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity]
#[ApiResource(
normalizationContext: ['groups' => ['book:read']],
)]
// Subresource: GET /api/authors/{authorId}/books
#[ApiResource(
uriTemplate: '/authors/{authorId}/books',
operations: [new GetCollection()],
uriVariables: [
'authorId' => new Link(
fromProperty: 'books',
fromClass: Author::class
),
],
normalizationContext: ['groups' => ['book:read']],
)]
class Book
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['book:read', 'author:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['book:read', 'author:read'])]
private ?string $title = null;
#[ORM\ManyToOne(targetEntity: Author::class, inversedBy: 'books')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['book:read'])]
private ?Author $author = null;
// Getters and setters...
}Security and Access Control
API Platform integrates seamlessly with Symfony's security system. Voters and security expressions control resource access.
<?php
// src/Entity/Order.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ApiResource(
operations: [
// List: authenticated users see their orders
new GetCollection(
security: "is_granted('ROLE_USER')",
// Automatic filter by connected user
provider: UserOrdersProvider::class,
),
// Creation: any authenticated user
new Post(
security: "is_granted('ROLE_USER')",
processor: CreateOrderProcessor::class,
),
// Read: owner or admin
new Get(
security: "is_granted('ROLE_ADMIN') or object.getCustomer() == user",
securityMessage: "Access denied to this order.",
),
// Modification: admin only
new Patch(
security: "is_granted('ROLE_ADMIN')",
),
// Deletion: admin only
new Delete(
security: "is_granted('ROLE_ADMIN')",
),
],
)]
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $customer = null;
#[ORM\Column(length: 50)]
private ?string $status = 'pending';
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private ?string $total = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
// Getters...
public function getCustomer(): ?User
{
return $this->customer;
}
}The expression object.getCustomer() == user provides access to the current entity and connected user for fine-grained checks.
Automated API Testing
API Platform provides PHPUnit traits for easily testing endpoints.
<?php
// tests/Api/BookTest.php
namespace App\Tests\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Book;
use App\Factory\BookFactory;
use App\Factory\UserFactory;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class BookTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
public function testGetCollection(): void
{
// Create test data
BookFactory::createMany(30);
// GET request on collection
$response = static::createClient()->request('GET', '/api/books');
// Assertions
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'@context' => '/api/contexts/Book',
'@type' => 'Collection',
'totalItems' => 30,
]);
// Verify pagination (20 items per page)
$this->assertCount(20, $response->toArray()['member']);
}
public function testCreateBook(): void
{
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
static::createClient()->request('POST', '/api/books', [
'auth_bearer' => $this->getToken($user),
'json' => [
'title' => 'Clean Code',
'description' => 'A Handbook of Agile Software Craftsmanship',
'isbn' => '9780132350884',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'@type' => 'Book',
'title' => 'Clean Code',
]);
}
public function testCreateBookValidationFails(): void
{
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
static::createClient()->request('POST', '/api/books', [
'auth_bearer' => $this->getToken($user),
'json' => [
'title' => '', // Empty title = error
'isbn' => 'invalid-isbn',
],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'@type' => 'ConstraintViolationList',
'violations' => [
['propertyPath' => 'title', 'message' => 'Title is required'],
],
]);
}
private function getToken(object $user): string
{
// Implementation depends on your authentication system
return 'test_token';
}
}Conclusion
API Platform 4 with Symfony 7 represents the state of the art for creating professional REST APIs in PHP. The clear separation between State Providers (reading) and State Processors (writing), combined with serialization groups and the validation system, enables building robust and maintainable APIs.
Checklist for Quality APIs
- ✅ Use distinct serialization groups for reading and writing
- ✅ Implement State Processors for business logic (password hashing, notifications)
- ✅ Configure filters for searching and sorting
- ✅ Apply per-operation validations with groups
- ✅ Secure endpoints with security expressions
- ✅ Write functional tests for each endpoint
- ✅ Document the API via OpenAPI metadata
Start practicing!
Test your knowledge with our interview simulators and technical tests.
API Platform 4's philosophy encourages composition over inheritance, and configuration over convention. The result: scalable, testable APIs that conform to REST/JSON-LD standards, production-ready from day one.
Tags
Share
Related articles

Doctrine ORM: Mastering Relationships in Symfony
Complete guide to Doctrine ORM relationships in Symfony. OneToMany, ManyToMany, loading strategies, and performance optimization with practical examples.

Symfony Interview Questions: Top 25 in 2026
The 25 most asked Symfony interview questions. Architecture, Doctrine ORM, services, security, forms, and testing with detailed answers and code examples.