Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Resource State Provider

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

We have a shiny new API resource class and... for the most part, we'll use it like normal.

Customizing ApiResource Options

For example, instead of DailyQuests, maybe we change the shortName to just Quest. When we peek at the docs, as expected, the title changes... along with all the URLs.

... lines 1 - 6
shortName: 'Quest'
class DailyQuest

Making the State Provider

To be able to load data and have this collection endpoint not return a 404, we need a state provider. And it's not just the GET endpoints. The PUT endpoint uses a state provider, as well as DELETE and PATCH: these all first load the resource, before editing or deleting it.

So let's make a state provider! We've done this before. At your terminal, run:

./bin/console make:state-provider

Call it DailyQuestStateProvider. Awesome name!

Spin back over, open the State/ directory and... there it is! Our job is simple: to return the DailyQuest object or objects for the current operation.

... lines 1 - 2
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
class DailyQuestStateProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
// Retrieve the state from somewhere

Let's start super basic: return an array with two hard-coded new DailyQuest() objects. They're both empty... because that class doesn't have any properties.

... lines 1 - 10
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
return [
new DailyQuest(),
new DailyQuest(),
... lines 18 - 19

To tell API Platform to use the shiny new provider, in DailyQuest, add provider set to DailyQuestStateProvider::class.

... lines 1 - 5
use App\State\DailyQuestStateProvider;
... line 9
provider: DailyQuestStateProvider::class,
class DailyQuest
... lines 13 - 16

Let's give this a whirl! Dash back over to the docs to "Execute" the collection endpoint. And... yes! No more 404! We get a 200... and it returned 2 items! All they have are the JSON-LD fields - @id and @type - but that makes sense since the class doesn't have any other properties.

Adding the Identifier

So, yay! But, before we run wild and add more properties, we need to talk about why the GET one endpoint is missing. We have the GET collection endpoint, but no GET-a-single-item endpoint. Why?

Every API resource needs an "identifier". Right now, our class does not have an identifier... and that causes the two GET routes to collide. Let me show you!

Spin over and run:

php bin/console debug:router

I love this. API Platform creates an actual route for every operation of every API resource. I'll make this a little smaller... better. You can see all the routes for the quests. Here's the one for _get_collection and, above it, the one for _get_single... but with the same URL!

Usually, the URL would be /api/quests/{id}... where id is known as the identifier. But... our DailyQuest doesn't have any properties... so API Platform has no idea what to use for the identifier.

So what's the solution? The easiest is to add an $id property: public int $id... and, for simplicity, let's add a constructor where we can pass the int $id. Set the property inside.

... lines 1 - 11
class DailyQuest
public int $id;
public function __construct(int $id)
$this->id = $id;

Over in DailyQuestStateProvider, invent a few IDs: how about 4 and 5.

... lines 1 - 8
class DailyQuestStateProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
return [
new DailyQuest(4),
new DailyQuest(5),

Cool, now dump the routes again:

php bin/console debug:router

Behold! The single GET has a different URL with {id}. The id was also missing from put, patch, and delete... and it's there now too. Over on the docs, when we refresh... we see the same thing.

The identifier is important because it's used in the URLs... and so it's also used to generate the @id IRI string for each item. Here, you can see the @id is now pointing to /api/quests/4.

A non-traditional Identifier with identifier: true

But wait, how did API Platform know that the id is the all-important "identifier"... and not just some normal property? I'm... honestly... not entirely sure. But it seems that the name id is special... somewhere in API platform. If you name a property id, API Platform says:

Oh, that must be your identifier!

And... it's usually not wrong! But, there is a more explicit way to say that a property is an identifier. Next, instead of an integer identifier, let's see if we can use a date identifier, so we have URLs like /api/quests/2023-06-05.

Leave a comment!

Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "3.1.x-dev", // 3.1.x-dev
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.10.2
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
        "doctrine/orm": "^2.14", // 2.16.1
        "nelmio/cors-bundle": "^2.2", // 2.3.1
        "nesbot/carbon": "^2.64", // 2.69.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.23.1
        "symfony/asset": "6.3.*", // v6.3.0
        "symfony/console": "6.3.*", // v6.3.2
        "symfony/dotenv": "6.3.*", // v6.3.0
        "symfony/expression-language": "6.3.*", // v6.3.0
        "symfony/flex": "^2", // v2.3.3
        "symfony/framework-bundle": "6.3.*", // v6.3.2
        "symfony/property-access": "6.3.*", // v6.3.2
        "symfony/property-info": "6.3.*", // v6.3.0
        "symfony/runtime": "6.3.*", // v6.3.2
        "symfony/security-bundle": "6.3.*", // v6.3.3
        "symfony/serializer": "6.3.*", // v6.3.3
        "symfony/stimulus-bundle": "^2.9", // v2.10.0
        "symfony/string": "6.3.*", // v6.3.2
        "symfony/twig-bundle": "6.3.*", // v6.3.0
        "symfony/ux-react": "^2.6", // v2.10.0
        "symfony/ux-vue": "^2.7", // v2.10.0
        "symfony/validator": "6.3.*", // v6.3.2
        "symfony/webpack-encore-bundle": "^2.0", // v2.0.1
        "symfony/yaml": "6.3.*", // v6.3.3
        "symfonycasts/micro-mapper": "^0.1.0" // v0.1.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.11
        "symfony/browser-kit": "6.3.*", // v6.3.2
        "symfony/css-selector": "6.3.*", // v6.3.2
        "symfony/debug-bundle": "6.3.*", // v6.3.2
        "symfony/maker-bundle": "^1.48", // v1.50.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.3.2
        "symfony/stopwatch": "6.3.*", // v6.3.0
        "symfony/web-profiler-bundle": "6.3.*", // v6.3.2
        "zenstruck/browser": "^1.2", // v1.4.0
        "zenstruck/foundry": "^1.26" // v1.35.0