Skip to content

Configuration ​

yaml
# config/packages/graphql_orm.yaml
graphql_orm:
    endpoint: 'http://localhost:4000/graphql'
    max_depth: 1

    # Optional: send root arguments as typed GraphQL variables (see "Variables" below)
    variables:
        enabled: true
        cache_pool: cache.app
        ttl: 3600

    # Optional: mask query values in logs and profiler (default: true)
    redact_query_values: true

    # Optional: id of the HTTP client service (see "HTTP client" below)
    http_client: graphql.client

    # Optional: HTTP client options
    http_client_options:
        verify_host: true
        verify_peer: true

    # Optional: static headers sent with every request
    headers:
        Authorization: 'Bearer my-token'

    # Optional: GraphQL dialect (see dialects.md)
    dialect: GraphqlOrm\Dialect\DefaultDialect

    # Optional: schema validation (see schema-validation.md)
    schema_validation:
        mode: warning # exception | warning | disabled; unset: warning in the dev environment, disabled otherwise

    mapping:
        entity:
            dir: '%kernel.project_dir%/src/GraphQL/Entity'
            namespace: App\GraphQL\Entity
        repository:
            dir: '%kernel.project_dir%/src/GraphQL/Repository'
            namespace: App\GraphQL\Repository

Available options ​

OptionDefaultDescription
endpoint(required)GraphQL API endpoint, at the root or for each manager
managers[]One manager per GraphQL API, each with any option of this table (see several APIs)
default_managerthe first managerThe manager autowired without a name, and of the entities no mapping claims
max_depth1Relation levels loaded by the automatic selection; deeper relations only get their identifier (an explicit select() or with() is not limited)
lazy_loadingfalseLoad the relations reduced to their identifier on first access (see lazy loading)
variables.enabledtrueSend root arguments as typed GraphQL variables
variables.cache_poolcache.appCache pool storing the introspected argument types
variables.ttl3600Seconds before the schema is introspected again
identity_maptrueShare the entities between the queries of a request (see shared identity map)
slow_query_thresholdnullMilliseconds from which a query is logged as a warning and highlighted in the profiler (see slow queries)
redact_query_valuestrueMask query values in logs and profiler (see logging.md)
http_clientnullHTTP client service id; defaults to the autowired HttpClientInterface
dialectDefaultDialectGraphQL dialect to use
headers[]HTTP headers sent with every request; those of a manager are added to those of the root
naming_strategyDefaultNamingStrategyService id of the NamingStrategy naming the fields without mappedFrom (see naming strategy)
result_cache.poolcache.appCache pool of the result cache; null disables it
second_level_cache.enabledfalseKeep the entities marked #[GraphqlOrm\Cache] (see second-level cache); ignored until enabled
second_level_cache.poolcache.appCache pool of the entities without region
second_level_cache.default_lifetime3600Seconds an entity is kept, unless its region or #[GraphqlOrm\Cache] says otherwise
second_level_cache.regions[]Regions by name, each with a pool and a lifetime (see choosing the pools)
metadata_cache_poolcache.systemCache pool for entity metadata outside debug mode (see metadata cache); null disables it
headers_providernullService id computing headers per request (see dynamic headers)
http_client_options.verify_hosttrueTLS host verification
http_client_options.verify_peertrueTLS peer verification
schema_validation.modewarning in the dev environment, disabled otherwiseexception, warning, or disabled
profiler.max_queries50Queries of a request beyond which the profiler toolbar turns yellow; null reports none; root only (see profiler.md)
profiler.max_query_timenullMilliseconds waited for GraphQL beyond which the profiler toolbar turns yellow; root only
profiler.repeated_queries3Times from which the profiler reports repeated queries, lazy loads of a relation and entities worth the second-level cache; root only
mapping.entity.dir%kernel.project_dir%/src/GraphQL/EntityDirectory where entities are stored
mapping.entity.namespaceApp\GraphQL\EntityNamespace for entities
mapping.repository.dir%kernel.project_dir%/src/GraphQL/RepositoryDirectory where repositories are stored
mapping.repository.namespaceApp\GraphQL\RepositoryNamespace for repositories

Lazy loading ​

Beyond max_depth, relations only get their identifier selected. To fetch a given relation lazily whatever the depth, see lazy relations. With lazy_loading, they are hydrated as PHP lazy ghosts instead: reading the identifier costs nothing, and the first access to another property loads the entity with a query of its own, through its repository find().

yaml
graphql_orm:
    max_depth: 1
    lazy_loading: true
php
$task = $taskRepository->find(1);   // tasks { id title user { id } }
$task->user->id;                    // no query: the identifier is known
$task->user->name;                  // users(...) { id name ... }: the user is loaded now
  • With Data API Builder and Hasura, the first access loads every lazy entity of the same class met in the response, with one query filtering on their identifiers (in, 100 identifiers per query): reading $task->user->name for 100 tasks sends one query, not 100. With the other dialects, which standardize no list filter, each entity is loaded by a query of its own (the N+1 problem): raise max_depth, name the relations with with(), or select the fields you need, when you know they will be read.
  • Each load is shown in the profiler, the logs and the events.
  • An entity whose complete row comes later in the same response is completed from it, without query.
  • An entity deleted in the meantime throws an EntityNotFoundException on first access.
  • The relations of a lazily loaded entity are lazy as well.

Lazy ghosts are a PHP 8.4 feature: they need no proxy class, and work with final entity classes.

Shared identity map ​

The queries of a request share their entities, as Doctrine does (identity_map, enabled by default):

  • An entity is one instance, updated by each query returning it.
  • find() returns an entity known in full without a query: one met in a previous query, nested relations included. An entity known only in part (a select() of some fields) or a lazy ghost not loaded yet is still queried.
  • The map is reset between requests and between Messenger messages (kernel.reset), so that no data outlives them. A long-running script calls GraphqlManager::clear().
  • toIterable() keeps its pages out of it, so that going through a large result set still holds a single page.

With identity_map: false, each query has its own identity map: two find(1) send two queries and give two instances.

HTTP client ​

By default the bundle uses the application HTTP client, whose timeout is PHP's default_socket_timeout (60 s) and which does not retry. To set a timeout or retries for the GraphQL API only, declare a scoped client and pass its id:

yaml
# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            graphql.client:
                base_uri: 'https://api.example.com'
                timeout: 5
                max_duration: 30
                retry_failed:
                    max_retries: 2
                    delay: 500
                    multiplier: 2
                    http_codes:
                        0: ['POST']   # network errors and timeouts
                        429: true
                        500: ['POST']
                        502: true
                        503: true
                        504: ['POST']

# config/packages/graphql_orm.yaml
graphql_orm:
    endpoint: 'https://api.example.com/graphql'
    http_client: graphql.client

Retrying temporary errors ​

GraphQL queries are sent with POST, which Symfony does not retry by default on network errors, timeouts, 500 or 504: its default strategy only retries those for idempotent methods (GET, PUT...), and POST only on 423, 425, 429, 502 and 503. The bundle only sends queries, which read data: listing POST for these codes, as above, makes them retried as well.

  • delay and multiplier space out the attempts (500 ms, then 1 s), and a Retry-After header sent by the server is honoured.
  • An error reported in a 200 response ({"errors": [...]}) is not retried: the server answered.
  • max_duration bounds each attempt: a query may take up to max_duration × (max_retries + 1) plus the delays, keep it below the time your users can wait.

Metadata cache ​

Entity metadata comes from attributes and docblocks. Outside debug mode it is kept in the metadata_cache_pool (cache.system by default) and built for every entity of mapping.entity.dir (the classes directly in it, not in its subdirectories) by cache:warmup, so that no request pays for it. In debug mode it is only kept for the current request, so mapping changes are seen without clearing the cache. After a deployment, cache:clear (or cache:warmup) refreshes it, as for the rest of the Symfony cache.

Dynamic headers ​

Static headers suit a fixed API key. For a value computed at request time, such as an OAuth access token that expires, implement HeadersProviderInterface and pass its service id. It is called before every request, schema introspection included, and its headers override the static ones with the same name:

php
use GraphqlOrm\Client\HeadersProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class OAuthHeadersProvider implements HeadersProviderInterface
{
    public function __construct(
        private CacheInterface $cache,
        private TokenClient $tokenClient, // your own client for the OAuth server
    ) {
    }

    public function getHeaders(): array
    {
        $token = $this->cache->get('graphql_api_token', function (ItemInterface $item): string {
            $token = $this->tokenClient->fetchAccessToken();
            $item->expiresAfter($token->expiresIn - 30);

            return $token->value;
        });

        return ['Authorization' => 'Bearer ' . $token];
    }
}
yaml
graphql_orm:
    headers_provider: App\GraphQL\OAuthHeadersProvider

Cache the token: the provider runs on every request. Its exceptions are not caught, so a failure to get a token stops the query.

Variables ​

By default (variables.enabled), the arguments of the root field are sent as GraphQL variables, their types read from a cached introspection of the schema; with enabled: false, the values are written in the query (escaped):

graphql
query($filter: TaskFilterInput, $first: Int) {
  tasks(filter: $filter, first: $first) {
    items { id title }
  }
}

A variable must be declared with its exact GraphQL type, so the types of the root field arguments are read from the schema (introspection) and stored in variables.cache_pool for variables.ttl seconds. A schema change on the API side is taken into account once the cache expires; clear the pool to apply it immediately.

Values are sent inline, as without the option, when:

  • the schema cannot be introspected (many APIs disable introspection in production): a warning is logged and the failure is cached for variables.ttl seconds, so the API is not introspected on every request;
  • an argument is not found on the root field: a warning lists the missing arguments.

Enums are sent as their value and dates as ISO 8601 strings. getQuery()->getGraphQL() still shows the query with inline values.

Released under the MIT License.