Events

Query Builder Event

Each connection may listen for a QueryBuilder event. The event has a getQueryBuilder() method to retrieve the Doctrine QueryBuilder object before it is executed. Listen to this event and modify the QueryBuilder to customize the query of that connection, such as to filter its data for the logged in user.

Warning

A QueryBuilder event applies only to the connection, or the collection, which dispatches it. The same rows can be reached in other ways: through a to-one association, which has no event, and through a collection whose association has no eventName. A listener on a connection of artists does not restrict the artist of a performance. A QueryBuilder event alone is therefore not a security layer.

For row level security, use a Doctrine SQL filter. It applies to every query the driver runs: connections, collections, batched or not, their counts and the loading of to-one associations. A to-one association to a row the filter excludes is null, with an error the client sees as Internal server error.

Event names are passed as a second parameter to a $driver->resolve().

In the code below, the event Artist::class . '.queryBuilder' will fire:

use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use App\ORM\Entity\Artist;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Schema;

$schema = new Schema([
  'query' => new ObjectType([
      'name' => 'query',
      'fields' => [
          'artists' => [
              'type' => $driver->connection(Artist::class),
              'args' => [
                  'filter' => $driver->filter(Artist::class),
              ],
              'resolve' => $driver->resolve(
                  Artist::class,
                  Artist::class . '.queryBuilder',
              ),
          ],
      ],
  ]),
]);

To listen for this event and add filtering, such as filtering for the context user, create a listener.

use ApiSkeletons\Doctrine\ORM\GraphQL\Event\QueryBuilder;
use League\Event\EventDispatcher;

$driver->get(EventDispatcher::class)->subscribeTo(Artist::class . '.queryBuilder',
    function(QueryBuilder $event) {
        $event->getQueryBuilder()
            ->innerJoin('entity.user', 'user') // The default entity alias is always `entity`
            ->andWhere($event->getQueryBuilder()->expr()->eq('user.id', ':userId'))
            ->setParameter('userId', $event->getContext()['user']->getId())
            ;
    }
);

Functions of the QueryBuilder event in addition to getters for all resolve parameters:

  • getQueryBuilder - Will return a query builder with the user specified filters already applied.

  • getRequestedOffset - Will return the offset the client asked for. The QueryBuilder passed to the event is not modified with the offset and limit yet, so if you have a large dataset and need to fetch it within the event you may use this method to get the offset.

  • getRequestedLimit - Will return the page size the client asked for, capped by the configured limit.

Note

The event is dispatched before the rows are counted so that a listener may modify the QueryBuilder and have that modification reflected in both totalCount and the rows returned. The offset and limit are therefore the window the client requested, not the window finally queried.

A backward request - last without a before cursor - reports an offset of zero because its real offset is the row count minus last, and that count has not been taken when the event is dispatched. Read getArgs()['last'] and getArgs()['before'] if you need to tell a backward request apart from a request for the first page.

A listener may join a collection, such as ->leftJoin('entity.performances', 'p'), whether it selects it, as ->addSelect('p') fetches it, or not. Each entity then has a row for each member of the collection, so a page of a query with a join a listener adds is fetched with Doctrine’s Paginator, which limits the entities rather than the rows.

Association QueryBuilder Event

Note

Version 13.x Breaking Change: Collections now use QueryBuilder instead of Criteria. The Criteria Event has been removed. See Migration from 12.x below.

When an association is resolved from an entity or another association, you may listen to the QueryBuilder Event to add additional filtering via QueryBuilder modifications if you assign an event name in the eventName parameter of the #[Association] attribute. The event is dispatched once for each row the association is resolved for, with that row as getObjectValue(), so an association with an event name is not batched.

This approach provides database-level filtering with full index support, eliminating the need to load entire collections into memory. As for a connection, the event applies only to this collection; to hide rows everywhere, such as soft deleted rows, use a Doctrine SQL filter, as the warning above describes.

use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL;
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\QueryBuilder;
use App\ORM\Entity\Artist;
use League\Event\EventDispatcher;

#[GraphQL\Entity]
class Artist
{
    #[GraphQL\Field]
    public $id;

    #[GraphQL\Field]
    public $name;

    #[GraphQL\Association(eventName: self::class . '.performances')]
    public $performances;
}

// Add a listener to filter the association with QueryBuilder
$driver->get(EventDispatcher::class)->subscribeTo(
    Artist::class . '.performances',
    function (QueryBuilder $event): void {
        // The default entity alias is always 'entity'
        $event->getQueryBuilder()
            ->andWhere('entity.isDeleted = :isDeleted')
            ->setParameter('isDeleted', false);

        // You can also add JOINs for more complex filtering
        $event->getQueryBuilder()
            ->innerJoin('entity.venue', 'venue')
            ->andWhere('venue.capacity > :minCapacity')
            ->setParameter('minCapacity', 1000);
    },
);

The QueryBuilder event for associations has the same methods as the QueryBuilder event for entity queries (see above):

  • getQueryBuilder - Returns a QueryBuilder with user-specified filters already applied

  • getRequestedOffset - Returns the offset the client asked for

  • getRequestedLimit - Returns the page size the client asked for

  • Plus getters for all resolve parameters (getSource, getArgs, getContext, getInfo)

Performance Benefits

Using QueryBuilder for collections provides significant performance improvements:

  • 83% faster for filtered collections (database filtering vs in-memory)

  • 90% less memory usage (only loads requested page, not entire collection)

  • Full index support for efficient database queries

  • Single query execution instead of loading collection then filtering

Migration from 12.x

Version 12.x used Criteria Events (deprecated):

// 12.x - OLD APPROACH (removed in 13.x)
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\Criteria;

#[GraphQL\Association(eventName: Artist::class . '.performances.criteria')]
public $performances;

$driver->get(EventDispatcher::class)->subscribeTo(
    Artist::class . '.performances.criteria',
    function (Criteria $event): void {
        $event->getCriteria()->andWhere(
            $event->getCriteria()->expr()->eq('isDeleted', false)
        );
    }
);

Version 13.x uses QueryBuilder Events (current):

// 13.x - NEW APPROACH (required)
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\QueryBuilder;

#[GraphQL\Association(eventName: Artist::class . '.performances')]
public $performances;

$driver->get(EventDispatcher::class)->subscribeTo(
    Artist::class . '.performances',
    function (QueryBuilder $event): void {
        // Note: Use 'entity' as the default alias
        $event->getQueryBuilder()
            ->andWhere('entity.isDeleted = :isDeleted')
            ->setParameter('isDeleted', false);
    }
);

Key Migration Changes:

  1. Change Event\Criteria to Event\QueryBuilder

  2. Remove .criteria suffix from event names

  3. Use getQueryBuilder() instead of getCriteria()

  4. Use QueryBuilder syntax (andWhere(), setParameter()) instead of Criteria syntax

  5. Use entity as the default alias in WHERE clauses

Modify an Entity Definition

You may modify the array used to define an entity type before it is created. This can be used for generated data and the like. You must attach to events before defining your GraphQL schema.

There are two ways to extend an entity type. You can extend an entity by listening to the EntityDefinition event. You can extend an entity by creating a new entity type by using a custom event name to replace the default.

The EntityDefinition event is dispatched when an entity type is created. The default name for this event is Entity::class . '.definition'. All entity types for Entity::class will be affected by this event.

The $driver->type() method takes a second, optional, event name parameter. When it is called with an event name, the event will replace the default Entity::class . '.definition' dispatched when the entity type is created. The type name in GraphQL will be the entity type name and the event name joined by an underscore. Any character in the event name which is not valid in a GraphQL name is replaced with an underscore, so $driver->type(Artist::class, 'artist.custom') has the type name of Artist followed by _artist_custom. Two event names which differ only in those characters, such as artist.custom and artist_custom, produce the same type name and cannot be used in the same schema.

use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\EntityDefinition;
use App\ORM\Entity\Artist;
use GraphQL\Type\Definition\ResolveInfo;
use League\Event\EventDispatcher;

$driver = new Driver($entityManager);

$driver->get(EventDispatcher::class)->subscribeTo(
    Artist::class . '.definition',
    static function (EntityDefinition $event): void {
        $definition = $event->getDefinition();

        // In order to modify the fields you must resolve the closure
        $fields = $definition['fields']();

        // Add a custom field to show the name without a prefix of 'The'
        $fields['nameUnprefix'] = [
            'type' => Type::string(),
            'description' => 'A computed dynamically added field',
            'resolve' => static function ($objectValue, array $args, $context, ResolveInfo $info): mixed {
                return trim(str_replace('The', '', $objectValue->getName()));
            },
        ];

        $definition['fields'] = $fields;
    }
);

The EntityDefinition event has one function:

  • getDefinition - Will return an ArrayObject with the ObjectType definition. Because this is an ArrayObject you may manipulate it as needed and the value is set by reference, just like the QueryBuilder event above.

With the useNonNullTypes config option, the type of a field may be a NonNull type. Type::getNullableType() returns the type within it.

A clever use of this event is to add a new field for related data and specify a custom QueryBuilder event in the $driver->resolve() function.

Custom Event Name

You may specify a custom event name for a an entity type. This is useful to create one-off entity objects that need special handling. For instance, if you want to append a field to an entity for only a single query, but not globally for all instances of the entity class.

$driver->type(Entity::class, Entity::class . '.entityDefinitionEvent');

Manually change the Metadata

You may modify the metadata directly when built. This event must be subscribed to immediately after creating the driver. See Metadata documentation.

This event is named 'metadata.build'.

use ApiSkeletons\Doctrine\ORM\GraphQL\Driver;
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\Metadata;
use App\ORM\Entity\Performance;
use League\Event\EventDispatcher;

$driver = new Driver($entityManager);

$driver->get(EventDispatcher::class)->subscribeTo(
    'metadata.build',
    static function (Metadata $event): void {
        $metadata = $event->getMetadata();

        $metadata[Performance::class]['limit'] = 100;
    },
);

The BuildMetadata event has one function:

  • getMetadata - Will return an ArrayObject with the metadata. Because this is an ArrayObject you may manipulate it as needed and the value is set by reference, just like the QueryBuilder event above.

After the listeners, every entity an exposed association refers to, or a computed field of an entity type returns, must be in the metadata. A listener may therefore add the entity, and one which removes an entity another refers to causes ApiSkeletons\Doctrine\ORM\GraphQL\Exception\Metadata.


This is documentation for API-Skeletons/doctrine-orm-graphql. Please add your ★ star to the project.