jul6art / api-bundle
API Platform filters and state providers, extracted from a working application
Package info
Type:symfony-bundle
pkg:composer/jul6art/api-bundle
Requires
- php: ^8.5
- api-platform/core: ^4.3
- doctrine/orm: ^3.5
- jul6art/core-bundle: ^2.9
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Requires (Dev)
- doctrine/doctrine-bundle: ^3.3
- friendsofphp/php-cs-fixer: ^3.68
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^13.0
- rector/rector: ^2.0
- symfony/flex: ^2.4
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/phpunit-bridge: ^7.4 || ^8.0
- symfony/security-bundle: ^7.4 || ^8.0
- symfony/var-dumper: ^7.4 || ^8.0
This package is auto-updated.
Last update: 2026-08-20 09:16:48 UTC
README
Symfony API Platform bundle
Symfony API Platform bundle
Requirements
- PHP ^8.5
- Symfony ^7.4 || ^8.0
Installation
composer require jul6art/api-bundle
Then register it in config/bundles.php (Flex does this for you):
Jul6Art\ApiBundle\ApiBundle::class => ['all' => true],
Configuration
# config/packages/api.yaml api: # Leaves the bundle installed and inert when false. enabled: true
api.enabled is also exposed as a container parameter.
Usage
Six Doctrine ORM filters and one state provider, extracted from an application that runs them.
Everything here is registered the ordinary API Platform way — as a service, referenced from an
#[ApiFilter] — so there is nothing to configure beyond the tenant header below.
Ordering a text column the way a human reads it
#[ApiFilter(CaseInsensitiveOrderFilter::class, properties: ['name', 'email'])] class Contact { … }
ORDER BY name sorts by byte value on PostgreSQL, so Apple, banana, Cherry comes back as
Apple, Cherry, banana and the list looks broken. This filter lowers the column first — and only
for text columns, since lowering an integer is a cast the database does per row for nothing.
⚠️ It does not emit
ORDER BY LOWER(...), and that is deliberate. API Platform's pagination extension parses ORDER BY by splitting on.and taking the first token as an alias, soLOWER(c.name)makes it fail withThe alias "LOWER(c" does not exist. The filter adds a HIDDEN select with a flat alias and orders by that instead. Removing the indirection brings the bug back.
Ordering by business rank rather than alphabet
#[ApiFilter(RankedOrderFilter::class, properties: ['status' => ['todo', 'doing', 'done']])]
ORDER BY status gives doing, done, todo — alphabetical, and meaningless to a user. This orders
by the rank you declare, through a CASE expression with bound values. A value outside the list
sorts last, never first: a status nobody planned for should not open the list.
One sortable property over several columns
#[ApiFilter(ConcatOrderFilter::class, properties: ['fullName' => ['lastName', 'firstName']])]
One search box over several columns
#[ApiFilter(OrSearchFilter::class, properties: ['name', 'reference', 'category.label'])]
?search=term becomes a single OR group. A filter per column would AND them together and only
match rows where every column contains the term.
Three things it handles that a naive implementation does not: a numeric column is cast
(CONCAT(col, '')) before the LIKE, a relation one hop away is reached with a LEFT JOIN — so a
row without a category still matches on its own name — and a non-scalar or empty term filters
nothing rather than producing LIKE '%%'.
Filtering by year
#[ApiFilter(YearFilter::class, properties: ['issuedAt'])]
?issuedAt=2026 becomes a half-open range, >= 2026-01-01 AND < 2027-01-01 — not YEAR(col) = 2026. Two reasons: YEAR() needs a DQL extension to be portable, and wrapping the column in a
function makes any index on it useless, which turns a filtered accounting journal from an index
scan into a full table scan.
Searching inside a JSON column
#[ApiFilter(JsonContainsFilter::class, properties: ['roles'])]
?roles=ROLE_ADMIN casts the column to text and matches it with a portable LIKE.
⚠️ This one needs a DQL function registered.
JSON_TEXT()comes from jul6art/core-bundle, and without the registration below the filter fails at query time with "Expected known function", far from the entity that declared it:# config/packages/doctrine.yaml doctrine: orm: dql: string_functions: JSON_TEXT: Jul6Art\CoreBundle\Doctrine\DQL\JsonTextFunction
A custom provider that keeps pagination, filters and ordering
final class OpenInvoiceProvider extends AbstractCollectionProvider { protected function provideCollection(Operation $operation, array $uriVariables, array $context): ?QueryBuilder { return $this->invoices->createQueryBuilder('i')->andWhere('i.paidAt IS NULL'); } protected function provideItem(Operation $operation, array $uriVariables, array $context): ?Invoice { return $this->invoices->find($uriVariables['id']); } }
Return a QueryBuilder, never an array. API Platform's extensions act on a query builder, so a
provider that returns rows silently ignores every ?page=, ?order= and ?search= a client
sends. Nothing errors — the collection is simply always the first page, unsorted and unfiltered,
which is the kind of bug that reaches production because every test asserts on content rather than
on order.
The class returns an empty collection rather than null when there is nothing to query, hands a
paginator back untouched — materialising it would run the query and lose the total the response
carries — and drops any row that is not an object, because a scalar in a collection response breaks
serialisation somewhere it cannot be traced back from.
The tenant header
# config/packages/api.yaml api: tenant_header: X-ORGANIZATION
Api\ApiHeaders holds the two rate-limit header names, which are conventional. The
tenant-scoping header is not a constant here: its name belongs to the application, renaming it
breaks every client, and it therefore has to be visible in the application's own configuration
rather than buried in a vendor class. Read it from the api.tenant_header parameter — a request
subscriber, an OpenAPI factory and a CORS rule all need the same value, and a literal copied into
one of the three is how they drift.
A note on two of these filters
CaseInsensitiveOrderFilter and RankedOrderFilter restate a good deal of API Platform's own
OrderFilter, which is final and cannot be extended. That is a debt, and the point of this
bundle is to carry it once instead of in every application. The test suite asserts the DQL
those filters produce, so an upstream change that alters the contract shows up here rather than in
a sorted list nobody checks.
Quality assurance
composer qa # cs-check + rector-check + phpstan (level max) + phpunit
Run composer qa, not the single tool you have in mind: the CI's "Coding standards" job runs
Rector too, and its lowest deps job installs the minimum of every constraint — which is where
this ecosystem has repeatedly found what a local run could not.
extra.symfony.require states which Symfony line this bundle targets; the CI enforces it with
SYMFONY_REQUIRE on both the highest and the lowest job. A local composer install may still
resolve a newer Symfony, which broadens what you exercise rather than narrowing it — but it means
the toolchain can propose something that only makes sense on one branch. rector.php skips one
such rule already, with the reason written next to it.
Whatever you do, keep the code free of classes that exist on only one of the declared branches.
A bundle promising ^7.4 || ^8.0 has to hold both.
License
The API bundle is open-sourced software licensed under the MIT license.
© 2026 jul6art
