API Platform

What #[ApiResource] generates from a class, how filters and pagination work, and when a generated API is the wrong shape.

Syntax#[ApiResource(operations: [new GetCollection(), new Get()], normalizationContext: ['groups' => ['article:read']])]

API Platform is a separate bundle, not part of the framework. You add #[ApiResource] to a class and it builds the HTTP layer: routes and operations, deserialization and validation of input, serialization of output, pagination, filters, an OpenAPI document and a browsable UI served at /docs.

What you get

Each operation is an object — GetCollection, Get, Post, Patch, Delete. Listing them is how you choose which endpoints exist; leave Delete out and there is no delete route to secure. Output is shaped by the serialization groups in normalizationContext and input by denormalizationContext, so the groups you already know carry over. Collections paginate at 30 items by default, navigated with the page query parameter, and paginationItemsPerPage changes the size for one resource. Filters are declared as attributes and become documented query parameters; the filter classes and the way they are declared have changed between major versions of the bundle, so check the documentation for the version your lock file pins.

When it is the wrong abstraction

A generated API is the entity, published. That is right while the resource and the table are the same thing. It stops being right when one request spans several entities, when an operation is a workflow rather than a write — publish, refund, cancel — when permissions differ per field, or when the response must keep a shape the entity has outgrown. Then write the controller. Mixing is normal: generated reads, hand-written commands.

Example

Example · php
// src/Entity/Article.php
declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    // No Patch here, so there is no partial-update endpoint at all.
    operations: [new GetCollection(), new Get(), new Post(), new Delete()],
    normalizationContext: ['groups' => ['article:read']],
    denormalizationContext: ['groups' => ['article:write']],
    paginationItemsPerPage: 20,
)]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'partial'])]
#[ApiFilter(OrderFilter::class, properties: ['publishedAt'])]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['article:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    #[Assert\NotBlank]
    #[Groups(['article:read', 'article:write'])]
    public string $title = '';

    #[ORM\Column(nullable: true)]
    #[Groups(['article:read'])]
    public ?\DateTimeImmutable $publishedAt = null;

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

// GET /articles returns 200 with the first 20 rows and links to the next
// page; ?page=2 walks forward, ?title=rout filters, ?order[publishedAt]=desc
// sorts. GET /articles/7 returns 200, POST /articles returns 201, and a
// blank title returns 422. The same metadata drives the docs at /docs.

When to use it

  • Expose a read-only reference table as a filtered, paginated, documented endpoint in an afternoon.
  • Give an internal admin client CRUD over a handful of entities without writing controllers.
  • Keep generated read endpoints while hand-writing the two operations that have business rules.
  • Hand a front-end team an OpenAPI document that is regenerated from the code on every build.

More examples

Defaults for every resource

Resource attributes override these, so the file is where you put the policy and the attribute is where you put the exception. Two of these keys are contract decisions rather than preferences: the page parameter name appears in every client's URL, and allowing clients to choose a page size means someone will eventually ask for 10000 — cap it before that happens.

Example · yaml
# config/packages/api_platform.yaml
api_platform:
    title: 'Articles API'
    defaults:
        # Applies to every resource that does not set its own.
        pagination_items_per_page: 20
        # Let a client ask for a different size with ?itemsPerPage=50
        pagination_client_items_per_page: true
    collection:
        pagination:
            page_parameter_name: page

Exporting the generated spec

The docs at <code>/docs</code> and this file come from the same metadata, which is the real reason to accept a generated API: the documentation cannot drift from the code. Commit the exported file and re-export it in your build, and any change to an operation, a group or a filter shows up as a diff somebody has to approve.

Example · bash
php bin/console api:openapi:export --yaml --output=docs/openapi.yaml

# Writes the OpenAPI document built from the attributes. Without --output it
# prints to stdout, so it can be piped into a diff. --yaml needs symfony/yaml,
# and --spec-version=3.0.0 emits the older format for tools that need it.

The operation that is not CRUD

This is the escape hatch, and using it early is healthier than bending the generated resource. A <code>Patch</code> that sets <code>publishedAt</code> would let any client with write access publish anything, and would scatter the rules about when publishing is allowed. A named endpoint carries one permission check and one service call, and reads in the logs as the thing that happened.

Example · php
// src/Controller/Api/PublishArticleController.php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Entity\Article;
use App\Service\ArticlePublisher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class PublishArticleController extends AbstractController
{
    public function __construct(private readonly ArticlePublisher $publisher)
    {
    }

    // Publishing is a transition with its own rules, not a field write, so
    // it stays out of the generated resource and gets its own route.
    #[Route('/api/articles/{id}/publish', name: 'api_articles_publish', requirements: ['id' => '\d+'], methods: ['POST'])]
    #[IsGranted('publish', subject: 'article')]
    public function __invoke(Article $article): JsonResponse
    {
        $this->publisher->publish($article);

        return $this->json($article, Response::HTTP_OK, [], ['groups' => ['article:read']]);
    }
}

// POST /api/articles/7/publish returns 200 with the published article, 403
// when the voter refuses, and 409 when the article is published already.

Discussion

  • Be the first to comment on this lesson.