Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[5.9.0] — 2026-10-18
Added
- The profiler suggests
->with()for a relation loaded one entity at a time,select(),hydrateAs()orenableResultCache()for a slow query or a large response, and the second-level cache for an entity found or loaded 3 times or more. - A "Callers" tab of the profiler groups the queries by the line of the application sending them, the costliest first.
- A "Replay" button of the profiler sends a query of the profile again and shows its response, in debug mode, for a query whose values are not masked (
redact_query_values: false) and which is not a mutation. It needs the routes of the bundle. - The
profileroptions set the alerts of the profiler:max_queries, the queries of a request beyond which its toolbar turns yellow (50 by default, as Doctrine),max_query_time, the milliseconds waited for GraphQL beyond which it does, andrepeated_queries, the times from which repeated queries, lazy loads and entities worth the second-level cache are reported (3 by default). - An "Export JSON" button of the profiler downloads the metrics, the queries and the findings of a profile, their values masked unless
redact_query_valuesis false.
Changed
- The timeline of the profiler shows the origin, the entity and the duration of each query, its hydration apart from the network, a graduated axis, and the time of the request before the first query.
Fixed
- The time in GraphQL of the profiler and of its toolbar counts once the queries sent together, which the request waited for at the same time; their sum is shown apart.
- The profiler describes the entities read by the queries sent after the response (a
kernel.terminatelistener, a streamed response), and marks these queries "after the response". - The profiler marks the queries of a sub-request (a fragment, a forward) with its path.
- A profile keeps the detail of the first 500 queries only: the others are counted in the metrics and the alerts, without their text, variables, AST nor response, so that a page sending thousands of queries does not bloat it.
[5.8.1] — 2026-10-18
Changed
graphqlorm:make:entitygenerates the mapping attributes through a namespace alias, as Doctrine:use GraphqlOrm\Mapping as GraphqlOrm;and#[GraphqlOrm\Entity(name: 'tasks', repositoryClass: ...)]. The documentation maps the entities the same way.- The messages of the exceptions, of the commands and of the profiler name the mapping attributes the same way:
#[GraphqlOrm\Cache],#[GraphqlOrm\Embedded]...
[5.8.0] — 2026-10-18
Added
$repository->with('assignee.company')names the relationsfind(),findOneBy(),findBy()andfindAll()load, aswith()of the query builder: it gives a copy of the repository, which is left as it is.graphqlorm:query --hydrate=Taskhydrates the response as the entity and prints the entities asgraphqlorm:find, as a table or JSON (--format=json).- The profiler tells the relations a query loads: those named by
with(), or up tomax_depth. - The schema validation reports a property typed with a value object that no type converter converts, and checks the fields a converter converts use one of the scalars of its
#[AsTypeConverter(..., scalars: ['Int'])]. graphqlorm:make:entity --from-schematypes a custom scalar of the API with the value object of the type converter telling it (scalars: ['Siret']).- Value objects spread over several fields, as the embeddables of Doctrine: a class marked
#[Embeddable], its fields mapped with#[Field], held by a property mapped with#[Embedded], a GraphQL object (price { amount currency }) or fields of the entity with#[Embedded(prefix: 'total_')]. Created through its constructor, selected, filtered and ordered by the path of its fields (total.amount), kept in the second-level cache, hydrated for the DTOs, checked by the schema validation and the PHPStan rules, listed by the profiler.
[5.7.1] — 2026-10-18
Fixed
- The
#[AfterHydrate]hooks of an entity completed after being hydrated in part threw aPartialEntityExceptionwith PHP 8.4, whoseReflectionProperty::isInitialized()runs the initializer of a lazy object. - No deprecation with symfony/dependency-injection 8.1, which deprecates the index and priority methods of the tagged iterators given by position.
[5.7.0] — 2026-10-18
Added
with()on the query builder names the relations a query loads, nested ones by their path (with('assignee.company', 'label')), in place ofmax_depth: the relations not named only get their identifier selected. A relation mapped withFetch::Lazyis loaded when named, and the relations named are added to the fields ofselect().graphqlorm:querysends a raw GraphQL query to the API of a manager (--manager), with its variables (--variables='{"id": 1}') or from the standard input, and prints the JSON response. A response holding errors fails the command; the query is logged and profiled, its values masked there as any other.hydrateAs()on the query builder hydrates the result as read-only DTOs, created through their constructor, rather than entities: the query selects their fields only. A parameter maps the field of its name, or another one with#[MapFrom('assignee.name')]; one typed with a class maps a relation to a nested DTO, an array typedlist<Dto>a collection. Neither identity map nor second-level cache; pagination,indexBy()and the result cache work as with entities. The profiler tells the queries hydrated as DTOs, with the DTO and the number created.- Type converters turn a scalar of the API into a value object (
Money,Email,Siret) and back: a service implementingTypeConverterInterface, autoconfigured, converts the properties typed with the class of its#[AsTypeConverter], or those whose#[Field(converter: ...)]names it. The values of the entities and of the DTOs are converted, and the value objects of the filters, criteria andindexBysent back. The "Entities" tab of the profiler tells the converter of each field. graphqlorm:cache:warmup [entity]loads the entities marked#[Cache]into the second-level cache, page by page (--batch-size, 100 by default), those of an entity or every one, of a manager (--manager) or all, and tells how many were hydrated and kept.GraphqlCache::evictQueryResults()evicts every result of a manager, with any pool, even shared: the results get a new key, as the entities withevictEntityRegion(). The results kept before stay valid until a first eviction.graphqlorm:cache:clearevicts the result cache and the second-level cache: both by default, or--results,--key=...,--entities,--class=..., of a manager (--manager) or all.graphqlorm:cache:statsdescribes the caches of each manager: the pool, adapter and lifetime of the result cache and of each region, the entities marked#[Cache], and how many times they were evicted.
[5.6.0] — 2026-10-16
Added
- Several GraphQL APIs in one application, a manager each, as the entity managers of Doctrine:
graphql_orm.managerstakes any option of the configuration for each API, those it does not set taken from the root, anddefault_managernames the default one. Withoutmanagers, the root is the managerdefault: nothing changes for a single API. - An entity belongs to the manager its
#[Entity(manager: ...)]names, or whose entity namespace holds it, or to the default one. A relation between entities of two APIs throws aLogicException. GraphqlManagerRegistry:getManager(),getManagerForClass(),getRepository(),getManagerNames(), andforManager()for a single manager. Managers are autowired by the name of their argument (GraphqlManagerInterface $crmManager,GraphqlCache $crmCache); their services are namedgraphql_orm.<manager>.<service>.ServiceGraphqlEntityRepository, a repository taking the manager of its entity from the registry, generated bygraphqlorm:make:entity.- The controller arguments, form fields and autocomplete search, the normalizer, the references of Doctrine entities and
graphqlorm:finduse the manager of their entity;graphqlorm:make:entityandgraphqlorm:schema:validatetake--manager, andgraphqlorm:debug:entitytells the manager of an entity. - Each API keeps its own introspection, schema validation, identity map and caches, the results of two APIs sharing a pool kept apart; the profiler lists the APIs, tells the API of each query, judges it slow by the threshold of its API, and shows the options of each API in its "Configuration" tab.
GraphqlManager::getName()andgetClient().
Fixed
- The routes of the bundle (
@GraphqlOrmBundle/Resources/config/routes.php) load withoutsymfony/form: they declared the autocomplete route throughGraphqlEntityType, whose class needs it. Withoutsymfony/formthere is no route, as there is no autocomplete field. The path is alsoEntityAutocompleteController::PATH.
[5.5.0] — 2026-10-16
Added
- Result cache:
GraphqlQuery::enableResultCache(lifetime, key)keeps the response of the query ingraphql_orm.result_cache.pool(cache.appby default, any Symfony cache pool, Redis included) and answers from it, as Doctrine. Each page of a paginated query is kept apart; a response with errors is not kept. - Second-level cache: the entities marked
#[Cache(region, lifetime)]are kept once hydrated in full, and read back byfind(), the references and the lazy loads, their relations kept by their identifier and loaded on first access. Disabled by default, as in Doctrine:graphql_orm.second_level_cache.enabledturns it on, and sets the default pool and lifetime, and regions with a pool and a lifetime of their own. GraphqlCache(alsoGraphqlManager::getCache()):evictEntity(),evictEntityRegion()(with any pool),evictQueryResult()andcontainsEntity().- Profiler: a "Cache" tab giving the pools of the caches with their adapter (Redis, APCu, Filesystem...) and lifetime, the values read, written and deleted with their time and size, the results by query (outcome, key, lifetime), the entities of the second-level cache with what looked them up (
find(), a lazy load, a query), the queries added by the relations of entities read from the cache, and the evictions with the line calling them. It warns when#[Cache]is ignored or a pool is anArrayAdapter, and shows a request reading the caches without a query. - Toolbar: summed up as Doctrine does, with the queries sent to the API, the different statements, the hydration time, the invalid entities, the entities hydrated, and the hits of each cache with the adapter of its pools.
[5.4.0] — 2026-10-16
Added
- Profiler: a "Lazy loading" tab summing up, by relation, the entities left to be loaded on first access (
Fetch::Lazy,lazy_loading, references of Doctrine entities): how many, how many were loaded and by which queries, how many were never read. The references of Doctrine entities are named after their property (Invoice::$task) in it and in the N+1 detection. - Profiler: a "Serializer" tab counting the relations written as identifiers and the partial entities written by
GraphqlEntityNormalizer, and listing the lazy loads sent while the serializer was writing an entity (an N+1 hidden in a response), with a warning on top of the panel and aserializermark on those queries. - Profiler: a "Partial entities" tab listing the entities hydrated from a selection of their fields, by class and fields not selected, with the queries hydrating them and the
PartialEntityExceptionthrown. - Profiler: the "Configuration" tab tells, for each cache of introspection results (types of the variables, countable fields, schema validation), whether it served the request or introspected the schema, and the time spent.
- Profiler: the "Hydration" detail of a query lists the collections hydrated by relation, with their type, how many and their items, and the field indexing the result (
indexBy). - Profiler: an "Autocomplete" tab describing the searches of autocomplete fields (entity, fields searched, text typed, masked unless
redact_query_valuesis false, results and limit); their queries have the originautocompleteinstead of__invoke.
[5.3.0] — 2026-10-16
Added
GraphqlEntityTypeloads only the submitted entities on submission, by their identifier within the restriction ofquery_builder, and supportschoice_lazy. Withautocomplete: trueandsearch_fields, it searches the entities as the user types with Symfony UX Autocomplete (as many results as itsmax_resultsoption), through a signed endpoint of the bundle (import@GraphqlOrmBundle/Resources/config/routes.php).GraphqlQueryBuilder::andWhere(), adding a condition to those ofwhere().GraphqlEntityRepository::getReference(), as Doctrine: the entity of an identifier without a query, loaded on first access.#[GraphqlReference]: a Doctrine entity references a GraphQL entity by an identifier column. The bundle sets it on load with a reference loaded on first access, and fills the column from the reference before the first persist.doctrine/ormis suggested.- A relation typed
Collectionofdoctrine/collections(suggested) is hydrated as anArrayCollection, its item type read from the generic type (/** @var Collection<int, Task> */). Thearrayproperties stay arrays. GraphqlEntityNormalizer, registered withsymfony/serializer: a relation loaded on first access and not read yet is written as its identifier, a partial entity with its selected fields only (not the default values of the others), without any query. TheObjectNormalizerwould load each lazy relation (an N+1 hidden in a JSON response) or fail on the fields not selected.symfony/serializeris suggested.- Reading a property that the query did not select, and that has no default value, throws a
PartialEntityExceptionnaming the properties left out and how to load them (select them, raisemax_depth,Fetch::Lazy), instead of the "must not be accessed before initialization" error of PHP. A partial entity met again in full is completed. indexBy, as Doctrine:findBy([], indexBy: 'code')and$qb->indexBy('code')key the result (or the items of a page) by a field of its entities, a GraphQL name or a PHP property. An unmapped field throws anInvalidArgumentExceptionsuggesting the closest one.
Changed
- The outcome of the schema validation is cached in
variables.cache_poolforvariables.ttl: the schema is no longer introspected on every request when each one runs a container of its own (PHP without a worker, in dev). Editing an entity checks the mapping again at once.
[5.2.0] — 2026-10-15
Added
- Profiler: the GraphQL queries are highlighted: keywords, fields, arguments, types, variables, strings, numbers and masked values, in the colors of the profiler theme.
- Profiler: repeated queries that are all a
find()of the same entity come with thefindBy()loading them at once, on the identifier of the entity.GraphqlQueryTrace::$identifierPropertynames it when the profiler is enabled. - Profiler: an "Entities" tab describing the mapped entities as the bundle maps them (root field, identifier, repository, properties with their GraphQL field, relation target and fetch mode), those read by the request first.
- Profiler: the time spent hydrating each response, apart from the network: a "Hydration" metric, a grey part in the duration bar of each query, and the time in its "Hydration" tab.
GraphqlQueryTrace::$hydrationDurationholds it.
Changed
- The caller of a query skips the methods of the entities, and gives a line of a compiled Twig template as the line of the template: a relation loaded on first access from
task.assigneein a template points totask/index.html.twig:25, not to the__toString()of the entity nor to a file of the Twig cache.
[5.1.1] — 2026-10-15
Fixed
- The schema validation checked no entity with the default
mapping.entity.dir,%kernel.project_dir%/src/GraphQL/Entity: the directory was read before%kernel.project_dir%was resolved, so that no entity was found. The metadata cache warmer, given the same list, warmed nothing either.
[5.1.0] — 2026-10-15
Added
- Profiler: the origin of each query (the method of the bundle the application called, e.g.
findBy,getResult, a custom repository method, orlazyfor a lazy load) and the class of its entities, and a timeline of the queries during the request, showing the queries sent together. - Profiler: the relations loaded on first access 3 times or more, one query each (an N+1 problem), with their queries, their duration and the lines reading them, in an alert, the toolbar and the "N+1 & repeated" tab. The lazy loads are no longer counted as repeated queries.
GraphqlQueryTrace::$lazyRelationnames the relation of a lazy load. - Profiler: the entities that
find()served from the shared identity map without a query, with the lines calling it, in a metric and an "Identity map" tab. - Profiler: the introspection queries of the bundle (typed variables, schema validation, countable fields of Data API Builder), sent without the manager, are listed with the origin
introspection. - Profiler: the first 2 KB of each response, values masked unless
redact_query_valuesis false, and a "Configuration" tab with the options of the bundle (header names only, no credentials) and the outcome of the schema validation.GraphqlQueryTrace::$responsePreviewholds the response when the profiler is enabled. GraphqlEntityRepository::getClassName(), as Doctrine, andGraphqlQuery::getEntityClass().GraphqlQueryTrace::$startedAt,$originand$entityClass, the two last ones filled when the profiler is enabled.
Fixed
- Profiler: opening a stored profile with slow queries failed with "Typed property ...::$slowQueryThreshold must not be accessed before initialization": a profile read from the storage only has its data, the threshold is now kept there. Fixed in 4.10.2 as well.
Changed
- The profiler panel is reorganized: metrics first (queries, time, slow queries, errors, entities hydrated, bytes received), then the queries in a table, filtered by status or searched, each opening its detail in tabs (GraphQL, variables, AST, hydration). The repeated queries get an alert and a tab of their own. The panel follows the light or dark theme of the profiler.
[5.0.0] — 2026-10-15
Upgrading from 4.x
Fix the deprecations reported by 4.9 first: each of them is a change of 5.0. The upgrade guide details every step.
- The mapping attributes of
GraphqlOrm\Attributeare removed:#[GraphqlEntity]and#[GraphqlField]become#[Entity]and#[Field]ofGraphqlOrm\Mapping, theidentifierargument becomes#[Identifier], a relation is mapped with#[Relation](with itsmappedFrom,targetEntity,fetchandignoreValidation), andGraphqlOrm\Attribute\FetchbecomesGraphqlOrm\Mapping\Fetch. An entity still mapped with the old attributes is no entity anymore (NotAGraphqlEntityException). The PHPStan extension no longer declares theFetchalias. - New default values:
identity_map: true,max_depth: 1,variables.enabled: true, andschema_validation.modewarningin the dev environment,disabledotherwise (tests included, rather than "in debug mode" as announced by 4.9, so that test suites do not introspect the API). An application that set them, as 4.9 asked, sees no change; otherwise, mind the relations no longer selected beyond the first level (raisemax_depth, or map them withFetch::Lazy), and an API refusing introspection, whose values are then sent inline with a logged warning. Direction::ASCandDirection::DESCare removed: use the casesDirection::AscandDirection::Desc. The values sent (ASC,DESC) are unchanged.GraphqlManageris final (@finalsince 4.9): depend onGraphqlManagerInterfaceto simulate or decorate it.EntityHydratorandGraphqlEntityMetadataFactory, internal, are final as well.symfony/deprecation-contractsis no longer a direct dependency.
Added
MockGraphqlServeranswers the introspection queries of the bundle (typed variables, schema validation) with an empty schema, without recording them nor consuming the queued responses: the tests of an application work whatever these options.
[4.10.3] — 2026-10-15
Fixed
- The schema validation checked no entity with the default
mapping.entity.dir,%kernel.project_dir%/src/GraphQL/Entity: the directory was read before%kernel.project_dir%was resolved, so that no entity was found. The metadata cache warmer, given the same list, warmed nothing either.
[4.10.2] — 2026-10-15
4.10.1 is withdrawn: its tag pointed by mistake at the code of 5.0. 4.10.2 holds the fix meant for it, on top of 4.10.0.
Fixed
- Profiler: opening a stored profile with slow queries failed with "Typed property ...::$slowQueryThreshold must not be accessed before initialization": a profile read from the storage only has its data, the threshold is now kept there.
[4.10.0] — 2026-10-15
Added
count()with Data API Builder works on an entity whose identifier is not a number (e.g. astringcode), or which has no identifier: the schema is introspected once, cached invariables.cache_pool, and the entity is counted on its first numeric field never null. Data API Builder only counts the non-null values of a numeric field, and rejectedcount(field: <identifier>)with "The specified argument value does not match the argument type". An entity with no such field gets aLogicExceptiontelling why. Nothing changes, and no introspection is sent, for an identifier typedintorfloat.
Changed
CountingDialect::countField()receivesnullas$identifierFieldwhen the property of the identifier is typed with neitherintnorfloat. The dialects of the bundle other thanDataApiBuilderDialectignore it.
[4.9.2] — 2026-10-15
Fixed
- Profiler: "Copy" and "Copy as cURL" failed on an application served over plain HTTP (other than localhost), where the browser offers no Clipboard API; they now copy through a selected text area there.
[4.9.1] — 2026-10-15
Fixed
- Profiler: the AST of a query with a filter or an ordering failed to render ("Array to string conversion"); its arguments are now shown as JSON.
headers: the dashes of the header names were turned into underscores by the configuration, so thatX-API-Keywas sent asX_API_Key.
[4.9.0] — 2026-10-15
Added
GraphqlManager::getRepository(), as DoctrineEntityManager::getRepository(): the repository service named in therepositoryClassof#[Entity], a generic one otherwise. The repositories extendingGraphqlEntityRepositoryare found by autoconfiguration (graphql_orm.repositorytag).- The mapping attributes of
GraphqlOrm\Mapping, as DoctrineDoctrine\ORM\Mapping:#[Entity]and#[Field];#[Identifier]marks the identifier field, as Doctrine#[Id];#[Relation]maps a relation or a collection, withmappedFrom,targetEntity,fetchandignoreValidation.graphqlorm:make:entitygenerates them. A property holding an entity mapped with#[Field], or mapped with two field attributes, throws anInvalidArgumentException. GraphqlManagerInterface, as DoctrineEntityManagerInterface, withgetRepository()andclear(): the type to inject, and to simulate or decorate in the tests of an application. Autowired toGraphqlManager.
Changed
- The
query_builderoption ofGraphqlEntityType, the controller argument resolver andgraphqlorm:finduse the repository of the application: its own methods and overrides apply there too.
Deprecated
#[GraphqlEntity]and#[GraphqlField]ofGraphqlOrm\Attribute: use#[Entity]and#[Field]ofGraphqlOrm\Mapping. Theidentifierargument of#[GraphqlField]: add#[Identifier]. Mapping a relation with#[GraphqlField], and itstargetEntityandfetcharguments: use#[Relation].GraphqlOrm\Attribute\Fetch: useGraphqlOrm\Mapping\Fetch, of which it is an alias. All still work in 4.x and are removed in 5.0, see "Upgrading from the mapping of 4.8" in docs/entity-mapping.md. The PHPStan extension declares theFetchalias.Direction::ASCandDirection::DESC: useDirection::AscandDirection::Desc, the cases being named in PascalCase as the other enums of the bundle. The old names are constants of the enum, pointing to the new cases, so thatDirection::ASC === Direction::Asc; they are removed in 5.0. The values sent (ASC,DESC) are unchanged.- Not setting
identity_map,max_depth,variables.enabledandschema_validation.mode: their default value changes in 5.0 (true,1,true,warningin debug mode). Set them, see "Defaults changing in 5.0" in docs/configuration.md. - Extending
GraphqlManager: it is@finalsince 4.9 and final in 5.0, reported by the Symfony debug class loader. Depend onGraphqlManagerInterfaceto simulate or decorate it.
[4.8.0] — 2026-10-13
Added
identity_map: trueshares the entities between the queries of a request: an entity is one instance, updated by each query returning it, andfind()returns one known in full without a query. Reset between requests and Messenger messages (kernel.reset);GraphqlManager::clear()for a long-running script.toIterable()keeps its pages out of it. Disabled by default.#[GraphqlField(fetch: Fetch::Lazy)]: a relation selected by its identifier only, whatevermax_depth, and loaded on first access, the ghosts of a response together, as Doctrinefetch: LAZY. It needs nolazy_loading.GraphqlQuery::batch(): several queries on different root fields in one GraphQL document, one HTTP request. A root field in error fails its query only; paginated queries and queries on the same root field are rejected.
Changed
- The AST of each query is exported for the trace without a JSON round trip: about a quarter less of the time the bundle spends on a small query, measured by the new benchmarks (
tests/Benchmark, PHPBench).
[4.7.0] — 2026-10-13
Added
- A list criterion of the find methods,
count()and the aggregates matches any of its values, as with Doctrine:findBy(['id' => [1, 3]])is sent asin(Data API Builder) or_in(Hasura); an empty list matches nothing.PostGraphileDialectrejects it with aLogicException, its conditions matching a single value. slow_query_threshold(milliseconds): a query lasting at least that long is logged as a warning ("Slow GraphQL query") and highlighted in the profiler, with a count in the toolbar. No threshold by default.graphqlorm:findconsole command: queries the API withfindBy()(--criteria field=value,--order-by,--limit) and shows the entities as hydrated, as a table or JSON.
Changed
- The duration of a query is measured by the client itself, so that it is known without the Symfony stopwatch, which is still fed for the profiler timeline.
Fixed
findBy($criteria, []): an empty ordering, accepted by Doctrine, was sent as an emptyorderByargument, which Data API Builder rejects.GraphqlManager::send(), added in 4.6.0, was flagged@internal, which made the Symfony debug class loader report a deprecation for every class extending or mocking the manager.
[4.6.0] — 2026-10-13
Added
mappedFromof#[GraphqlField]is optional: the field is then named from its property by the naming strategy,naming_strategyin the configuration, as Doctrine does for columns.DefaultNamingStrategy(the default) keeps the property name,SnakeCaseNamingStrategyturnsdueDateintodue_date; implementNamingStrategyfor another convention. The PHPStan rules take the same class in thegraphqlOrm.namingStrategyparameter.- Query builder filters through relations:
$expr->eq('user.name', 'User B')is sent nested, as{user: {name: {eq: ...}}}; on a collection (tasks.done), it matches the entities having at least one related entity that matches. Data API Builder and Hasura. GraphqlEntityRepository::sum(),avg(),min()andmax(), aggregating a field over the entities matching the criteria, with the dialects implementing the newAggregatingDialectinterface:DataApiBuilderDialect(numeric fields) andHasuraDialect. The PHPStan rules check their field and criteria. A repository already declaring a method of the same name must give it the same signature, or rename it.GraphqlQuery::concurrently(), running several queries at once: every request is sent before any response is read.GraphqlClientimplements the newConcurrentGraphqlClientInterface; other clients run the queries one after the other.GraphqlQueryandPaginatedResultare covariant in their entity type: aGraphqlQuery<Task>is aGraphqlQuery<object>.
[4.5.0] — 2026-10-13
Added
- The PHP property names of an entity are accepted wherever a field is named, besides the GraphQL names: criteria and ordering of the find methods and
count(), query builder filters and ordering,#[MapGraphqlEntity]mapping.findBy(['dueDate' => null])sendsdue_date. A property having the GraphQL name of another field designates that field, as before. - A failing query naming fields the entity does not map points them out, with the closest mapped field:
Field "titel" is not mapped on App\Task: did you mean "title"?. They are still sent, the API may accept them. - Profiler: "Copy as cURL" copies a query, with its variables, as a command to replay it from a terminal. Headers are not recorded, so no token ends up in it.
GraphqlQuery::getSingleResult(), the only entity a query returns, throwing aNoResultExceptionwhen there is none and aNonUniqueResultExceptionwhen there are several, as Doctrine.
Changed
- PHPStan rules: a PHP property name is no longer reported where a field is named; the
graphqlOrm.propertyNameidentifier is gone (remove the@phpstan-ignore graphqlOrm.propertyNamecomments, if any).
Fixed
- An invalid query answered with an HTTP
400(Data API Builder, PostGraphile) threw "answered with HTTP status 400" without the errors of the server: they are now in the exception message and the profiler.
[4.4.0] — 2026-10-13
Added
- Query builder comparisons:
gt(),gte(),lt(),lte()(_gt... with Hasura),isNotNull()andnotIn(), sent as one inequality per value, Data API Builder having no "not in" operator. GraphqlQuery::toIterable(), a generator going through every page of a paginated query, fetching each page once the previous one is read.- Controller arguments typed with an entity receive the entity whose identifier is in the route (
{task}or{id}), a 404 response when it does not exist, ornullfor a nullable argument.#[MapGraphqlEntity]reads the identifier from another route parameter (id), finds the entity by other fields (mapping) or disables the resolution (disabled), as Doctrine#[MapEntity]does. GraphqlEntityType, a form field whose choices are entities, loaded withfindAll()or thequery_builderoption, as DoctrineEntityType. Registered when symfony/form is installed.MockGraphqlServer, set as thehttp_clientof the bundle in the tests of an application: it answers the queries with the responses queued (respond(),respondWithErrors(),respondWithHttpStatus()) and records them.
Changed
- Documentation: retrying temporary errors. Symfony does not retry
POSTrequests on network errors, timeouts,500or504by default; the configuration of the "HTTP client" section now listsPOSTfor these codes.
[4.3.0] — 2026-10-13
Upgrading from 4.2
- A repository already declaring its own
count()must give it the signaturecount(array $criteria = []): int, or rename it: otherwise PHP fails with a fatal error on loading it. - Schema validation is stricter:
graphqlorm:schema:validatemay now fail on mappings it accepted, e.g. a nullable field mapped on a non-nullable property. Each message says how to fix the mapping.
Added
- The profiler reports repeated queries, those differing only by their values and sent 3 times or more (the sign of an N+1 problem), in yellow in the toolbar and listed at the top of the panel.
GraphqlEntityRepository::count($criteria), with the dialects implementing the newCountingDialectinterface:DataApiBuilderDialect(aggregation),HasuraDialect(_aggregate),PostGraphileDialectandRelayDialect(totalCount). The PHPStan rules check its criteria as those offindBy(). A repository already declaring its owncount()must give it the same signature,count(array $criteria = []): int(that of Doctrine), or rename it.- Schema validation (
graphqlorm:schema:validate, first request listener) also reports a field the schema declares nullable mapped on a property that does not acceptnull, a relation to another type than the one of its target entity or mapped on a scalar, and a collection mapped on a single object (or the reverse).
Fixed
- The profiler template defined its AST macro inside a block, deprecated since Twig 3.28 and an error with Twig 4.
[4.2.0] — 2026-10-13
Added
PostGraphileDialect: Relay connections, criteria of the find methods sent as acondition, ordering with theorderByenum values of the fields (DUE_DATE_DESC); the query builder filters, which need a plugin, are rejected.EnumLiteral, an enum value built at runtime, printed without quotes by the walkers and sent as a string in the variables.HasuraDialectsupportspaginate()with offsets: the cursor of a page is the offset of the next one, and one extra row is requested so thathasNextPageis exact.OffsetPaginationDialectmarker interface for such dialects.
Fixed
DataApiBuilderDialect: going back to the first page of a pagination started at a cursor (paginate($cursor)) sent that cursor again, returning the same page instead of the first one.
[4.1.1] — 2026-10-13
Fixed
DefaultDialectwrapped nested collections in anitemsenvelope, a Data API Builder syntax that standard GraphQL APIs reject: they are now selected as plain lists.- Schema validation reported a mismatch, and
make:entity --from-schemagenerated astring, for the scalars named after the database types by Hasura (timestamptz,numeric,bigint...) and for theDatetimeof PostGraphile. make:entity --from-schemaused the table name of Hasura types as relation target class (users): it is now a singular class name (User).
Changed
symfony/stringis required: entity generation uses its inflector (it was already installed bysymfony/console).
[4.1.0] — 2026-10-13
Added
- Lazy loading loads the ghosts of a class met in a response with one query (
inon their identifiers, 100 per query) instead of one query each, with the dialects implementing the newBatchLoadingDialectmarker interface:DataApiBuilderDialectandHasuraDialect.
Changed
GraphqlExecutionContext::$lazyLoader, set by the manager, loads a list of identifiers and is marked@internal.
[4.0.1] — 2026-10-13
Fixed
- Lazy loading: a ghost referenced twice in the same response (two entities pointing to the same relation) was marked as loaded with its default values, without query. A ghost is now only completed by a row holding every mapped field; otherwise it is loaded on first access.
[4.0.0] — 2026-10-13
Upgrading from 3.x
- PHP 8.4+ is required: lazy loading relies on PHP 8.4 lazy objects. The Symfony requirement is unchanged (7.4 or 8.x).
- Nothing else changes unless
lazy_loadingis enabled: it is disabled by default, so relations beyondmax_depthstill only get their identifier. When enabling it, mind the extra query sent for each lazy relation accessed (see the "Lazy loading" section of docs/configuration.md). - Contributors: the Castor QA tasks need Castor 1.8 or later, whose embedded PHP satisfies the requirement.
Added
lazy_loadingoption (disabled by default): the relations reduced to their identifier beyondmax_depthare hydrated as PHP 8.4 lazy ghosts, loaded with a query of their own on first access;EntityNotFoundExceptionwhen the entity no longer exists.
Changed
- Requires PHP 8.4+ (was PHP 8.3+).
[3.2.0] — 2026-10-13
Added
RelayDialect, for APIs following the Relay cursor connections specification (edges { node },first/after,pageInfo), with itsRelayGraphqlWalker.HasuraDialect, for Hasura and APIs following its conventions:wherewith_-prefixed operators (%and_of searched values escaped),order_by,limit, plain lists;paginate()is rejected, Hasura paginating with an offset.- PHPStan rules (
extension.neon, enabled by phpstan/extension-installer) checking the field names given tofindBy(),findOneBy()and the query builder against the entity mapping: PHP property name instead of GraphQL name, unmapped field with a suggestion (graphqlOrm.reportUnmappedFieldsto allow them), path through a field that is not a relation. CursorPaginationDialectinterface, telling wherehasNextPageandendCursorlie; implemented byDataApiBuilderDialectandRelayDialect.AbstractGraphqlWalker::walkCollection(), to print the envelope of nested collections.- Schema validation and
make:entity --from-schemaunwrap Relay connections.
Changed
phpstan/phpdoc-parseris required in^2.2(was^2.0), the first version compatible with PHPStan 2.2 which shares it.DefaultGraphqlWalkeris no longerfinal, so that dialects close to standard GraphQL can extend it.
[3.1.1] — 2026-10-13
Fixed
graphqlorm:make:entityno longer adds@methodannotations to the generated repository: they described the 2.xfindBy()signature and missedfind()andfindOneBy(), while@extends GraphqlEntityRepository<Entity>already types every method. Existing repositories can drop them as well.- The description of
graphqlorm:make:entitywas in French, unlike the other commands. - Schema validation on the first request was remembered in a static property, shared by every container of the process: a second kernel (functional tests, a rebuilt container in worker mode) skipped it. It now runs once per container.
[3.1.0] — 2026-10-13
Added
graphqlorm:make:entity --from-schema: generates the entity properties (types, nullability, identifier, relations and collections) from the GraphQL schema instead of asking them.- Query events (
QueryStartedEvent,QueryExecutedEvent,QueryFailedEvent, all extendingQueryEvent) dispatched through the Symfony event dispatcher, with the query values masked. New dependency:psr/event-dispatcher. GraphqlQuery::getArrayResult(): rows as the API sent them, without hydration, the collection envelopes of the dialect removed.
Fixed
- Queries without variables sent
"variables": [], which Data API Builder 2 (Hot Chocolate) rejects with an HTTP 400: variables are now always sent as a JSON object. - An entity first met as a nested relation reduced to its identifier (beyond
max_depth) was returned as is when its complete row came later in the same response, leaving its other properties uninitialized: it is now completed, and its#[AfterHydrate]hooks run once it is.
[3.0.0] — 2026-10-12
Upgrading from 2.x
- PHP 8.3+ and Symfony 7.4 or 8.x are required.
- Logs and profiler show
***instead of query values: setredact_query_values: false(e.g. underwhen@dev) to see them. DefaultDialectthrows aLogicExceptiononorderBy()andpaginate()instead of ignoring them: configure the dialect matching your API.- With
DataApiBuilderDialect,findBy()criteria are sent as an equalityfilterinstead of plain root arguments. - A repository overriding
findBy()must use the new signaturefindBy(array $criteria, ?array $orderBy = null, ?int $limit = null): array. A repository declaring its ownfind()orfindOneBy()with another signature must rename it or align it on the new methods. - A custom dialect must implement
GraphqlQueryDialect::applyCriteria(); a custom walker extendingAbstractGraphqlWalkermust implementwalkRootField(). GraphqlManagerproperties are private: usegetMetadataFactory(),getHydrator(),getMaxDepth()andgetDialect(), and pass the dialect to the constructor.GraphqlClient,GraphqlQueryCompiler,GraphqlExecutionContextandGraphqlOrmDataCollectorarefinal: decorateGraphqlClientInterfaceinstead of extending the client.- Classes marked
@internalmay change in any release: do not use or extend them. GraphqlManagerand#[GraphqlEntity]are no longer generic: removeGraphqlManager<...>type parameters from PHPDoc.- Outside debug mode, entity metadata is cached in
cache.system: clear or warm up the cache when deploying, as for the rest of the Symfony cache.
Added
- Entity metadata is cached between requests outside debug mode (
metadata_cache_pooloption,cache.systemby default) and warmed up bycache:warmup. headers_provideroption: id of a service implementingHeadersProviderInterface, whose headers are computed before every request (e.g. an OAuth access token) and override the staticheaders.GraphqlEntityRepository::find($id)andfindOneBy($criteria, $orderBy), returning the entity ornull.http_clientoption: id of the HTTP client service to use, e.g. a scoped client with a timeout and retriesvariablesoption (disabled by default): root query arguments are sent as typed GraphQL variables, their types being introspected from the schema and cached (variables.cache_pool,variables.ttl). Values are sent inline when the schema cannot be introspected or an argument is unknown. New dependency:symfony/cache-contracts.redact_query_valuesoption (defaulttrue): query values are masked in logs and in the profiler, as they may hold personal data. The query sent to the API is not affected.
Changed
GraphqlManager, and the#[GraphqlEntity]attribute, are no longer generic: a repository constructor can typeGraphqlManager $managerwithout PHPStan asking for a type parameter.GraphqlManager::execute()takes the entity type from its hydration callable.- Requires PHP 8.3+ and Symfony 7.4 or 8.x (was PHP 8.2+ and Symfony 7.1+): Symfony 8 is now supported, and the Symfony versions no longer maintained are dropped.
GraphqlEntityRepository::findBy()takes an optional ordering and limit (findBy($criteria, $orderBy, $limit)): a repository overridingfindBy()must update its signature.GraphqlQueryDialectdeclaresapplyCriteria(), which turns the criteria of the repository find methods into root arguments: custom dialects must implement it.DataApiBuilderDialectnow sends them as an equalityfilter(isNullfornull) instead of plain root arguments, which Data API Builder rejected;DefaultDialectkeeps plain root arguments.GraphqlManagerdependencies are now private: usegetMetadataFactory(),getHydrator(),getMaxDepth()andgetDialect()instead of the former public properties, and pass the dialect to the constructor instead of assigning$manager->dialect.GraphqlClient,GraphqlQueryCompiler,GraphqlExecutionContextandGraphqlOrmDataCollectorare nowfinal: decorateGraphqlClientInterfaceinstead of extending the client.- Implementation classes are marked
@internaland are not covered by the backward compatibility promise: the query compiler and string builder, the hydrator, the metadata classes, the schema tools, the query redactor, the commands, the event listener, the data collector and the DependencyInjection classes. The public API is made of the attributes,GraphqlManager, the repository, the query builder and query, the dialects, walkers and AST, the client interface, the expressions and the exceptions. DefaultDialectthrows aLogicExceptiononorderBy()andpaginate(), which it used to silently ignore: standard GraphQL defines no ordering nor pagination arguments. Use a dialect matching your API.AbstractGraphqlWalkernow implementswalk()and declares an abstractwalkRootField(): a custom walker extending it must implementwalkRootField(). Walkers implementingGraphqlWalkerInterfacedirectly are not affected (they just do not support variables).- Logs and profiler show
***instead of query values by default; setredact_query_values: false(e.g. underwhen@dev) to see them.
Fixed
PaginatedResult::next()returnsnullwhen the API reports a next page without an end cursor, instead of loading the first page again (which made a loop overnext()endless).- A collection row that is not an object throws an
InvalidGraphqlResponseExceptioninstead of a PHPTypeError.
[2.0.0] — 2026-10-12
Upgrading from 1.x
GraphqlClientnow throwsGraphqlRequestException(aGraphqlOrmException) on transport failure, HTTP status= 400, non-JSON body, or GraphQL errors without data. Code relying on an empty result, or catching Symfony HTTP client exceptions, must catch
GraphqlRequestExceptioninstead.DefaultDialectfilters are now printed as input objects:eq('status', 'active')generatesstatus: { eq: "active" }(it generated the invalidstatus: ["active"]).- An argument or input object name that is not a valid GraphQL name throws
InvalidArgumentException. max_depth(default2) is now applied: the automatic selection loads fewer nested relations than before. Raise it, or use an explicitselect(), to load deeper relations.getOneOrNullResult()sendsfirst: 1when no limit is set: the API must accept afirstargument.- New dependencies:
symfony/type-infoandphpstan/phpdoc-parser.symfony/console^7.3is required. QueryOptions::$filterwas removed (it was never read).GraphqlManager::$collectoris nullable, the profiler collector only exists in debug mode, andEntityHydratoris no longer areadonlyclass.
Added
graphqlorm:schema:validatecommand, to validate the mapping against the schema in CISchemaIntrospector::introspectSchema()also returns the name of the root query type- Collection relations are detected from the property docblock (
@var Task[],list<Task>), throughsymfony/type-infoandphpstan/phpdoc-parser - Hydration of
BackedEnum,DateTimeandDateTimeInterfaceproperties
Changed
GraphqlClientthrowsGraphqlRequestExceptionon transport failure, HTTP status >= 400, non-JSON body, or GraphQL errors without data. Partial data alongside errors is still returned, errors being recorded in the query trace.getOneOrNullResult()requests a single result (first: 1) when no limit is set- The profiler collector is only registered in debug mode;
GraphqlManager::$collectoris nullable EntityHydratorcaches reflection per class and is no longer areadonlyclass- Removed the unused
QueryOptions::$filterproperty - CI: tests also run against the lowest dependencies, and sources are linted on PHP 8.2
Fixed
- GraphQL injection: string values are now escaped and argument / input object names are validated (
InvalidArgumentExceptionon an invalid name) DefaultDialect: filters were printed as lists (status: ["active"]) instead of input objects (status: { eq: "active" })DataApiBuilderDialect: nested collections were hydrated with theiritemsenvelope instead of the related entities- Hydration: invalid
float,boolandstringvalues now throwCastExceptioninstead of aTypeError(or, forstring, silently storing the exception text) - Identity map was ignored when the identifier was not the first mapped property
- PHP 8.2 compatibility: removed a typed class constant (PHP 8.3 syntax)
symfony/consolerequirement raised to^7.3, needed by the invokable commands and#[Argument]/#[Option]- Documentation:
Direction::DESCcase andgetGraphQL()example output max_depthwas ignored: relations beyond it are now reduced to their identifier in the automatic selection- Metadata: a
targetEntityon a union-typed property crashed, and untyped mapped properties were silently ignored - Pagination: fetching another page altered the options of the original query; a
nullroot returned an array instead of an emptyPaginatedResult - Profiler: variables, root result count and hydration depth were never recorded
- Schema validation: the GraphQL type is resolved from the root query field (including Data API Builder connections) instead of being guessed by removing a trailing "s"
- Schema validation: Data API Builder scalars (
Byte,Short,Long,Single,Decimal) were reported as mismatches - Schema introspection:
[Type!]!fields were not fully unwrapped InvalidGraphqlResponseExceptionnow implementsGraphqlOrmException- Invalid dates now throw
CastException - Profiler caller: the path was shortened against a wrong project directory, frames were filtered on a
graphql-ormsubstring (hiding projects whose path contains it), and the recorded class/function was the called one - The container no longer needs the
filesystemservice from FrameworkBundle - Non-finite floats (
INF,NAN) are rejected instead of producing an invalid query graphqlorm:make:entityrejects property and GraphQL field names that would generate invalid code- An entity reused from the identity map is no longer counted twice in the hydration stats
[1.0.0] — 2026-02-28
Added
Hydration hooks
#[BeforeHydrate]— method hook called before field assignment, receives raw GraphQL data array (802e32b)#[AfterHydrate]— method hook called after field assignment, for computing virtual fields and post-processing (802e32b)#[AfterHydrate]is skipped on partial hydration to prevent accessing uninitialized properties
Schema validation (1b3a404)
- Introspection-based schema validation via
SchemaIntrospectorandSchemaValidator - Checks: GraphQL type existence, field existence, PHP/GraphQL scalar compatibility
- Three modes:
exception,warning,disabled - Entity classes discovered automatically from
mapping.entity.dir - Triggered on first HTTP request via
SchemaValidationListener
Field suggestions (1474c8c)
- "Did you mean?" suggestions using Levenshtein distance when a mapped field is not found in the schema
Logging (e6d189b)
- Dedicated
graphql_ormMonolog channel debuglevel for successful queries (endpoint, duration, hydration stats, query, caller)errorlevel for queries returning GraphQL errors
Query duration (843d0a2)
- Query duration measured via Symfony Stopwatch
- Duration exposed in the Symfony Profiler timeline per query
Debug command (ca7b587)
graphqlorm:debug:entity— displays resolved metadata for an entity (table or JSON output)
Pagination (54a7fde)
- Cursor-based pagination via
->paginate() PaginatedResult<T>withitems,hasNextPage,hasPreviousPage,endCursor->next()and->previous()navigation helpers
Query Builder (c149e49)
ExpressionBuilderwitheq,neq,contains,notContains,startsWith,endsWith,in,isNull,andX,orX- Dot notation for nested field selection (
user.name) orderBy(),limit(),addSelect()- Raw GraphQL query via
setGraphQL() - Query inspection via
->getQuery()->getGraphQL()
AST (fde7559)
- AST-based query generation with
QueryNode,FieldNode,SelectionSetNode - Walker pattern with
DefaultGraphqlWalkerandDABGraphqlWalker
Dialects (20c9dcb)
DefaultDialectfor standard GraphQL APIsDataApiBuilderDialectfor Microsoft Data API Builder withitemsenvelope and cursor paginationGraphqlQueryDialectinterface for custom implementations
Entity generator (429a1cc)
graphqlorm:make:entity— interactive wizard generating entity + repository- Autocomplete on existing entities for relation targets
Profiler (f5e2a49)
GraphqlOrmDataCollectorwith per-query details: endpoint, duration, response size, hydration stats, AST viewer, variables, caller- GraphQL errors highlighted in the toolbar
CI (7faff8e)
- GitHub Actions CI pipeline
Tests (eb6115f)
- PHPUnit test suite covering hydration, metadata, schema validation, introspection, hydration hooks
Fixed
- Nested collection relations missing
itemswrapper in generated query forDataApiBuilderDialect(582a0d8) - Empty error message when query had no arguments (
352c3c1) - Deprecated use of
Symfony\Component\HttpKernel\DependencyInjection\Extension(a306a4d)
Changed
- HTTP client options
verify_hostandverify_peermade configurable (4f483ec) - Documentation moved from
README.mdtodocs/directory (6548bda) - Symfony requirement updated to
^7.1for#[Argument]and#[Option]console attribute support - PHP requirement set to
^8.2forreadonly classsupport
[0.1.0] — 2026-02-21
Initial commit — core entity mapping, hydration, identity map, repositories, and cycle-safe relation loading.