Search by

letkode / list-query-bundle

letkode.app

List query for Symfony applications: filtering, sorting, search and pagination DTOs

Package info

github.com/letkode/list-query-bundle

Type:symfony-bundle

pkg:composer/letkode/list-query-bundle

Statistics

Installs: 45

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-11-01 22:24 UTC

This package is auto-updated.

Last update: 2026-11-02 00:47:44 UTC


README

Filtering, sorting, search and pagination for list endpoints in Symfony applications: parse the query string into typed DTOs, declare which fields a client may filter or sort by, and return the page with its metadata. Framework-agnostic beyond symfony/validator, usable in table listings, dashboards or any other list surface.

A list request flows through three steps, each with its own namespace:

?page=2&sort=legalName&filters[...]   Request\ + Factory\ + Filter\FilterParser   ->  ListQueryRequest
declared allowlists                   Filter\FilterInput, Sort\SortInput            ->  what a client may touch
the page you return                   Result\PaginatedResult, PaginationMeta         ->  data + pagination

Successor of letkode/query-filter-bundle 2.1.0. See the CHANGELOG for the rename table.

Installation

composer require letkode/list-query-bundle

Symfony Flex will register the bundle automatically. If not using Flex, add it manually:

// config/bundles.php
return [
    Letkode\ListQueryBundle\LetkodeListQueryBundle::class => ['all' => true],
];

Filter/

FilterCriteria

A single resolved filter condition.

use Letkode\ListQueryBundle\Filter\FilterCriteria;

$criteria = new FilterCriteria(field: 'firstName', operator: 'is', values: ['Ana']);

FilterInput

Declares how a filterable field should be typed and cast.

use Letkode\ListQueryBundle\Filter\FilterInput;

$input = FilterInput::text();          // no casting
$input = FilterInput::bool();          // 'true'/'false' -> bool
$input = FilterInput::int();
$input = FilterInput::float();
$input = FilterInput::number();        // float, for numeric comparisons
$input = FilterInput::date();          // string -> DateTimeImmutable
$input = FilterInput::array();

$input->castValue('42.5');             // typed value
$input->castValues(['1', '2']);        // list<mixed>

$input->type is a FilterCastType enum (Text, Bool, Int, Float, Number, Date, ArrayType), which owns the casting rule via $input->type->cast($value).

Where a filter points: alias, property and propertyCase

A filter key (legal_name) is not always how the field it targets is spelled (legalName), nor does it always live on the root of the query. A FilterInput says where it points with two optional arguments, and the consumer joins them (alias.property):

  • alias: the query alias that owns the field (e.g. a joined entity). When omitted, the consumer uses its own default (the root alias).
  • property: the field's name. When omitted, $input->resolveProperty($key) converts the key to the input's own propertyCase, or failing that, to the global property_case. An explicit property is never converted.
FilterInput::text();                                      // 'legal_name' -> legalName (global case)
FilterInput::text(alias: 'c');                            // c.legalName
FilterInput::array(alias: 'rp', property: 'uuid');        // rp.uuid
FilterInput::bool(property: 'enabled');                   // <root>.enabled, key can be 'active'
FilterInput::text(propertyCase: PropertyCase::Snake);     // this input only

When the filter is not a plain column (a concatenation, UNACCENT(...), a DQL function...), declare it with expression. It is raw DQL, used as-is, so write it yourself in the repository and never build it from request input. It cannot be combined with alias, property or propertyCase (an InvalidArgumentException is thrown):

FilterInput::text(expression: "CONCAT(u.firstName, ' ', u.lastName)");

FilterParser

Parses raw filter arrays (as sent by a frontend) into ParsedFilters: ->criteria holds the well-formed list<FilterCriteria>, ->rejected holds a list<QueryParameterRejection> for entries that were structurally malformed (value not an array, entry not an array, missing/non-string op).

use Letkode\ListQueryBundle\Filter\FilterParser;

$parsed = FilterParser::parse([
    'firstName' => [['op' => 'is', 'value' => ['Ana']]],
]);
// $parsed->criteria, $parsed->rejected

Sort/

SortInput

The sorting counterpart of FilterInput. It is the value of a sortable entry whose array key is the public name the client sends in sort, so the query alias never reaches the API. Plain-string entries are not supported: every sortable field is declared with a SortInput.

use Letkode\ListQueryBundle\Sort\SortInput;

$sortable = [
    'createdAt'    => SortInput::field(),                                  // <root>.createdAt
    'legalName'    => SortInput::field(alias: 'c'),                        // ?sort=legalName -> c.legalName
    'company_name' => SortInput::field(alias: 'c', property: 'name'),      // explicit property
    'fullName'     => SortInput::field(expression: "CONCAT(u.firstName, ' ', u.lastName)"),
];

alias, property and propertyCase behave as in FilterInput; $input->resolveProperty($key) returns the property. expression is raw DQL ordered by as-is, never built from request input, and cannot be combined with alias, property or propertyCase (InvalidArgumentException).

Naming/

PropertyCase is None (key used as-is), Camel or Snake; the converters accept any input spelling (legal_name, legal-name, legalName), so only the target is declared. FilterInput and SortInput can each override the case with their own propertyCase argument. Set the global default once:

# config/packages/letkode_list_query.yaml
letkode_list_query:
    property_case: camel   # none (default) | camel | snake

The example file ships with the package: bin/console letkode:config:publish list-query. This bundle only resolves the property; building the query path (alias included) is up to the consumer (e.g. letkode/orm-toolkit-bundle).

Request/

ListQueryStringRequest

Validated DTO to bind directly from a query string.

use Letkode\ListQueryBundle\Request\ListQueryStringRequest;

$request = new ListQueryStringRequest(page: 1, perPage: 20, q: 'search', sort: 'name', dir: 'asc');

ListQueryRequest

Normalized query: page, perPage, search term, sort, direction and parsed filters.

use Letkode\ListQueryBundle\Request\ListQueryRequest;

$query = ListQueryRequest::fromArray($request->query->all());
// $query->page, $query->perPage, $query->q, $query->sort, $query->dir, $query->filters
// $query->rejected: list<QueryParameterRejection> for malformed filter entries

page is at least 1, perPage is clamped to 1..100 (default 20), and an invalid dir falls back to asc.

Factory/

ListQueryRequestFactory

Builds a ListQueryRequest from a validated ListQueryStringRequest.

use Letkode\ListQueryBundle\Factory\ListQueryRequestFactory;

$query = ListQueryRequestFactory::build($queryStringRequest);

Result/

PaginatedResult

Returned by the repository layer: the paginated data plus its pagination metadata.

use Letkode\ListQueryBundle\Result\PaginatedResult;

$result = new PaginatedResult(data: $items, total: 120, page: 1, perPage: 20);
$result->totalPages; // computed, e.g. 6 (0 when perPage is 0)

PaginationMeta

Pagination metadata only, without the data, for API responses that expose pagination separately from the result set.

use Letkode\ListQueryBundle\Result\PaginationMeta;

$pagination = new PaginationMeta(
    total: $result->total,
    perPage: $result->perPage,
    totalPages: $result->totalPages,
    page: $result->page,
);

Exception/

UndeclaredQueryParameterException

Thrown when a query carries parameters the consumer did not declare (unknown sort/filter field, unsupported operator) or that are malformed. It carries every rejection at once (->rejections: a list of QueryParameterRejection with parameter, reason and value).

It is an HTTP status exception of letkode/http-exception-bundle (it extends AbstractHttpStatusException), so that bundle's ExceptionListener answers it with:

{
  "success": false,
  "message": "The query contains undeclared or invalid parameters.",
  "status": 422,
  "errorCode": "INVALID_QUERY_PARAMETERS",
  "errors": {
    "sort": ["Sorting by \"secret\" is not allowed."],
    "filters.etapa": ["Filtering by \"etapa\" is not allowed."]
  }
}
  • errors is keyed by the rejected parameter (dot notation: sort, filters.etapa); a parameter rejected more than once has several messages.
  • Messages and the top-level message come from the list_query translation domain (list_query.<reason>; English and Spanish are included, override any key in your app's translations/list_query.<locale>.yaml). Each message is a RejectionMessage, a Symfony TranslatableInterface.
  • The class is not final: extend it and override defaultErrorCode() to change the code.
  • If your application renders exceptions with its own listener instead, read ->rejections and build the response yourself, or handle HttpStatusExceptionInterface with its ErrorsOption.

Requirements

  • PHP ^8.4
  • letkode/http-exception-bundle ^1.2
  • letkode/config-publisher-bundle ^1.0
  • Symfony ^7.0 || ^8.0 (config, dependency-injection, http-kernel, validator, yaml, translation-contracts)

Migrating from letkode/query-filter-bundle

Behavior is identical; only names changed. The full old-to-new table is in the CHANGELOG.

License

MIT, see LICENSE.