Collection "Types" and readableLink

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

Something a bit odd just happened: in order for API Platform to correctly serialize the mostPopularListings collection, we had to explicitly tell it what was inside the collection. Why?

To learn what's going on, let's look at another example. Inside User, we have a cheeseListings property, which is writable in our API, but isn't readable:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 77
/**
... line 79
* @Groups({"user:write"})
... line 81
*/
private $cheeseListings;
... lines 84 - 286
}

There is also a getPublishedCheeseListings() method, which is part of the API and we actually gave it the cheeseListings name:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 209
/**
* @Groups({"user:read"})
* @SerializedName("cheeseListings")
*/
public function getPublishedCheeseListings(): Collection
{
... lines 216 - 218
}
... lines 220 - 286
}

Let's put in our lab coats and do an experiment! Science! Start by removing the SerializedName annotation:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 209
/**
... line 211
* @SerializedName("cheeseListings")
*/
public function getPublishedCheeseListings(): Collection
{
... lines 216 - 218
}
... lines 220 - 286
}

We're still going to expose this method, but it will use its natural name: publishedCheeseListings. Then, up on the cheeseListings property add user:read to also expose this:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 77
/**
... line 79
* @Groups({"user:write", "user:read"})
... line 81
*/
private $cheeseListings;
... lines 84 - 286
}

Let's see what it looks like! Head over to /api/users.jsonld and... cool! Each User now has cheeseListings and publishedCheeseListings properties and they're both embedded objects. The reason why is that the $title and $price properties in CheeseListing have the user:read group.

Let's remove those temporarily. Go into CheeseListing and take user:read off of $title and user:read off of $price:

... lines 1 - 58
class CheeseListing
{
... lines 61 - 67
/**
... line 69
* @Groups({"cheese:read", "cheese:write", "user:write"})
... lines 71 - 76
*/
private $title;
... lines 79 - 86
/**
... lines 88 - 90
* @Groups({"cheese:read", "cheese:write", "user:write"})
... line 92
*/
private $price;
... lines 95 - 217
}

Thanks to this change, when API Platform goes to serialize these two array fields, it will realize that there are no embedded properties and return an array of IRI strings.

But... surprise! When we refresh, cheeseListings is an array of IRI strings, but check out publishedCheeseListings! It's still an array of embedded objects! Other than the fact that publishedCheeseListings may have less items in it, these two fields return the same thing! And yet, they're being serialized in different ways!

Property Metadata for Collections

Here's the deal. We know that API Platform collects a lot of metadata about each property, like its type and whether it's required. And it gets that from many different sources like Doctrine metadata and our own PHPDoc.

And because collecting all of this can take time, it caches it. Now, API Platform is really good in dev mode at knowing when it needs to rebuild that cache. Like, if we add more PHPDoc to a property, it rebuilds. And so, even though it's caching all of this, we don't really notice it.

And this metadata collection process happens before API Platform starts handling any request, which means the metadata is built purely by looking at our code. Right now, when it looks at the cheeseListings property, it knows that this is an array of CheeseListing objects thanks to the Doctrine annotations:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 77
/**
* @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner", cascade={"persist"}, orphanRemoval=true)
... lines 80 - 81
*/
private $cheeseListings;
... lines 84 - 286
}

But it does not know that getPublishedCheeseListings() returns a collection of CheeseListing objects:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 209
/**
* @Groups({"user:read"})
* SerializedName("cheeseListings")
*/
public function getPublishedCheeseListings(): Collection
{
... lines 216 - 218
}
... lines 220 - 286
}

It does know that its a Collection... but not what's inside that Collection.

Why is this a problem? Well, whenever API platform serializes a collection, before it even starts, it asks its own metadata: what is this a collection of? If the "thing" that's being serialized is a collection of objects that are an API Resource class - like the cheeseListings property - then it calls one set of code that knows how to handle this. But if it's an array of anything else - which is what happens down in getPublishedCheeseListings() since it doesn't know what's inside this collection, then it runs a different set of code with different behavior.

This isn't a problem very often - especially if you're relying on Doctrine metadata - but whenever you have a collection field, you should think:

Does API Platform know what this is a collection of?

For getPublishedCheeseListings(), we already know the solution. Add @return Collection<CheeseListing>:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 209
/**
... lines 211 - 212
* @return Collection<CheeseListing>
*/
public function getPublishedCheeseListings(): Collection
{
... lines 217 - 219
}
... lines 221 - 287
}

Try it! Refresh the endpoint and... we get an array of IRI strings in both cases.

Now, you can actually control this behavior directly... with an option that - honestly - makes my head spin a little bit. Instead of allowing API Platform to figure out if a property should be an embedded object or an IRI string, you can force it with @ApiProperty({readableLink=true}):

... lines 1 - 5
use ApiPlatform\Core\Annotation\ApiProperty;
... lines 7 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
* @ApiProperty(readableLink=true)
... lines 213 - 215
*/
public function getPublishedCheeseListings(): Collection
{
... lines 219 - 221
}
... lines 223 - 289
}

Refresh now. Yep! This forces it to be an embedded object. readableLink is an internal option that's set on every API field, and it's normally determined automatically. API Platform sets it by looking to see if there are intersecting normalization groups between User and CheeseListing. Basically it says:

Hey! I can see that this property will hold an array of CheeseListing objects. Let's see if any of the CheeseListing properties are in the user:read group. If there are any, set readableLink to false to force it to be embedded.

By using the @ApiProperty annotation, we're overriding this and taking control ourselves.

Now, readableLink is super weird... at least for me - I can't quite wrap my mind around it. The name almost seems backwards: readableLink=true says that you want to embed and readableLink=false says to use an IRI link... though I've seen some odd behavior in some cases. If you have any questions, let us know down in the comments.

Ok, let's undo everything: take off readableLink, but leave the @return because that's actually helpful. Put back the @SerializedName():

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
* @Groups({"user:read"})
* @SerializedName("cheeseListings")
* @return Collection<CheeseListing>
*/
public function getPublishedCheeseListings(): Collection
{
... lines 218 - 220
}
... lines 222 - 288
}

And, on the cheeseListings property, remove user:read:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 78
/**
... line 80
* @Groups({"user:write"})
... line 82
*/
private $cheeseListings;
... lines 85 - 288
}

Back in CheeseListing, I'll undo to re-add the user:read groups:

... lines 1 - 58
class CheeseListing
{
... lines 61 - 67
/**
... line 69
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... lines 71 - 76
*/
private $title;
... lines 79 - 86
/**
... lines 88 - 90
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... line 92
*/
private $price;
... lines 95 - 217
}

Go over and refresh to make sure things are back to normal.

Next, let's get back to our custom DailyStats API Resource. We've implemented the collection operation, now let's add the get "item" operation so that we can fetch stats for a single day.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.9.6
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.7.3
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.21.1
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.1.2
    }
}