Skip to content
Logo Theodo

Retrieve an Object with Desired Subresources with API Platform

Souleyman BEN HADJ BELGACEM9 min read

API Platform

API Platform offers a lot of possibilities, once you learn to leverage its full capabilities, it will send your development skills to the next level, that’s what I learned when I discovered how to use data providers and extensions. API Platform beginners tend to use a lot of custom controllers by a lack of comprehension of it. API Platform is a really powerful framework that can manage a lot of things for you such as error management, and a lot of basic operations are optimized. Using too many Custom Controllers makes you lose these advantages. Moreover, you’re often re-developing something that already exists, and the way you develop it is almost always less optimized. The first time I used API Platform, it was on a company website with legacy code of previous developers, using Symfony and API Platform as backend technologies. The company wanted to add a blog dimension to its site. So, I had to allow editor users to write news in their language and translate it into several other languages used by the company. Readers can access these articles on the website’s home page, but only in their language. In order to manage these news I have different entities :

In my case, no need to get every translation of the news I have to display, I only need the user’s language translation to avoid having to sort which language to show in front.

I didn’t want to use a Custom Controller because as I mentioned, API Platform services come with optimization for maintainability and speed.

Search filter

My first idea was to use a search filter. I knew that you could filter on entities’ properties with it but also on nested properties. When I created it in the InternalNews Entity, filtered on the child property for the language and called the route with a query parameter matching the language (i.e. English), I was expecting to get only the English translation.

<?php
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;

#[ApiFilter(SearchFilter::class,
       properties:["translations.language.isoCode" => "exact"]
      )]

class InternalNews

Actually, I still got all my translations. After a lot of tests, I discovered something: search filter only checks the existence of the property. In this case, it only checks that the property value matches the searched value. In this case, it only checks that the news object possesses a property translation with the wanted language, if it exists it will return the full object. For example, if I make a request for this news with the filter translations.language.isoCode=ITA, only News 2 will be retrieved because it’s the only one with an Italian translation. Nevertheless, all its translations will be retrieved

An Example with 3 News but only one of it have an Italian Translation

The documentation was not really clear so I didn’t expect this behavior. No matter, I can create my own custom filter.

Second solution: Custom Filters and Data providers

Custom filter

Vincent (a coworker) told me that I could use a data provider to retrieve the desired data, but I needed to create a custom filter in the first place to be able to collect the iso code linked to the user language. In order to do that, let’s create a new PHP class that implements FilterInterface. FilterInterface needs two methods: apply() and getDescription(). I will not focus on getDescription, it’s only for documentation. The apply function will be called on every route, before controllers or data managers (data provider, data persister or extensions). So the first thing I had to do is to check if the request has the query param I want to use, if not I can return and quit the function. If the request uses the wanted queryParam I can access its value and put it in the context to use it a little bit further. Now I am able to get the user iso code. From now, I’ll need to process it to retrieve the associated translations. To do that, I’ll use a new concept : data providers.

<?php
namespace App\Portal\Filter;

use ApiPlatform\Core\Serializer\Filter\FilterInterface;
use Symfony\Component\HttpFoundation\Request;

class LanguageIsoCodeFilter implements FilterInterface
{
final public const LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT = 'language_iso_code_filter';

/**
* @param array<mixed> $attributes
    * @param array<mixed> $context
        */
        public function apply(
            Request $request,
            bool $normalization,
            array $attributes,
            array &$context
        ): void
        {
        $languageIsoCode = $request->query->get("languageIsoCode");

        if (!$languageIsoCode) {
        return;
        }

        $context[self::LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT] = $languageIsoCode;
        }

        /**
        * @return array<mixed>
            */
            public function getDescription(string $resourceClass): array
            {
            return [
            'languageIsoCode' => [
            'property' => null,
            'type' => 'string',
            'required' => false,
            ]
            ];
            }

            }

Data provider

Data provider is natively used to load data from data source. For example, when you use a default route like api/translation, API Platform will retrieve these data using a default data provider. In this case, I don’t want default values (which contain all translations), so I can implement my own data provider. To do this, I first created an InternalNewsCollectionDataProvider class to implement CollectionDataProviderInterface (or ContextAwareCollectionDataProviderInterface if you’re using a version of API Platform below 3).

use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
use App\Portal\Entity\InternalNews;
use App\Portal\Entity\InternalNewsTranslation;
use App\Portal\Entity\ThemeTranslation;
use App\Portal\Filter\LanguageIsoCodeFilter;
use App\Portal\Services\InternalNewsService;
use Doctrine\Common\Collections\ArrayCollection;

class InternalNewsCollectionDataProvider implements ContextAwareCollectionDataProviderInterface
{
    public function __construct(
        private readonly ContextAwareCollectionDataProviderInterface $decoratedDataProvider,
        private readonly InternalNewsService $internalNewsService
        )
    {
    }

CollectionDataProviderInterface uses two methods: supports and getCollection. By default, every data provider will be called on every call I will make. To prevent this and only use the right data provider on the right route I can use the supports method. The function getCollection will only apply if the function supports return true. In this case, I want to use my data provider if the route called operation name is get_published_internal_news (which is the name of the operation I am working on) and if the ressource class is an InternalNews.

/**
* @param array<mixed> $context
*/
  public function supports(
    string $resourceClass,
    string $operationName = null,
    array $context = []): bool
{
    return  InternalNews::class === $resourceClass &&
            $operationName === 'get_published_internal_news'
}

Now that my data provider only applies on the right route I can focus on the getCollection function. First, let’s inject another CollectionDataProviderInterface into the construct of ours. We will use it in getCollection to get the default data provider value (with all the translations). Next step, I will retrieve the language value from the context, that we previously added with our custom filter. I just need to browse all the news provided by the default data provider and filter on the correct language and return it. From this point, I was able to retrieve all published internal news with only the request language.

/**
 * @param array<mixed> $context
 *
 * @return iterable<int, InternalNews>
 *
 * @throws ResourceClassNotSupportedException
 */
public function getCollection(
    string $resourceClass,
    string $operationName = null,
    array $context = []
): iterable
{
    /** @var iterable<int, InternalNews> $internalNewsCollection */
    $internalNewsCollection = $this->decoratedDataProvider->getCollection(
        $resourceClass,
        $operationName,
        $context
    );

    /** @var string | false $languageIsoCodeToFilterOn */
    $languageIsoCodeToFilterOn = $context[LanguageIsoCodeFilter::LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT] ?? false;

    if (false !== $languageIsoCodeToFilterOn ) {
        foreach ($internalNewsCollection as $internalNews) {
            $this->internalNewsService->filterTranslationsForLanguage($internalNews, $languageIsoCodeToFilterOn);
        }
    }

    return $internalNewsCollection;
}
public function filterTranslationsForLanguage(
    InternalNews $internalNews,
    string $languageIsoCode
    ): void
{
    $internalNews->setTranslations(
        new ArrayCollection(
            $internalNews->getTranslations()->filter(
                fn(InternalNewsTranslation $translation = null) =>
                    $translation && $languageIsoCode === $translation->getLanguage()->getCodeIso()
            )->getValues()
        )
    );

    foreach ($internalNews->getThemes() as $theme) {
        $theme->setTranslations(
            new ArrayCollection(
                $theme->getTranslations()->filter(
                    fn(ThemeTranslation $translation = null) =>
                        $translation && $languageIsoCode === $translation->getLanguage()->getCodeIso()
                )->getValues()
            )
        );
    }
}

Expert mode : Extension

I used the previous solution for a quite long time but I improved it for performance reasons. The issue of data provider solution was that I put a lot of load on the PHP part that can create slowness. In this case, I load all the data from the database and then I filter to only keep the interesting values. The idea I had to improve performance was to retrieve only the needed value from the database, so I don’t have to filter in back. Extensions allows developers to modify the SQL request made by Doctrine in order to specify it for example. In our case, I wanted to specify the way the join between tables InternalNews and InternalNewsTranslationtables was done in order to get only selected translation.

In practice, I deleted our InternalNewsCollectionDataProvider and created a FilteredOnLanguageIsoCodeExtension class. This class will implement QueryCollectionExtensionInterface (or ContextAwareQueryCollectionExtensionInterface if you’re using an API Platform version below 3). Only one method is necessary to use QueryCollectionExtensionInterface : applyToCollection. Extensions will apply on every call you will do on your database and it doesn’t have a support method, so I firstly have to check that I call the expected route with a good object type. Then, I can get the value of the language I want to use from context and actually use it to add condition in our query builder (in addWhere function in our example). This way, I can easily get InternalNews only with desired translations with better performance than with a data provider.

<?php

namespace App\Portal\Extension;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\ContextAwareQueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Portal\Entity\InternalNews;
use App\Portal\Entity\Language;
use App\Portal\Filter\LanguageIsoCodeFilter;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;

class FilteredOnLanguageIsoCodeExtension implements ContextAwareQueryCollectionExtensionInterface
{
    /**
     * @param array<mixed> $context
     */
    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        ?string $operationName = null,
        array $context = []
    ): void
    {
        /** @var string | false $languageIsoCodeToFilterOn */
        $languageIsoCodeToFilterOn = $context[LanguageIsoCodeFilter::LANGUAGE_ISO_CODE_FILTER_IN_CONTEXT] ?? false;

        if (false !== $languageIsoCodeToFilterOn && in_array(
                $operationName,
                [
                    'get_published_internal_news',
                ])) {
            $this->addWhere($queryBuilder, $resourceClass, $languageIsoCodeToFilterOn);
        }
    }

    private function addWhere(
        QueryBuilder $queryBuilder,
        string $resourceClass,
        string $languageIsoCodeToFilterOn
    ): void
    {
        if (InternalNews::class !== $resourceClass) {
            return;
        }
        $rootAlias = $queryBuilder->getRootAliases()[0];

        /** @var Language $selectedLanguage */
        $selectedLanguage = $queryBuilder->getEntityManager()->getRepository(Language::class)
            ->findOneBy(["codeIso" => $languageIsoCodeToFilterOn]);

        $internalNewsTranslations = sprintf('%s.translations', $rootAlias);
        $queryBuilder
            ->join($internalNewsTranslations, 'int', Join::WITH, 'int.language = :newsSelectedLanguage')
            ->setParameter('newsSelectedLanguage', $selectedLanguage)
    }
}

The performance improvement comes precisely from these lines :

$queryBuilder
    ->join($internalNewsTranslations, 'int', Join::WITH, 'int.language = :newsSelectedLanguage')
    ->setParameter('newsSelectedLanguage', $selectedLanguage)

Doctrine allows me to add a condition when performing the tables join with the JOIN::WITH parameter. In this case, when joining tables, Doctrine will only keep InternalNewsTranslations with the requested language. Without this JOIN::WITH, I should have to join tables and then filter with an andWhere to keep InternalNewsTranslations with the requested language which performs less well.

Replacing the data provider by an extension using the join method with a JOIN::WITHparameter made the operation six times faster!

Conclusion

I finally achieved retrieving news only with its desired translations using a custom filter and an extension. This experience made me aware of the power and the utility of API Platform and made me quit using custom controller. A lot of other tools to manipulate data are provided by API Platform such as data persister, voters or normalization context. Even if API Platform documentation is hard to understand, it’s worth to deep dive into it to find which tool will be the most optimal to create or optimize a wanted behavior, like I did to improve performances using an extension instead of a data provider

Liked this article?