securing-collections skill
Secures API Platform collections using Doctrine extensions for global query filters and link handlers for nested resource ownership validation.
- GitHub stars
- 29
- Files
- 1
- Last commit
- Oct 22, 2026
- License
- —
- Allowed tools
- —
What does the securing-collections skill do?
securing-collections secures API Platform collections using Doctrine extensions for global query filters and link handlers for nested resource ownership validation.
Summary generated from the skill's SKILL.md.
About the securing-collections skill
Implementing multi-tenant isolation and data privacy within API Platform is what this skill enables. Developers can restrict data access at the query level to ensure users only see records belonging to their own organization or account. This is achieved through two primary mechanisms: Doctrine extensions for global query filters and link handlers for validating ownership in nested resource URIs.
An agent should load this skill when a project requires soft-delete filtering, scoping queries by user, or securing hierarchical paths like accounts and mailboxes. For Symfony users, the skill provides implementations for both Doctrine ORM and MongoDB ODM, utilizing autowiring for extensions. For Laravel environments, it provides guidance on using the Eloquent QueryExtensionInterface and the specific Options configuration required for Eloquent models. The skill produces structured code patterns that prevent unauthorized data exposure by modifying the underlying QueryBuilder or aggregation pipeline before data is fetched.
What an agent can do with it
- 1Enforce multi-tenant data isolationApply a global filter so that every query for a specific resource is automatically scoped to the user's current organization.
- 2Implement soft-delete functionalityAutomatically hide records that have been marked as deleted from all collection and item queries.
- 3Secure nested resource URIsValidate that a child resource belongs to the correct parent resource when accessing nested API paths.
- 4Configure Laravel Eloquent filtersSet up global query extensions for Laravel projects using the specific QueryExtensionInterface and service tagging requirements.
Written from the skill's SKILL.md. Every use case is checked against a quote from it.
How to install the securing-collections skill
Each command copies the skill folder into the client's skills directory. Restart the client or start a new session to load it.Install the securing-collections skill in Plugin
/plugin marketplace add api-platform/skillset /plugin install api-platform@api-platform-skillset
Install the securing-collections skill in Claude Code
~/.claude/skills/securing-collections/SKILL.md
$ npx degit api-platform/skillset/skills/securing-collections ~/.claude/skills/securing-collectionsInstall the securing-collections skill in Claude Code (project)
.claude/skills/securing-collections/SKILL.md
$ npx degit api-platform/skillset/skills/securing-collections .claude/skills/securing-collectionsInstall the securing-collections skill in Codex
~/.agents/skills/securing-collections/SKILL.md
$ npx degit api-platform/skillset/skills/securing-collections ~/.agents/skills/securing-collectionsInstall the securing-collections skill in Gemini CLI
~/.gemini/skills/securing-collections/SKILL.md
$ npx degit api-platform/skillset/skills/securing-collections ~/.gemini/skills/securing-collectionsInstall the securing-collections skill in Claude.ai
npx degit api-platform/skillset/skills/securing-collections securing-collections && zip -r securing-collections.zip securing-collections # claude.ai → Customize → Skills → + → Create skill → Upload a skill → securing-collections.zip
SKILL.md
View on GitHubSecuring Collections
Two mechanisms restrict what data users can access at the query level.
Doctrine Extensions (Global Query Filters)
Extensions automatically modify every query for a resource class. Use them for:
- Multi-tenant isolation (filter by organization/user)
- Hiding soft-deleted records
- Any filter that must apply to ALL queries (item + collection)
ORM Extension
<?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.
MongoDB ODM Extension
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 (Nested Resource Security)
Link handlers validate parent resource ownership for nested URIs like /accounts/{accountId}/mailboxes/{mailboxId}/messages.
MongoDB ODM Link Handler
<?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']);
}
}
}
Assigning to Resource
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,
),
),
]
)]
When to Use Which
| 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.
Laravel (Eloquent)
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.
Files in this skill
1securing-collections skill questions
How do I install the securing-collections skill in Claude Code?
Run npx degit api-platform/skillset/skills/securing-collections ~/.claude/skills/securing-collections. Claude Code loads skills from ~/.claude/skills (all projects) or .claude/skills (one project) and uses a skill when its description matches the task.
Which clients can use the securing-collections skill?
Any client that reads the Agent Skills format: Claude Code, Codex, Gemini CLI and claude.ai (upload as a ZIP). Scripts in the skill run only where the client allows code execution.
How do I implement global query filters in Laravel with API Platform?
Implement the QueryExtensionInterface and tag it in your service provider, as Laravel does not support Symfony's autowiring for this.
Can I use these mechanisms together?
Yes, they are designed to stack: an extension filters the query, a link handler scopes it to the parent, and security attributes validate the final object.