How to paginate and transform your API response data with Fractal and Pagerfanta

When you create a public API, frequently you want to add a presentation layer and a transformation layer for the output of complex data. Further, combining those layers with pagination can be a challenge. This blog post discusses how you can successfully complete this task.

Nebojsa Stojanovic
Fullstack Developer

First, you need an example to work with for this discussion. This discussion uses an API that 2amigos has been working on recently as a reference. It’s an application based upon Slim, which uses Doctrine for a database abstraction layer, Fractal for data transformation, and PagerFanta for data pagination.

All of these are great libraries, but there wasn’t an explanation about combining them together that met 2amigos’s needs.

First, install the following on your local machine if you haven’t already.

  • Slim framework
  • Composer
  • PHP 7.x (any version of PHP that’s 7 or higher)

Installing Dependencies

Make certain that you add these dependencies with Composer:

composer require doctrine/dbal
composer require league/fractal
composer require whiteoctober/pagerfanta

URL Parameters Parser Class

To use our paginator, the API consumer would need to send URL parameters to define the current page and the number of items per page that are wanted. You also need an option to order the paginated result.

2amigos chose the following format, but the parser can be modified to accept a different format if you require it.

http://xxxx.xxx/documents/list?filter=limit(5|2):order(created_at|desc)

Let’s take a closer look at this example, you want five items, a second page, and you want the items to be ordered by the created_at field in descending order.

Before moving on to pagination, the URL parameters below should be parsed, as follows.


final class QueryParametersParser
{
   /**
    * @param Request $request
    * @return array
    */
   public static function parse(Request $request): array
   {
       $data = [];

       $filter = $request->getParam('filter');
       if (null !== $filter) {
           $params = array_pad(explode(':', $filter, 3), 3, null);

           foreach ($params as $param) {
               if (!empty($param)) {
                   $paramHolder = [];
                   preg_match_all('/([\w]+)(\(([^\)]+)\))?/', $param, $paramHolder);
                   $pipe = array_slice(explode('|', $paramHolder[3][0], 2), 0, 2);

                   $data[$paramHolder[1][0]] = $pipe;
               }
           }
       }
       return $data;
   }
}

The data that’s returned appears as below.

Array
(
    [limit] => Array
        (
            [0] => 10
            [1] => 1
        )

    [order] => Array
        (
            [0] => created_at
            [1] => desc
        )

)

Now that the parameters are parsed and they are ready for pagination, the next step is querying the data with Doctrine DBAL.

Database Query

Use parsed query parameters to order and, later paginate, results. If you have the order key, it will have two items and you can use the built-in orderBy method.


/**
* @param array $queryParams
* @return array
*/
public function findAll(array $queryParams = []): array
{
   $queryBuilder = new QueryBuilder($this->connection);
   $queryBuilder->select('`id`, BIN_TO_UUID(uuid) AS `uuid`, `name`, `tag`, `status`, `storage`, `path`')
       ->from('storage_document AS sd');

   if (!empty($queryParams['order'][0]) && !empty($queryParams['order'][1])) {
       $queryBuilder->orderBy($queryParams['order'][0], $queryParams['order'][1]);
   }

   return $this->pagerfantaPaginator->paginate($queryBuilder, $queryParams);
}

NOTE: Ordering needs to be done before you start the pagination because you want to paginate ordered items, not order them when they are already paginated.

PagerFanta Paginator Class


use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Pagerfanta\Adapter\DoctrineDbalAdapter;
use Pagerfanta\Adapter\DoctrineDbalSingleTableAdapter;
use Pagerfanta\Pagerfanta;

final class PagerfantaPaginator extends AbstractPaginator
{
   /**
    * @param QueryBuilder $queryBuilder
    * @param array $params
    * @return array
    */
   public function paginate(QueryBuilder $queryBuilder, array $params): array
   {
       $adapter = $this->getAdapter($queryBuilder);
       $paginator = new Pagerfanta($adapter);

       if (isset($params['limit'][0], $params['limit'][1])) {
           $paginator->setMaxPerPage($params['limit'][0]);
           $paginator->setCurrentPage($params['limit'][1]);
       }

       $items = $paginator->getCurrentPageResults();

       return [
           'items' => $items,
           'paginator' => $paginator,
       ];
   }

   /**
    * @param QueryBuilder $queryBuilder
    * @return DoctrineDbalAdapter|DoctrineDbalSingleTableAdapter
    */
   private function getAdapter(QueryBuilder $queryBuilder)
   {
       return !$this->hasQueryBuilderJoins($queryBuilder)
           ? new DoctrineDbalSingleTableAdapter($queryBuilder, 'sd.id')
           :  new DoctrineDbalAdapter($queryBuilder, function ($queryBuilder) {
               return $queryBuilder->select('COUNT(DISTINCT sd.id) AS total_results')
                   ->setMaxResults(1);
           });
   }

   /**
    * @param QueryBuilder $queryBuilder
    * @return bool
    */
   private function hasQueryBuilderJoins(QueryBuilder $queryBuilder)
   {
       $joins = $queryBuilder->getQueryPart('join');
       return !empty($joins);
   }
}

In this class, the paginate() method receives a QueryBuilder object and the parsed query parameters from QueryParametersParser.

Be certain to check whether or not the QueryBuilder has any table joins. Depending upon that, use either DoctrineDbalSingleTableAdapter or DoctrineDbalAdapter. The latter is a little more complicated and contains a callback function.

NOTE: This example uses the same main table alias for all the queries.

If you want to use a custom alias, make sure you send it to the paginate function as an additional parameter and use it here when you are instantiating Doctrine Adapter.

Once your data is ready, you need to send it back to the controller so that you can transform it with Fractal.

Transforming Data


$response = $this->createCollection(
   $data['items'],
   $request,
   new DocumentTransformer,
   null,
   $data['paginator']
);

The example code above demonstrates how the returned data is transformed by the DocumentTransformer. DocumentTransformer is an instance of Fractal’s Transformer. If you need to know more about it, read the documentation in this link — https://fractal.thephpleague.com/transformers/


/**
* @param array $data
* @param $request
* @param null $callback
* @param string|null $namespace
* @param null $paginator
* @return array
*/
protected function createCollection(array $data, $request, $callback = null, string $namespace = null, $paginator = null): array
{
   if (null === $callback || !$callback instanceof TransformerAbstract || !is_callable([$callback, 'transform'])) {
       $callback = function ($data) {
           return $data;
       };
   }

   $resource = new Collection($data, $callback, $namespace); //League\Fractal\Resource\Collection;

   if (!empty($paginator)) {
       $paginatorAdapter = new PagerfantaPaginatorAdapter($paginator, function(int $page) use ($request) {
           $basePath = $request->getUri()->getBaseUrl();
           $path = $request->getUri()->getPath();
           $newRoute = $basePath . $path . $this->getUpdatedQueryParams($request, $page);
           return $newRoute;
       });

          $resource->setPaginator($paginatorAdapter);
       }
   }

   return $this->fractal->createData($resource)->toArray();
}

To create a collection, you need to check if your callback is an instance of Fractal’s Transfomer. If it is, it should have a callable transform method. After that, you create a Fractal Collection resource with the data. If you don’t want to use pagination, this part isn’t necessary as you could simply let Fractal return the data. Since you want pagination, you need to create the PagerfantaPaginatorAdapter.

You need the instance of Pagerfanta/Pagerfanta that you have and the Slim Request to start. Also, you need a callback function as the second parameter. Your goal with this callback function is to create a link for the next page that can be used by the API consumer.

The getUpdatedQueryParams() method below warrants a closer look.


/**
* @param Request $request
* @param int $page
* @return string
*/
private function getUpdatedQueryParams(Request $request, int $page)
{
   $queryString = '?';

   foreach ($request->getQueryParams() as $key => $params) {
       $parsedArray = QueryParametersParser::parse($request);

       if ($key === 'filter') {
           $queryString .= $key . '=';
           $lastElement = end($parsedArray);
           foreach ($parsedArray as $parsedKey => $parsed) {
               $first = $parsed[0];
               $second = $parsedKey === 'limit' ? $page : $parsed[1];

               $queryString .= $parsedKey .'(' . $first . '|' . $second . ')';
               if ($parsed !== $lastElement) {
                   $queryString .= ':';
               }
           }
       } else {
           $queryString .= '&' . $key .'='. $params;
       }

   }

   return $queryString;
}

In this method, use query parameters from the current request and the current page to generate the following for your example ?filter=limit(5|3):order(created_at|desc).

The current page number is 2 and you are getting query parameters for the next page. Set this created paginator adapter as your resource. Because you used the Transformer and Paginator, Fractal returns data that appears as below.


{
    "data": [
        {},
        {},
        {},
        ...,
    ],
    "meta": {
        "pagination": {
            "total": 17,
            "count": 5,
            "per_page": 5,
            "current_page": 2,
            "total_pages": 4,
            "links": {
                "previous": "http://xxxx.xxx/documents/list?filter=limit(5|1):order(created_at|desc)",
                "next": "http://xxxx.xxx/documents/list?filter=limit(5|3):order(created_at|desc)"
            }
        }
    }

}

Since you used PagerfantaPaginatorAdapter, it automatically creates links for the previous and the next page. You also have all the data necessary to create similar, beautifully-styled pagination links on the frontend as well.

Finally, you have a fully working PagerFanta paginator that you can use in your API. Remember that you can modify it to suit your specific needs by:

  • using a different parameters parser
  • adding new query parameters