Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Using a Custom (Date) Identifier

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

For our DailyQuest API endpoints, we set up an id as the identifier. But what we really want is a date... so we can have fancy URLs like /api/quests/2023-06-05.

Let's try it! In DailyQuest, instead of public int $id, say public \DateTimeInterface $day. And in the constructor, replace the argument with \DateTimeInterface $day... and $this->day = $day.

... lines 1 - 11
class DailyQuest
public \DateTimeInterface $day;
public function __construct(\DateTimeInterface $day)
$this->day = $day;

Next, in DailyQuestStateProvider, we'll say... how about new \DateTime('now') and new \DateTime('yesterday').

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

When we refresh the docs... we're back to where we were before: we're missing the ID on PUT, DELETE, and PATCH, and our single GET is gone. That's because API Platform doesn't know that the $day property is meant to be our identifier. Though, if we try the GET collection endpoint... hey! The day field does show up inside the JSON like a normal property!

What we want to do is tell API Platform:

Hey! This isn't a normal property: day is our identifier.

We do that by adding an #[ApiProperty] attribute above this with identifier: true.

... lines 1 - 4
use ApiPlatform\Metadata\ApiProperty;
... lines 6 - 12
class DailyQuest
#[ApiProperty(identifier: true)]
public \DateTimeInterface $day;
... lines 17 - 21

Debugging IRI Generation Errors

When we check, this does, in fact, fix all of our routes. But when we try the collection endpoint... we get a 400 error:

Unable to generate an IRI for the item of type DailyQuest.

So API Platform loaded our two DailyQuest objects... but when it tried to generate the @id property (the IRI), for some reason, it exploded!

To find out more, go down to the web debug toolbar and open up that request in the profiler. On the Exception tab, there were two exceptions on this page: a nested exception situation.

The top level - Unable to generate an IRI - doesn't really tell us why there was a problem. Down here, we can see:

We were not able to resolve the identifier matching parameter "day".

This error isn't super clear either, but it's closer. It's really saying:

Yo! I tried to generate the IRI by using the day field... but that's a DateTimeInterface object... and I don't know how to convert that to a string.

We actually chose a pretty tricky IRI to work with, and I think that's cool. API Platform does have a system called "URI variable transformer". The {day} is a variable in the route... and you can help "transform" the DateTimeInterface object into something that can be used in that string. The "Identifiers" documentation talks about this.

But there's also a simple solution. Create a new function called getDayString() which will return a string. Inside, return $this->day->format() with the format we want: Y-m-d.

Making a Method the Identifier

The trick is to make this method the identifier: move the ApiProperty from the actual property... down above this.

... lines 1 - 12
class DailyQuest
... lines 15 - 21
#[ApiProperty(identifier: true)]
public function getDayString(): string
return $this->day->format('Y-m-d');

Perfect! Back over here... the routes still look correct. You can see we have {dayString} now. And when we try our GET collection endpoint... check it out! We see "@id": "/api/quests/ and then the date string. That's exactly what we wanted!

Though, now we have a dayString field in the JSON... as well as the day itself. Let's think. We really don't need the day field at all: it exists internally just to help the dayString. And because the dayString is in the URL, having that as a field also seems unnecessary. Can we hide these?

Hiding Specific Fields from your API

Sure! And we don't even need to use serialization groups! We're going to go deeper into this later, but above the day property, we can hide this entirely from our API by using an #[Ignore] attribute from Symfony's serializer.

... lines 1 - 7
use Symfony\Component\Serializer\Annotation\Ignore;
... lines 9 - 13
class DailyQuest
public \DateTimeInterface $day;
... lines 18 - 28

If we head over here and "Execute" that... boom! That field is gone: it can't be read or written.

We could do the same thing for getDayString(). But another option is to say readable: false. This means it won't be readable, but it will still technically be writable. However, because there's no setDayString, it's not actually writable.

... lines 1 - 13
class DailyQuest
... lines 16 - 23
#[ApiProperty(readable: false, identifier: true)]
public function getDayString(): string
... lines 26 - 28

Now, when we "Execute" this... that field disappears too.

This is the setup we want! We have the ID we want, we don't have any extra fields that we don't want, and we can now add whatever fields that we do want. To do that, we're going to build an Enum.

Create a src/Enum/ directory... and, inside, a new PHP class, or really enum, called DailyQuestStatusEnum. I'll paste some code here.

... lines 1 - 2
namespace App\Enum;
enum DailyQuestStatusEnum: string
case ACTIVE = 'active';
case COMPLETED = 'completed';

This is just a way for us to keep track of the status of each DailyQuest. Back over in that class, let's add some properties: public string $questName, public string $description.... and whatever other properties we need in our API, like public int $difficultyLevel, and a public DailyQuestStatusEnum called $status.

... lines 1 - 14
class DailyQuest
... lines 17 - 18
public string $questName;
public string $description;
public int $difficultyLevel;
public DailyQuestStatusEnum $status;
... lines 23 - 33

Null Fields are Hidden

Nice! Let's try this! Head over... and Execute! Hmm, we don't see any of the new fields yet. That's because they're not populated and, by default, API Platform hides fields that are null or uninitialized.

But if we refresh the page and go down to the documentation for the response... it shows that these are part of the API.

Head over to DailyQuestStateProvider so we can populate them. Say return $this->createQuests(): a new private function we'll create. I'll paste that in as well: you can grab the code from the code block on this page.

... lines 1 - 9
class DailyQuestStateProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
return $this->createQuests();
private function createQuests(): array
$quests = [];
for ($i = 0; $i < 50; $i++) {
$quest = new DailyQuest(new \DateTimeImmutable(sprintf('- %d days', $i)));
$quest->questName = sprintf('Quest %d', $i);
$quest->description = sprintf('Description %d', $i);
$quest->difficultyLevel = $i % 10;
$quest->status = $i % 2 === 0 ? DailyQuestStatusEnum::ACTIVE : DailyQuestStatusEnum::COMPLETED;
$quests[$quest->getDayString()] = $quest;
return $quests;

This creates 50 quests - each one a day further in the past - and populates simple data for the rest of the fields. Some of the quests will be ACTIVE, and others COMPLETED.

Oh, and notice that I'm using getDayString() as the key for this array. We don't need to do that: they keys in the array returned by your collection provider are not important. I only did this because it's going to be handy in a few minutes when we create the get one operation.

Testing time! Move over, hit "Execute" again and... look at that! We have 50 items with data on all of them. That's gorgeous!

Next: Let's get our provider working for the item operations: meaning when we fetch a single item. The item provider is used for the GET one operation, PUT, PATCH and DELETE.

Leave a comment!

Login or Register to join the conversation
Alexandre-G Avatar
Alexandre-G Avatar Alexandre-G | posted 7 days ago | edited

in my case, I tried to set the identifier with a propery of my DTO.
It work :

    #[ApiProperty(identifier: true)]
    public function getIdentifier(): string
        return "test";

return array of:

"myElement": {
            "year": "2011",
            "property1": "test",
            "property2": "test",
            "elements": [
                    "p_elment": "test",
                    "nom": "test"
                    "p_elment": "test",
                    "nom": "test"

It does not work: ($myProperty is a public property of my class.). I have no error but my collection is not return

    #[ApiProperty(identifier: true)]
    public function getIdentifier(): string
        return $this->myProperty;

array 'elements' is missing
`"myElement": {

        "year": "2011",
        "property1": "test",
        "property2": "test"

Do you have any of why ?


Hey @Alexandre-G!

Sorry for the slow reply! I'm not sure what's happening here. Can you give a bit more detail? The getIdentifier() method - is that on the top-level ApiResource that's being returned from this API endpoint? Or is it some embedded object? Basically, if you can give me some more context on which classes are involved and which endpoints you're hitting, that would help a lot :). But, I CAN see that you are hitting some odd behavior. I would not expect the return value of the identifier to be able to affect whether or not a field (elements) is returned. So something, indeed, looks odd...


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