API Platform for Symfony: Building REST and GraphQL APIs That Scale

#API Platform Symfony
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Every Symfony team building a data-driven SaaS eventually arrives at the same crossroads. You have twelve Doctrine entities, a growing list of React frontend requirements, and a junior developer who just added the fourth hand-rolled pagination implementation to the codebase — each one slightly different from the last. Filters are scattered across controller queries. OpenAPI documentation is perpetually out of date. And the GraphQL endpoint your mobile team asked for three months ago is still an open issue.

This is the moment API Platform stops being a framework curiosity and becomes an engineering decision.

What API Platform Actually Gives You (Beyond the Tutorial)

The official documentation sells API Platform as a tool for "creating hypermedia-driven REST and GraphQL APIs." That is accurate but undersells what drops out automatically once you annotate a class with #[ApiResource].

Out of the box, a single attribute gives you: full CRUD operations on the resource, JSON-LD and Hydra serialisation, a live OpenAPI 3.1 spec at /api/docs, pagination with configurable page sizes, a filter system covering exact match, range, date, search, and order, Symfony Validator integration, event-driven extension points via state processors and providers, and an opt-in GraphQL endpoint that covers your entire API surface.

That is not a tutorial simplification — it is production-grade infrastructure you do not have to write. For a typical B2B SaaS with fifteen to thirty resources, that represents weeks of work your team can spend on product differentiation instead.

#[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: 25,
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', 'status' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name'])]
class Organisation
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    public int $id;

    #[ORM\Column]
    #[Assert\NotBlank]
    public string $name;

    #[ORM\Column(enumType: OrganisationStatus::class)]
    public OrganisationStatus $status = OrganisationStatus::Active;

    #[ORM\Column]
    public \DateTimeImmutable $createdAt;
}

Forty lines of annotated entity, and you have a filtered, paginated, documented REST API with role-based access control. The OpenAPI spec updates automatically. Your frontend team can import it into Postman immediately.

GraphQL Without a Rewrite

Enabling GraphQL in API Platform requires one package and one config flag:

composer require webonyx/graphql-php
# config/packages/api_platform.yaml
api_platform:
    graphql:
        enabled: true
        graphiql:
            enabled: '%kernel.debug%'

Every #[ApiResource] class is immediately available as a GraphQL type with queries, mutations, and cursor-based pagination. The schema is derived from your existing serialisation groups, which means you are not maintaining two separate API definitions.

Where most teams hit trouble is with N+1 queries — a query for a list of orders that includes nested customer data can fire a database query per row. API Platform exposes this through Doctrine's EagerLoadingExtension, but the right fix is usually a custom state provider with a pre-fetching strategy for complex reads:

final class OrderCollectionProvider implements ProviderInterface
{
    public function __construct(
        private readonly OrderRepository $repository,
        private readonly CustomerRepository $customerRepository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
    {
        $orders = $this->repository->findWithFilters($context['filters'] ?? []);
        
        // Batch-load related data
        $customerIds = array_unique(array_map(fn($o) => $o->customerId, $orders));
        $customers = $this->customerRepository->findByIds($customerIds);
        
        return array_map(
            fn($order) => $order->withCustomer($customers[$order->customerId]),
            $orders
        );
    }
}

Attaching this provider to a resource with provider: OrderCollectionProvider::class gives you full control over the query strategy without giving up the API Platform machinery around it.

Security, DTOs, and the Patterns the Documentation Glosses Over

The built-in security attribute on operations handles simple role checks well. For object-level access control — where a user can only read their own organisation's data — you need Symfony's Voter system combined with securityPostDenormalize on write operations:

#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('ROLE_USER')",
            provider: UserScopedCollectionProvider::class,
        ),
        new Put(
            security: "is_granted('ROLE_USER')",
            securityPostDenormalize: "is_granted('EDIT', object)",
        ),
    ],
)]

The securityPostDenormalize runs after the incoming data has been applied to the object, which is when your Voter has access to the fully hydrated entity to check ownership. This is the pattern you want for any multi-tenant SaaS — the voter becomes the single enforcement point regardless of whether the request comes in over REST or GraphQL.

For write operations with complex validation rules or fields that should not be exposed at the entity level, DTOs decouple the API surface from the Doctrine model:

final class CreateInvitationInput
{
    #[Assert\Email]
    #[Assert\NotBlank]
    public string $email;

    #[Assert\Choice(['admin', 'member', 'viewer'])]
    public string $role = 'member';
}

#[Post(
    uriTemplate: '/organisations/{id}/invitations',
    input: CreateInvitationInput::class,
    processor: InvitationProcessor::class,
)]

The processor receives the validated DTO and handles the business logic: creating the invitation record, dispatching the email via Messenger, returning the appropriate response. Your entity never exposes fields like invitationToken or expiresAt to the API layer.

Performance Patterns for APIs Under Real Load

API Platform works well out of the box up to a few hundred concurrent users. Beyond that, two areas require deliberate attention.

HTTP caching. API Platform emits correct Cache-Control and Vary headers by default. Placing a reverse proxy like Varnish or Cloudflare in front multiplies read capacity by orders of magnitude. The #[ApiResource] attribute accepts cacheHeaders for fine-grained control per resource, and the built-in Varnish integration supports cache invalidation via Cache-Tags when an entity is written.

Database query control. Doctrine's QueryExtension interface is where you enforce multi-tenancy at the query layer — every collection endpoint automatically appended with a WHERE organisation_id = :current_org clause:

final class CurrentOrganisationExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    public function applyToCollection(QueryBuilder $qb, QueryNameGeneratorInterface $generator, string $resourceClass, Operation $operation = null, array $context = []): void
    {
        $this->addWhere($qb, $resourceClass);
    }

    private function addWhere(QueryBuilder $qb, string $resourceClass): void
    {
        if (!in_array(BelongsToOrganisation::class, class_implements($resourceClass), true)) {
            return;
        }

        $alias = $qb->getRootAliases()[0];
        $org = $this->security->getUser()?->getOrganisation();
        $qb->andWhere("{$alias}.organisation = :org")->setParameter('org', $org);
    }
}

Implementing a BelongsToOrganisation marker interface on all tenant-scoped entities and registering this single extension covers every collection and item endpoint across your entire API. It is one of those patterns that makes a code quality audit genuinely satisfying to recommend — because it replaces dozens of scattered WHERE clauses with a single enforced policy.

When API Platform Is the Wrong Choice

API Platform is not universally the right answer. For pure write-heavy event ingestion endpoints — high-frequency webhook receivers, analytics collection, bulk import pipelines — the framework overhead per request adds latency you do not need. A thin Symfony controller that validates, dispatches a Messenger message, and returns a 202 will outperform API Platform on those paths.

Similarly, if your API layer is primarily orchestrating calls to external services rather than reading and writing Doctrine entities, the resource-centric model is a poor fit. API Platform shines when your API surface maps reasonably onto your domain model. When it does not, a custom software development approach with purpose-built controllers is the pragmatic choice.

The Practical Calculus

For most Symfony applications with a standard CRUD-heavy API surface, API Platform is the correct default. It eliminates weeks of boilerplate, keeps your OpenAPI documentation honest, and gives GraphQL for free when you need it. The learning curve is concentrated in three areas — custom state providers for complex reads, DTO-backed inputs for complex writes, and query extensions for multi-tenancy — and each has a clear, repeatable pattern once you have done it once.

Teams that build on API Platform also find that code reviews become more consistent: there is one canonical way to add a filter, one canonical way to add an operation-level security check, one canonical way to paginate. That consistency has compounding value as the team grows.

If your Symfony API is growing past the point where manual controllers feel manageable, API Platform is worth the investment to evaluate properly. Reach out to us at hello@wolf-tech.io or visit wolf-tech.io — we run API Platform adoption assessments for teams navigating exactly this transition.