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.
Table of contents
- 1. Why you need a State Provider and a State Processor
- 2. The ProviderInterface: custom read access
- 3. The ProcessorInterface: write logic after validation
- 4. Connecting non Doctrine data sources
- 5. Reading the Context and Operation object correctly
- 6. Combining multiple providers with the decorator pattern
- 7. Testing State Providers and Processors
- 8. Error handling and performance
- 9. State Provider compared to the Doctrine default path
- 10. Summary
- 11. FAQ
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.