If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWhen we get a collection of treasures, we currently return every treasure, even unpublished treasures. Probably some of these are unpublished. We did add a filter to control this... but let's be honest, that's not the best solution. Really, we need to not return unpublished treasures at all.
Find the API Platform Upgrade Guide... and search for the word "state" to find a section that talks about "providers" and "processors". We talked about state processors earlier, like the PersistProcessor
on the Put
and Post
operations, which is responsible for saving the item to the database.
But each operation also has something called a state provider. This is what's responsible for loading the object or collection of objects. For example, when we make a GET request for a single item, the ItemProvider
is what's responsible for taking the ID and querying the database. There's also a CollectionProvider
to load a collection of items.
So if we want to automatically hide unpublished treasures, one option would be to decorate this CollectionProvider
, very much like we did with the PersistProcessor
. Except... that won't quite work. Why? The CollectionProvider
from Doctrine executes the query and returns the results. So all we would be able to do is take those results... then hide the ones we don't want. That's... not ideal for performance - imagine loading 50 treasures then only showing 10 - and it would confuse pagination. What we really want to do is modify the query itself: to add a WHERE isPublished = true
.
Luckily for us, this CollectionProvider
"provides" its own extension point that lets us do exactly that.
Before we dive in, let's update a test to show the behavior we want. Find testGetCollectionOfTreasures()
. Take control of these 5 treasures and make them all isPublished => true
:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 16 | |
public function testGetCollectionOfTreasures(): void | |
{ | |
DragonTreasureFactory::createMany(5, [ | |
'isPublished' => true, | |
]); | |
... lines 22 - 44 | |
} | |
... lines 46 - 183 | |
} |
because right now, in DragonTreasureFactory
, isPublished
is set to a random value:
... lines 1 - 29 | |
final class DragonTreasureFactory extends ModelFactory | |
{ | |
... lines 32 - 46 | |
protected function getDefaults(): array | |
{ | |
return [ | |
... lines 50 - 51 | |
'isPublished' => self::faker()->boolean(), | |
... lines 53 - 56 | |
]; | |
} | |
... lines 59 - 73 | |
} |
Then add one more with createOne()
and isPublished
false:
... lines 1 - 12 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 15 - 16 | |
public function testGetCollectionOfTreasures(): void | |
{ | |
DragonTreasureFactory::createMany(5, [ | |
'isPublished' => true, | |
]); | |
DragonTreasureFactory::createOne([ | |
'isPublished' => false, | |
]); | |
... lines 25 - 44 | |
} | |
... lines 46 - 183 | |
} |
Awesome! And we still want to assert that this returns just 5 items. So... let's make sure it fails:
symfony php bin/console phpunit --filter=testGetCollectionOfTreasures
And... yea! It returns 6 items.
Ok, to modify the query for a collection endpoint, we're going to create something called a query extension. Anywhere in src/
- I'll do it in the ApiPlatform/
directory - create a new class called DragonTreasureIsPublishedExtension
. Make this implement QueryCollectionExtensionInterface
, then go to "Code"->"Generate" or Command
+N
on a Mac - and generate the one method we need: applyToCollection()
:
... lines 1 - 2 | |
namespace App\ApiPlatform; | |
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; | |
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; | |
use ApiPlatform\Metadata\Operation; | |
use Doctrine\ORM\QueryBuilder; | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface | |
{ | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void | |
{ | |
// TODO: Implement applyToCollection() method. | |
} | |
} |
This is pretty cool: it passes us the $queryBuilder
and a few other pieces of info. Then, we can modify that QueryBuilder
. The best part? The QueryBuilder
already takes into account things like pagination and any filters that have been applied. So those are not things we need to worry about.
Also, thanks to Symfony's autoconfiguration system, just by creating this class and making it implement this interface, it will already be called whenever a collection endpoint is used!
In fact, it will be called for any resource. So the first thing we need is if (DragonTreasure::class !== $resourceClass)
- fortunately it passes us the class name - then return:
... lines 1 - 7 | |
use App\Entity\DragonTreasure; | |
... lines 9 - 10 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface | |
{ | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void | |
{ | |
if (DragonTreasure::class !== $resourceClass) { | |
return; | |
} | |
... lines 18 - 21 | |
} | |
} |
Below, this is where we get to work. Now, every QueryBuilder
object has a root alias that refers to the class or table that you're querying. Usually, we create the QueryBuilder
... like from inside a repository we say something like $this->createQueryBuilder('d')
and d
becomes that "root alias". Then we use that in other parts of the query.
However, in this situation, we didn't create the QueryBuilder
, so we never chose that root alias. It was chosen for us. What is it? It's: "banana". Actually, I have no idea what it is! But we can get it with $queryBuilder->getRootAliases()[0]
:
... lines 1 - 10 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface | |
{ | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void | |
{ | |
if (DragonTreasure::class !== $resourceClass) { | |
return; | |
} | |
$rootAlias = $queryBuilder->getRootAliases()[0]; | |
... lines 20 - 21 | |
} | |
} |
Now it's just normal query logic: $queryBuilder->andWhere()
passing sprintf()
. This looks a little weird: %s.isPublished = :isPublished
, then pass $rootAlias
followed by ->setParameter('isPublished', true)
:
... lines 1 - 10 | |
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface | |
{ | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void | |
{ | |
if (DragonTreasure::class !== $resourceClass) { | |
return; | |
} | |
$rootAlias = $queryBuilder->getRootAliases()[0]; | |
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias)) | |
->setParameter('isPublished', true); | |
} | |
} |
Cool! Spin over to try this thing!
symfony php bin/console phpunit --filter=testGetCollectionOfTreasures
Mission accomplished! It's just that easy.
By the way, will this also work for sub-resources? For example, over in our docs, we can also fetch a collection of treasures by going to /api/users/{user_id}/treasures
. Will this also hide the unpublished treasures? The answer is... yes! So, it's not something you need to worry about. I won't show it, but this also uses the query extension.
Oh, and if you wanted admin users to be able to see unpublished treasures, you could add logic to only modify this query if the current user is not an admin.
Next up: this query extension fixes the collection endpoint! But... someone could still fetch a single unpublished treasure directly by its id. Let's fix that!
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}