Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Completely Custom Resource

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

Even though they have some custom fields, both of our API resources are truly bound to the database. Ultimately we're querying for and saving to a specific entity like CheeseListing or User.

Custom Fields vs Custom Resource Class

But really, an API resource can be any object that pulls data from... anywhere! So instead of adding un-persisted $isMe and $isMvp fields to User:

... lines 1 - 41
class User implements UserInterface
... lines 44 - 97
* Returns true if this is the currently-authenticated user
* @Groups({"user:read"})
private $isMe = false;
* Returns true if this user is an MVP
* @Groups({"user:read"})
private $isMvp = false;
... lines 111 - 286

We could have created a totally different non-entity class - like UserApiResource - with exactly the fields in our API.

And, the way we would do that is pretty similar to what we've seen: we would need a custom data provider and data persister. The reason we didn't do that is that if your underlying data source is a Doctrine entity, it's much easier to add a few custom fields than to reinvent the wheel with a custom resource. After all, with a Doctrine entity, we get things like pagination and filtering for free!

But sometimes... you will need to create a completely custom ApiResource class, maybe because the data comes from some complex queries that join across multiple tables... or maybe the data doesn't come from the database at all!

So here's our goal: we're going to create a new DailyStats API resource so that we can have an endpoint - /api/daily-stats - that returns a collection of items where each one contains site stats for a single day: things like total visitors and most popular cheese listings. And we're going to pretend that the data for these stats do not come from the database.

Creating the API Resource Class

So... let's get started! Step one: create an API resource class that looks exactly how we want our API to look... regardless of how the underlying data looks or where it's coming from.

In the Entity/ directory, create a new class called DailyStats:

... lines 1 - 2
namespace App\Entity;
... lines 4 - 9
class DailyStats
... lines 12 - 16

And yes, I'm putting this in the Entity/ directory even though this isn't going to be a Doctrine entity. That's totally allowed and it's up to you if you like this or not. If you do want to put API Resource classes somewhere else, then in config/packages/api_platform.yaml, you'll need to tweak the config to tell API Platform to look in that new spot:

paths: ['%kernel.project_dir%/src/Entity']
... lines 4 - 21

Easy peasy!

Back in the new class, for simplicity - and... because this class will stay very simple - I'm going to use public properties: public $date, public $totalVisitors and a public $mostPopularListings that will hold an array of CheeseListing objects:

... lines 1 - 9
class DailyStats
public $date;
public $totalVisitors;
public $mostPopularListings;

If you're using PHP 7.4, you can also add property types, which will also help API Platform's documentation. More on that soon. Or, if you don't want this stuff to be public, use the normal private properties with getters and setters: whatever you want.

To officially make this an API Resource class, above, add @ApiResource:

... lines 1 - 4
use ApiPlatform\Core\Annotation\ApiResource;
* @ApiResource()
class DailyStats
... lines 12 - 16

That's it! Spin back to the browser and refresh the documentation. Say hello to our DailyStats resource! No... it won't magically work yet, but it is already documented.

Customizing the Resource URL

Now, I don't mean to be picky, but I don't love this daily_stats URL - dashes are much more hipster. We can fix that by adding an option to the annotation: shortName="daily-stats":

... lines 1 - 6
* @ApiResource(
* shortName="daily-stats"
* )
class DailyStats
... lines 14 - 18

When we refresh the docs now... that did it! But we could have also solved this "Ryan hates underscores" problem in a more global way. Search for "API Platform operation path naming" to find a spot on their docs that talks about how the URLs are generated.

Whenever you have an entity - like DailyStats - there is a process inside API Platform that converts the word DailyStats to daily_stats or daily-stats. That's called the path_segment_name_generator and it's something you can customize. It uses underscores by default, but there's a pre-built service that loves dashes.

Copy the example config. Then, remove the shortName option and in api_platform.yaml paste that config anywhere:

... lines 2 - 5
path_segment_name_generator: api_platform.path_segment_name_generator.dash
... lines 7 - 22

Back on our docs, let's try it! Beautiful! The URL is still /api/daily-stats.

Next: let's make these operations actually work, starting with the get collection operation. How do we do that? By adding a data provider... and a few other details that are special when you working with a class that is not an entity.

Leave a comment!

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.4.0 || ^8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "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.18.7
        "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.8.0
        "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.23.0
        "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.8.0