API Platform State Provider and State Processor: Custom Data Sources
AI generated
SF
{ }
Symfony · API Platform · State Provider · PHP 8.4
API Platform State Provider and State Processor
connecting custom data sources beyond Doctrine

Anyone who thinks API Platform only works with Doctrine entities has not yet met the State Provider and the State Processor. These two interfaces fully decouple reading and writing from the persistence layer and let you expose REST endpoints, legacy systems, or purely computed values as first class API resources.

18 min read ProviderInterface · ProcessorInterface · Context · Operation API Platform 4 · Symfony 7 · PHP 8.4

1. Why you need a State Provider and a State Processor

By default, API Platform maps Doctrine ORM entities directly to REST and GraphQL endpoints. That works well as long as a resource truly corresponds to a database table. But as soon as an endpoint merges data from several sources, calls an external service, or returns purely computed values, the default path is no longer enough. This is exactly where the State Provider comes in: it replaces the implicit Doctrine access with a custom class that decides how a resource is loaded.

The State Processor mirrors this on the write side. After a request has been deserialized and validated, API Platform hands the object to the State Processor, which is free to decide where the data goes: into a database, onto a queue, to an external system, or to several targets at once. This separation of transport format and persistence is the core reason why a State Provider and a State Processor become practically unavoidable in advanced API Platform projects.

It is important to note: a State Provider is not a replacement for a service, it is a thin adapter layer. The actual business logic stays in domain services, and the State Provider only calls them and shapes the result into an API resource object. Anyone who consistently keeps this separation ends up with testable, replaceable building blocks instead of a monolithic controller full of API Platform annotations.

2. The ProviderInterface: custom read access

A State Provider implements ProviderInterface with exactly one method: provide(Operation $operation, array $uriVariables = [], array $context = []). For a collection operation, the method returns an iterable array or a Paginator object, for an item operation a single object or null when the resource was not found. API Platform then automatically takes care of serialization, status code, and content negotiation, the State Provider does not have to worry about any of that.

Registration happens through the provider attribute directly on the resource class or per operation, which allows fine grained control: a collection operation can use a different State Provider than the item operation of the same resource. That is especially useful when lists should come from a cache while single item lookups always need to be loaded fresh from an external API.


<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WeatherStation;
use App\Service\WeatherFeedClient;

/**
 * Custom state provider that loads weather station data
 * from an external feed instead of Doctrine.
 */
final readonly class WeatherStationProvider implements ProviderInterface
{
    public function __construct(
        private WeatherFeedClient $client,
    ) {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|object|null
    {
        // Item operation: single station requested by id
        if (isset($uriVariables['id'])) {
            $data = $this->client->fetchStation($uriVariables['id']);

            return $data === null ? null : WeatherStation::fromFeed($data);
        }

        // Collection operation: return all active stations
        return array_map(
            static fn (array $row): WeatherStation => WeatherStation::fromFeed($row),
            $this->client->fetchAllStations(),
        );
    }
}

3. The ProcessorInterface: write logic after validation

The State Processor implements ProcessorInterface with the method process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []). The $data parameter already contains the deserialized and validated object, actual validation is complete by this point. The State Processor only decides what happens to the data next: persist it, hand it to a message bus, or transform it into another format before it goes to a third party system.

A common pattern is combining a State Processor with Symfony Messenger: the processor no longer validates anything itself, it simply creates a command that is processed asynchronously and immediately returns a confirmation with status code 202. That keeps the API call fast while the actual processing happens in the background, without the client having to wait.


<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\OrderExport;
use Symfony\Component\Messenger\MessageBusInterface;

/**
 * State processor that dispatches order exports asynchronously
 * instead of writing directly to Doctrine.
 */
final readonly class OrderExportProcessor implements ProcessorInterface
{
    public function __construct(
        private MessageBusInterface $bus,
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderExport
    {
        // Validated DTO is dispatched as a message, not persisted directly
        $this->bus->dispatch(new RequestOrderExport(
            orderId: $data->orderId,
            format: $data->format,
        ));

        $data->status = 'queued';

        return $data;
    }
}

4. Connecting non Doctrine data sources

The practical benefit of a State Provider shows up most clearly with non Doctrine data sources. Classic examples are a legacy SOAP service, an in memory configuration, a Redis hash, or an external REST API that should be mirrored outward as its own API Platform resource. In all these cases the API resource stays a plain PHP object with attributes, while the State Provider handles the actual retrieval.

It is important that the API resource itself no longer needs Doctrine annotations. It is declared as a plain PHP object with the attribute #[ApiResource(provider: WeatherStationProvider::class)]. That fully decouples the public API shape from the internal storage format, so the data source can later be swapped without the published contract changing at all.

This is especially valuable during migrations: a team can gradually replace an old database table with a new microservice without API consumers noticing anything, as long as the State Provider hides the new source transparently behind the same resource.

5. Reading the Context and Operation object correctly

The $context array passed to every State Provider and State Processor carries valuable metadata: the current request, applied filters, the security context, and for GraphQL additionally information about the requested fields. A State Provider can use that, for instance, to only load the fields from an external API that were actually queried, avoiding unnecessary network calls.

The Operation object provides, among other things, the operation name, configured extra properties, and the associated resource class. This information lets you reuse a single generic State Provider for several resources, as long as the relevant differences are configured through extra properties on the #[ApiResource] attribute instead of writing a separate class for every resource.

6. Combining multiple providers with the decorator pattern

In larger projects, several responsibilities need to be combined: caching, access checks, and the actual data retrieval. Instead of bundling all of that into one class, a State Provider can be decorated. A caching State Provider calls the inner, actual State Provider only on a cache miss and stores its result. Symfony's dependency injection supports this directly on the PHP class through #[AsDecorator].

This pattern keeps each individual State Provider class small and independently testable, while cross cutting concerns like caching or logging can be added or removed freely without touching the actual data retrieval logic. It is the same principle as middleware, just applied at the level of API Platform providers.


<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Contracts\Cache\CacheInterface;

/**
 * Decorates the inner provider with a short lived cache layer.
 */
#[AsDecorator(decorates: WeatherStationProvider::class)]
final readonly class CachedWeatherStationProvider implements ProviderInterface
{
    public function __construct(
        private ProviderInterface $inner,
        private CacheInterface $cache,
    ) {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|object|null
    {
        $key = 'weather_station_' . ($uriVariables['id'] ?? 'collection');

        return $this->cache->get($key, function () use ($operation, $uriVariables, $context) {
            return $this->inner->provide($operation, $uriVariables, $context);
        });
    }
}

7. Testing State Providers and Processors

Because a State Provider is a normal Symfony service, it can be tested entirely without the HTTP kernel. A plain PHPUnit test instantiates the class with a test double for the dependent data source and calls provide() directly. That is significantly faster than a functional test through the kernel and covers the actual logic in isolation.

For the complete path including serialization and status codes, a functional test with ApiTestCase still makes sense, verifying that the registered State Provider is actually invoked and that the HTTP response looks correct. Both testing levels complement each other: the unit test for the logic inside the State Provider, the functional test for its integration into API Platform.

8. Error handling and performance

A State Provider should never silently return null when there is actually an error in the data source. API Platform interprets null on an item operation as a 404, which is misleading for a network failure against an external source. Instead, a dedicated exception should be thrown and translated by a Symfony exception listener into an appropriate 502 or 503 status code, so monitoring can distinguish a missing resource from a broken connection.

On the performance side, it is worth watching for N plus 1 problems outside of Doctrine as well: a State Provider that makes a separate external call for every element of a collection scales poorly. Batch endpoints on the external source or an intermediate cache in the decorator solve this before it becomes a production bottleneck.

9. State Provider compared to the Doctrine default path

The choice between the implicit Doctrine access and a custom State Provider depends on where the data comes from and the requirements around testability. The table below summarizes the key differences.

Criterion Doctrine default path Custom State Provider Recommendation
Data source Doctrine entity manager Anything: API, cache, legacy system State Provider for non Doctrine sources
Testability Requires database fixtures Plain unit test possible State Provider for fast test suites
Coupling Resource tied to entity Resource freely modeled State Provider for stable public contracts
Implementation effort No custom class needed Custom class per resource Doctrine default path for CRUD without edge cases
Caching and decoration Hard to retrofit Freely extendable via AsDecorator State Provider for cross cutting concerns

In practice, most projects mix both approaches: simple CRUD resources stay on the Doctrine default path, while complex aggregations, external integrations, and asynchronous write operations run through a custom State Provider or State Processor. This mix is not a compromise, it is API Platform's actual strength: the default path stays available for the simple case, while the State Provider hands back full control for the complex case.

Mironsoft

Symfony and API Platform architecture for demanding APIs

Need custom State Provider and Processor classes for your API Platform project?

We design API resources that are decoupled from persistence, connect legacy systems and external APIs cleanly, and secure your write paths with tested State Processors.

API architecture

State Provider and Processor design for complex resources

Legacy integration

Connect external systems transparently through custom State Providers

Test automation

Building unit and functional tests for State Provider and Processor classes

10. Summary

A State Provider replaces the implicit Doctrine access with a custom class that freely decides where the data for an API resource comes from. A State Processor mirrors this on the write side and fully decouples the target storage from validation and serialization. Both interfaces are the key to using API Platform productively for non Doctrine data sources, asynchronous processing, and legacy integrations.

Anyone who consistently builds a State Provider and a State Processor as a thin adapter layer over domain services ends up with small, unit testable classes instead of bloated controllers. The decorator pattern additionally allows retrofitting caching and other cross cutting concerns without touching the actual retrieval logic, so a State Provider written once stays maintainable for the entire lifetime of the project.

State Provider and State Processor in API Platform: the essentials

ProviderInterface

Replaces Doctrine access for reads, returns objects or arrays for any data source.

ProcessorInterface

Takes over write logic after validation, can persist, hand off to Messenger, or transform.

Decorator pattern

AsDecorator lets you wrap caching, logging, and access checks around a State Provider.

Testability

Plain PHP classes without kernel dependency, checkable directly with PHPUnit against test doubles.

11. FAQ: State Provider and State Processor in API Platform

1What is a State Provider?
A class implementing ProviderInterface that replaces Doctrine access for reads and freely decides where resource data comes from.
2When do I need a State Processor?
When writes do not go directly into an entity but to Messenger, an external system, or several targets at once.
3One class for multiple operations?
Yes, through the Operation object the provider can detect which operation is running and react differently.
4How do I return 404?
Simply return null for an item operation. API Platform automatically converts that into a 404.
5Must the processor validate itself?
No, validation runs beforehand through the Symfony Validator constraints, the processor already receives a validated object.
6Combine caching with a State Provider?
Wrap a caching provider around the actual one with AsDecorator, calling the inner one only on a cache miss.
7Testing without an HTTP kernel?
Yes, a State Provider is a normal service and can be tested directly with PHPUnit using test doubles.
8Avoiding N plus 1 on external APIs?
Use batch endpoints on the external source or cache results, instead of calling per element.
9Combination with Symfony Messenger?
Yes, the processor builds a command, dispatches it, and returns a confirmation with the appropriate status code.
10Does the provider replace the service layer?
No, it stays a thin adapter layer that calls domain services and maps their result into a resource.