Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
"license": "MIT",
"require": {
"php": "^8.1",
"pagerfanta/core": "^3.7 || ^4.0",
"pagerfanta/core": "^4.10",
"psr/container": "^1.0 || ^2.0",
"symfony/config": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/dependency-injection": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/http-foundation": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/http-kernel": "^5.4 || ^6.4 || ^7.3 || ^8.0",
"symfony/property-access": "^5.4 || ^6.4 || ^7.3 || ^8.0",
Expand All @@ -19,7 +20,7 @@
"jms/serializer": "^3.18",
"jms/serializer-bundle": "^4.2 || ^5.0",
"matthiasnoback/symfony-dependency-injection-test": "^6.2",
"pagerfanta/twig": "^3.7 || ^4.0",
"pagerfanta/twig": "^4.10",
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan": "2.2.16",
"phpstan/phpstan-phpunit": "2.0.19",
Expand All @@ -34,7 +35,7 @@
"conflict": {
"jms/serializer": "<3.18",
"jms/serializer-bundle": "<4.2",
"pagerfanta/twig": "<3.7",
"pagerfanta/twig": "<4.10",
"symfony/serializer": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3",
"symfony/translation": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3",
"symfony/twig-bridge": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3",
Expand Down
8 changes: 8 additions & 0 deletions config/jms_serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use BabDev\PagerfantaBundle\Serializer\Handler\CursorPagerHandler;
use BabDev\PagerfantaBundle\Serializer\Handler\PagerfantaHandler;

return static function (ContainerConfigurator $container): void {
Expand All @@ -10,4 +11,11 @@
$services->set('pagerfanta.serializer.handler', PagerfantaHandler::class)
->tag('jms_serializer.subscribing_handler')
;

$services->set('pagerfanta.serializer.cursor_handler', CursorPagerHandler::class)
->args([
service('pagerfanta.cursor_encoder'),
])
->tag('jms_serializer.subscribing_handler')
;
};
68 changes: 67 additions & 1 deletion config/pagerfanta.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder;
use BabDev\PagerfantaBundle\Position\PositionResolver;
use BabDev\PagerfantaBundle\RouteGenerator\RequestAwarePositionRouteGeneratorFactory;
use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory;
use BabDev\PagerfantaBundle\View\ContainerBackedImmutableViewFactory;
use Pagerfanta\Cursor\Base64JsonCursorEncoder;
use Pagerfanta\Cursor\CursorEncoderInterface;
use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface;
use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface;
use Pagerfanta\View\DefaultView;
use Pagerfanta\View\Foundation6View;
use Pagerfanta\View\SemanticUiView;
use Pagerfanta\View\SequentialView;
use Pagerfanta\View\Template\DefaultTemplate;
use Pagerfanta\View\Template\Foundation6Template;
use Pagerfanta\View\Template\SemanticUiTemplate;
use Pagerfanta\View\Template\TwitterBootstrap3Template;
use Pagerfanta\View\Template\TwitterBootstrap4Template;
use Pagerfanta\View\Template\TwitterBootstrap5Template;
use Pagerfanta\View\Template\TwitterBootstrapTemplate;
use Pagerfanta\View\TwitterBootstrap3View;
use Pagerfanta\View\TwitterBootstrap4View;
use Pagerfanta\View\TwitterBootstrap5View;
Expand All @@ -17,14 +31,48 @@
return static function (ContainerConfigurator $container): void {
$services = $container->services();

$services->set('pagerfanta.cursor_encoder.base64_json', Base64JsonCursorEncoder::class);

Check failure on line 34 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\Cursor\Base64JsonCursorEncoder not found.

Check failure on line 34 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\Cursor\Base64JsonCursorEncoder not found.

$services->set('pagerfanta.cursor_encoder.signed', SignedCursorEncoder::class)
->args([
service('pagerfanta.cursor_encoder.base64_json'),
param('kernel.secret'),
])
;

$services->alias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed');
$services->alias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder');

Check failure on line 44 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\Cursor\CursorEncoderInterface not found.

Check failure on line 44 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\Cursor\CursorEncoderInterface not found.

$services->set('pagerfanta.position_resolver', PositionResolver::class)
->args([
service('property_accessor'),
service('pagerfanta.cursor_encoder'),
])
;
$services->alias(PositionResolver::class, 'pagerfanta.position_resolver');

$services->set('pagerfanta.route_generator_factory', RequestAwareRouteGeneratorFactory::class)
->args([
service('router'),
service('request_stack'),
service('property_accessor'),
service('pagerfanta.cursor_encoder'),
])
->deprecate('babdev/pagerfanta-bundle', '4.7', 'The "%service_id%" service is deprecated, use the "pagerfanta.position_route_generator_factory" service instead.')
;
$services->alias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory')
->deprecate('babdev/pagerfanta-bundle', '4.7', \sprintf('The "%%alias_id%%" alias is deprecated, use the "%s" alias instead.', PositionRouteGeneratorFactoryInterface::class))

Check failure on line 64 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface not found.

Check failure on line 64 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface not found.
;

$services->set('pagerfanta.position_route_generator_factory', RequestAwarePositionRouteGeneratorFactory::class)
->args([
service('router'),
service('request_stack'),
service('property_accessor'),
service('pagerfanta.cursor_encoder'),
])
;
$services->alias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory');
$services->alias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.position_route_generator_factory');

Check failure on line 75 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface not found.

Check failure on line 75 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface not found.

$services->set('pagerfanta.view.default', DefaultView::class)
->tag('pagerfanta.view', ['alias' => 'default'])
Expand Down Expand Up @@ -54,6 +102,24 @@
->tag('pagerfanta.view', ['alias' => 'twitter_bootstrap5'])
;

foreach ([
'default' => DefaultTemplate::class,
'foundation6' => Foundation6Template::class,
'semantic_ui' => SemanticUiTemplate::class,
'twitter_bootstrap' => TwitterBootstrapTemplate::class,
'twitter_bootstrap3' => TwitterBootstrap3Template::class,
'twitter_bootstrap4' => TwitterBootstrap4Template::class,
'twitter_bootstrap5' => TwitterBootstrap5Template::class,
] as $name => $templateClass) {
$services->set(\sprintf('pagerfanta.view.%s_sequential', $name), SequentialView::class)

Check failure on line 114 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\View\SequentialView not found.

Check failure on line 114 in config/pagerfanta.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\View\SequentialView not found.
->args([
inline_service($templateClass),
\sprintf('%s_sequential', $name),
])
->tag('pagerfanta.view', ['alias' => \sprintf('%s_sequential', $name)])
;
}

$services->set('pagerfanta.view_factory', ContainerBackedImmutableViewFactory::class)
->args([
abstract_arg('service locator'),
Expand Down
8 changes: 8 additions & 0 deletions config/serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use BabDev\PagerfantaBundle\Serializer\Normalizer\CursorPagerNormalizer;
use BabDev\PagerfantaBundle\Serializer\Normalizer\PagerfantaNormalizer;

return static function (ContainerConfigurator $container): void {
Expand All @@ -10,4 +11,11 @@
$services->set('pagerfanta.serializer.normalizer', PagerfantaNormalizer::class)
->tag('serializer.normalizer')
;

$services->set('pagerfanta.serializer.cursor_normalizer', CursorPagerNormalizer::class)
->args([
service('pagerfanta.cursor_encoder'),
])
->tag('serializer.normalizer')
;
};
4 changes: 3 additions & 1 deletion config/twig.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use BabDev\PagerfantaBundle\Twig\UndefinedCallableHandler;
use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface;
use Pagerfanta\Twig\Extension\PagerfantaExtension;
use Pagerfanta\Twig\Extension\PagerfantaRuntime;
use Pagerfanta\Twig\View\TwigView;
Expand All @@ -18,7 +19,8 @@
->args([
abstract_arg('default view'),
service('pagerfanta.view_factory'),
service('pagerfanta.route_generator_factory'),
service(PositionRouteGeneratorFactoryInterface::class),

Check failure on line 22 in config/twig.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface not found.

Check failure on line 22 in config/twig.php

View workflow job for this annotation

GitHub Actions / PHPStan

Class Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface not found.
abstract_arg('default sequential view'),
])
->tag('twig.runtime')
;
Expand Down
20 changes: 19 additions & 1 deletion docs/configuring-the-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ babdev_pagerfanta:
default_view: my_view
```

## Default Sequential View

<div class="docs-note docs-note--new-feature">The default sequential view was introduced in PagerfantaBundle 4.7.</div>

Views with numbered page links can only render offset pagers. When the default view (or the view given to the `pagerfanta()` Twig function) cannot render a pager, such as a [cursor pager](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination), the Twig function renders it with the default sequential view instead.

The default sequential view can be set with the `default_sequential_view` configuration node. When not set, the sequential variant of the default view is used if one exists (i.e. `twitter_bootstrap5_sequential` for the `twitter_bootstrap5` view). The Twig view can render any pager, so it does not need a default sequential view.

```yaml
# config/packages/babdev_pagerfanta.yaml
babdev_pagerfanta:
default_view: twitter_bootstrap5
default_sequential_view: twitter_bootstrap5_sequential
```

## Default Twig Template

The default Twig template for Twig views in your application can be set with the `default_twig_template` configuration node. This defaults to "`@BabDevPagerfanta/default.html.twig`".
Expand All @@ -23,12 +38,15 @@ babdev_pagerfanta:

## Exception Strategies

By default, the bundle converts `Pagerfanta\Exception\NotValidCurrentPageException` and `Pagerfanta\Exception\NotValidMaxPerPageException` exceptions into 404 responses. If you would like to disable or change this behavior, you can change the strategies using the `exceptions_strategy` node by setting the value to "custom" for each behavior you want to change.
By default, the bundle converts `Pagerfanta\Exception\NotValidCurrentPageException` and `Pagerfanta\Exception\NotValidMaxPerPageException` exceptions into 404 responses, and `Pagerfanta\Exception\InvalidCursorException` exceptions into 400 responses. If you would like to disable or change this behavior, you can change the strategies using the `exceptions_strategy` node by setting the value to "custom" for each behavior you want to change.

```yaml
# config/packages/babdev_pagerfanta.yaml
babdev_pagerfanta:
exceptions_strategy:
out_of_range_page: custom # Disables converting `Pagerfanta\Exception\NotValidMaxPerPageException` to a 404 response
not_valid_current_page: to_http_not_found # Default behavior converting `Pagerfanta\Exception\NotValidCurrentPageException` to a 404 response
invalid_cursor: to_http_bad_request # Default behavior converting `Pagerfanta\Exception\InvalidCursorException` to a 400 response
```

<div class="docs-note docs-note--new-feature">The <code>invalid_cursor</code> exception strategy was introduced in PagerfantaBundle 4.7.</div>
108 changes: 108 additions & 0 deletions docs/cursor-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Cursor Pagination

<div class="docs-note docs-note--new-feature">Cursor pagination support was introduced in PagerfantaBundle 4.7.</div>

The bundle integrates the [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) support from Pagerfanta with your Symfony application:

- Cursors in URLs are signed, so they cannot be tampered with
- The position of the current page is resolved from the request with the `BabDev\PagerfantaBundle\Position\PositionResolver`
- Invalid cursors are converted into 400 responses
- Cursor pagers are rendered with the [sequential views](/open-source/packages/pagerfantabundle/docs/4.x/views#sequential-views) and serialized with their cursors

## Resolving The Current Page

The `BabDev\PagerfantaBundle\Position\PositionResolver` service reads the position of the current page from the request. It has a method for each kind of pager, which ignores the parameters for the other kind:

- `resolveCursorPosition()` returns the `Pagerfanta\Position\CursorPosition` from the `cursor` parameter, or null for the first page
- `resolvePagePosition()` returns the `Pagerfanta\Position\PagePosition` from the `page` parameter, or null for the first page
- `resolve()` returns either position, preferring the cursor when both parameters are given

Below is an example of paginating blog posts with a cursor pager.

```php
<?php

namespace App\Controller;

use App\Entity\BlogPostRepository;
use BabDev\PagerfantaBundle\Position\PositionResolver;
use Pagerfanta\CursorPagerfantaFactory;
use Pagerfanta\Doctrine\ORM\CursorQueryAdapter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

final class BlogController extends AbstractController
{
#[Route(path: '/blog', name: 'app_blog_list', methods: ['GET'])]
public function listPosts(Request $request, BlogPostRepository $blogPostRepository, PositionResolver $positionResolver): Response
{
$queryBuilder = $blogPostRepository->createQueryBuilder('p')
->orderBy('p.publishedAt', 'DESC')
->addOrderBy('p.id', 'DESC');

$pager = CursorPagerfantaFactory::create(
new CursorQueryAdapter($queryBuilder),
10,
$positionResolver->resolveCursorPosition($request),
);

return $this->render(
'blog/list.html.twig',
[
'pager' => $pager,
],
);
}
}
```

The parameters are read from the query string and the route parameters of the request. If your application uses different parameters, set the `cursorParameter` and `pageParameter` options, which use the same format as the [route generator options](/open-source/packages/pagerfantabundle/docs/4.x/generating-paginated-routes#position-route-generator-options).

```php
$position = $positionResolver->resolveCursorPosition($request, ['cursorParameter' => '[after]']);
```

When the page parameter is not a positive integer, a `Pagerfanta\Exception\NotValidCurrentPageException` is thrown, which is handled by the `not_valid_current_page` [exception strategy](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#exception-strategies).

## Signed Cursors

The bundle encodes cursors with the `BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder`, which appends a HMAC-SHA256 signature using the `kernel.secret` parameter to the cursors encoded by the `Pagerfanta\Cursor\Base64JsonCursorEncoder`. The signature is verified before a cursor is decoded, so a cursor which has been altered (or was not created by your application) is rejected with a `Pagerfanta\Exception\InvalidCursorException` and never reaches your query.

The encoder is available as the `pagerfanta.cursor_encoder` service, and can be autowired with the `Pagerfanta\Cursor\CursorEncoderInterface`. It is used by the position resolver, the route generators, and the serializers.

<div class="docs-note">Changing the <code>kernel.secret</code> parameter invalidates all previously generated cursors, clients using a cursor from before the change will receive a 400 response.</div>

To use another encoder, such as one which encrypts the cursors, point the `pagerfanta.cursor_encoder` alias to your service.

```yaml
# config/services.yaml
services:
pagerfanta.cursor_encoder:
alias: App\Pagination\EncryptedCursorEncoder
```

## Invalid Cursors

By default, the bundle converts a `Pagerfanta\Exception\InvalidCursorException` into a 400 response. This exception is thrown for cursors which cannot be decoded, fail signature verification, or do not match the sort fields of the adapter. See the [exception strategies](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#exception-strategies) to change this behavior.

## Rendering Cursor Pagers

Cursor pagers are rendered with the `pagerfanta()` Twig function, the same as offset pagers. As cursor pagers can only link to the previous and next pages, they are rendered with a [sequential view](/open-source/packages/pagerfantabundle/docs/4.x/views#sequential-views).

```twig
{{ pagerfanta(pager) }}
```

The URLs are generated for the current route, setting the signed cursor to the `cursor` parameter and removing the `page` parameter. The `pagerfanta_position_url()` Twig function generates the URL for a single position, such as a "Load more" link.

```twig
{% if pager.hasNextPage() %}
<a href="{{ pagerfanta_position_url(pager.nextPosition) }}">Load more</a>
{% endif %}
```

## APIs

Cursor pagers are [serialized](/open-source/packages/pagerfantabundle/docs/4.x/serializer#cursor-pagers) with the signed cursors for the previous and next pages, which clients pass back in the `cursor` parameter to request those pages.
6 changes: 6 additions & 0 deletions docs/default-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ babdev_pagerfanta:
# The default Pagerfanta view to use in your application
default_view: default

# The view to render pagers which the default view cannot render (i.e. cursor pagers with a numbered view), defaults to the "<default_view>_sequential" view when one exists
default_sequential_view: null

# The default Twig template to use when using the Twig Pagerfanta view
default_twig_template: '@BabDevPagerfanta/default.html.twig'

Expand All @@ -14,4 +17,7 @@ babdev_pagerfanta:

# The exception strategy if the current page is not an allowed value in a paginated list; valid options are "custom" or "to_http_not_found"
not_valid_current_page: to_http_not_found

# The exception strategy if a cursor cannot be decoded or fails validation (i.e. a tampered cursor); valid options are "custom" or "to_http_bad_request"
invalid_cursor: to_http_bad_request
```
Loading
Loading