From api-platform
Secures API Platform collections using Doctrine query extensions and link handlers. Enforces multi-tenant isolation, soft-delete filtering, and parent ownership validation at the query level.
How this skill is triggered — by the user, by Claude, or both
Slash command
/api-platform:securing-collectionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Two mechanisms restrict what data users can access at the query level.
Two mechanisms restrict what data users can access at the query level.
Extensions automatically modify every query for a resource class. Use them for:
<?php
namespace App\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
final class AccountExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private UserHelper $userHelper) {}
public function applyToCollection(QueryBuilder $qb, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($qb, $resourceClass);
}
public function applyToItem(QueryBuilder $qb, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($qb, $resourceClass);
}
private function addWhere(QueryBuilder $qb, string $resourceClass): void
{
if (Account::class !== $resourceClass) {
return;
}
$user = $this->userHelper->getUser();
if (!$user) {
return;
}
$rootAlias = $qb->getRootAliases()[0];
$qb->andWhere(sprintf('%s.organization = :org', $rootAlias))
->andWhere(sprintf('%s.isDeleted = :deleted', $rootAlias))
->setParameter('org', $user->getCurrentOrganization())
->setParameter('deleted', false);
}
}
Extensions are auto-registered by Symfony's autowiring. No service tag needed.
Same structure as the ORM version; only the interfaces, the apply-method
signatures, and the query API differ. Implement
AggregationCollectionExtensionInterface / AggregationItemExtensionInterface
(from ApiPlatform\Doctrine\Odm\Extension), and build the filter on a
Doctrine\ODM\MongoDB\Aggregation\Builder instead of a QueryBuilder:
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
$this->applyFilters($aggregationBuilder, $resourceClass);
}
// applyToItem adds `array $identifiers` after $resourceClass; both delegate here:
private function applyFilters(Builder $aggregationBuilder, string $resourceClass): void
{
if (Account::class !== $resourceClass) {
return;
}
$user = $this->userHelper->getUser();
if (!$user) {
return;
}
$aggregationBuilder->match()
->field('isDeleted')->equals(false)
->field('organization')->equals($user->getCurrentOrganization()->getId());
}
The ODM apply methods take string $resourceClass directly (no
QueryNameGenerator) and receive $context by reference.
Link handlers validate parent resource ownership for nested URIs like /accounts/{accountId}/mailboxes/{mailboxId}/messages.
<?php
namespace App\Extension;
use ApiPlatform\Doctrine\Odm\State\LinksHandlerInterface;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class MessageLinkHandler implements LinksHandlerInterface
{
public function __construct(
private readonly UserHelper $userHelper,
private readonly MailboxRepository $mailboxRepository,
) {}
public function handleLinks(Builder $aggregationBuilder, array $uriVariables, array $context): void
{
$user = $this->userHelper->getUser() ?? throw new AccessDeniedHttpException();
// Validate parent resource exists
$mailbox = $this->mailboxRepository->find($uriVariables['mailboxId'])
?? throw new NotFoundHttpException();
// Validate parent belongs to correct account
if ($mailbox->account->getId() !== ($uriVariables['accountId'] ?? null)) {
throw new NotFoundHttpException();
}
// Validate user has access to this resource tree
if ($mailbox->organization?->getId() !== $user->getCurrentOrganization()?->getId()) {
throw new AccessDeniedHttpException();
}
// Apply query filters
$aggregationBuilder->match()
->field('isDeleted')->equals(false)
->field('mailbox')->equals($mailbox);
// Filter individual item if ID provided
if (isset($uriVariables['id'])) {
$aggregationBuilder->match()
->field('id')->equals($uriVariables['id']);
}
}
}
use ApiPlatform\Doctrine\Odm\State\Options;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/accounts/{accountId}/mailboxes/{mailboxId}/messages/{id}',
uriVariables: ['accountId', 'mailboxId', 'id'],
stateOptions: new Options(
handleLinks: MessageLinkHandler::class,
documentClass: Message::class,
),
),
]
)]
| Pattern | Use Case |
|---|---|
| Extension | Global filters: multi-tenant isolation, soft-delete, applies to ALL queries for a resource |
| Link Handler | Nested resources: validate parent ownership, scope child queries to parent |
security attribute | Per-operation checks on already-fetched objects: security: 'object.user == user' |
These mechanisms stack: an extension filters the query, a link handler scopes it to the parent, and security validates the final object.
The two mechanisms exist on Laravel too, but there are no Doctrine extensions.
The global-query-filter equivalent is ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface
— one apply() method covering both item and collection (the Eloquent providers run
it for every query):
<?php
namespace App\Extension;
use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface;
use ApiPlatform\Metadata\Operation;
use Illuminate\Database\Eloquent\Builder;
final class AccountExtension implements QueryExtensionInterface
{
public function __construct(private readonly UserHelper $userHelper) {}
public function apply(Builder $builder, array $uriVariables, Operation $operation, $context = []): Builder
{
if (Account::class !== $operation->getClass() || !($user = $this->userHelper->getUser())) {
return $builder;
}
return $builder
->where('organization_id', $user->getCurrentOrganization()->id)
->where('is_deleted', false);
}
}
Tag it so the providers pick it up by binding to the QueryExtensionInterface tag in
a service provider ($this->app->tag([AccountExtension::class], QueryExtensionInterface::class));
there is no Symfony autowiring.
For nested-resource ownership, implement
ApiPlatform\Laravel\Eloquent\State\LinksHandlerInterface —
handleLinks(Builder $builder, array $uriVariables, array $context): Builder —
returning the scoped builder (it operates on an Eloquent Builder, not an aggregation
pipeline). Assign it with the Eloquent Options:
use ApiPlatform\Laravel\Eloquent\State\Options;
new Get(
uriTemplate: '/accounts/{accountId}/messages/{id}',
uriVariables: ['accountId', 'id'],
stateOptions: new Options(handleLinks: MessageLinkHandler::class, modelClass: Message::class),
)
Note Eloquent Options uses modelClass: (not documentClass:/entityClass:).
Per-operation security expressions and Laravel policies (viewAny/view/
create/update/delete, or an explicit policy: on an operation) layer on top —
see the operations skill's Laravel section.
npx claudepluginhub api-platform/skillset --plugin api-platformAdds search, sort, date range, boolean, enum, numeric, IRI, and free-text filters to API Platform collections using the QueryParameter approach. Covers migration from legacy #[ApiFilter] code.
Secure API Platform resources with Symfony security expressions, voters, securityPostValidation, and operation-level access control.
Designs JSON Schema collections and CRUD patterns for Falcon Foundry NoSQL document stores. Useful when creating collections, defining schemas, or setting up FQL queries and indexable fields.