API Platform
Writing controllers by hand for every entity — a list, a single record, create, update, delete — is repetitive work. API Platform builds such an API from Doctrine entities together with documentation, validation and pagination.
Installation
composer require apiA resource
Marking an entity with an attribute is enough:
use ApiPlatform\Metadata\ApiResource;
#[ORM\Entity]
#[ApiResource]
class Post
{
// properties and methods
}Five addresses appear:
| Method and address | Action |
|---|---|
GET /api/posts | The list |
POST /api/posts | Creating |
GET /api/posts/{id} | A single record |
PATCH /api/posts/{id} | Updating |
DELETE /api/posts/{id} | Deleting |
The /api address opens documentation where every request can be run right in the browser.
Only the needed operations
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post as PostOperation;
#[ApiResource(operations: [
new GetCollection(),
new Get(),
new PostOperation(),
])]
class Post
{
// …
}Updating and deleting through the API are no longer available.
Which fields to return and accept
Serialization groups separate reading from writing:
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['post:read']],
denormalizationContext: ['groups' => ['post:write']],
)]
class Post
{
#[Groups(['post:read'])]
private ?int $id = null;
#[Groups(['post:read', 'post:write'])]
#[Assert\NotBlank]
private ?string $title = null;
#[Groups(['post:read'])]
private ?\DateTimeImmutable $createdAt = null;
}The client sees the identifier and the date but cannot change them. Assert constraints are checked automatically: invalid data yields a 422 response.
Pagination
#[ApiResource(paginationItemsPerPage: 20)]The client asks for pages with the ?page=2 parameter.
Filters
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'partial', 'author' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt'])]
class Post
{
// …
}Now the /api/posts?title=php and /api/posts?order[createdAt]=desc requests work.
Access rules
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch;
#[ApiResource(operations: [
new GetCollection(),
new Get(),
new PostOperation(security: "is_granted('ROLE_USER')"),
new Patch(security: "object.getAuthor() == user"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
])]In the expression object is the requested record and user is the current user.
Your own logic
Actions on saving — setting the author, sending an email — are written in a state processor class attached to an operation with the processor parameter. Data that does not come from the database is served by a state provider.
Common mistake
A bare #[ApiResource] attribute opens every operation and every property of the entity to any visitor — including deletion and fields like a password. For each resource set the list of operations, the serialization groups and the security rules, and then check in the /api documentation what a guest can see.