Conversation
Generated response objects can now expose properties that are not backed
by the GraphQL response but resolved at access time by user-supplied code.
The common case: the server returns a scalar (typically an id), and the
application wants the corresponding domain object (fetched from a local
database or cache) to appear on the response next to the existing fields,
as if the GraphQL server had returned it.
The directive is a generator-only marker. It is stripped from the operation
document before schema validation and before the request is sent, so the
server never sees it.
Usage
Define an invokable class and tag it with `#[Hook(name: ...)]`:
#[Hook(name: "findUserById")]
final readonly class FindUserByIdHook {
public function __construct(private UserRepository $users) {}
public function __invoke(string $id): ?User
{
return $this->users->find($id);
}
}
Register it once:
Config::create(...)
->withHook(FindUserByIdHook::class);
And reference it from a query with a synthetic field:
query Project {
project(id: "42") {
name
creator { id }
user @hook(name: "findUserById", input: ["creator.id"])
}
}
`input` is a list of dotted paths resolved against the enclosing selection.
Each path becomes a positional argument to `__invoke` in order, so a hook
with `input: ["a.b", "c"]` is invoked as `$hook->__invoke($data[a][b], $data[c])`.
The generated Project class gains a lazy `$user` property that caches after
first access:
public ?User $user {
get => $this->user ??= $this->hooks['findUserById']->__invoke(
$this->data['creator']['id'],
);
}
How it works
- `src/Attribute/Hook.php` – attribute carrying the hook name; lives on the
hook class itself.
- `src/Config/HookDefinition.php` and `Config::withHook()` – register a hook.
The class must define `__invoke`. The return type is inferred from the
`__invoke` signature via Symfony TypeInfo's ReflectionReturnTypeResolver,
so config never has to restate it.
- `src/GraphQL/HookDirectiveSchemaExtender.php` – mirrors `IndexByDirective`:
adds `directive @hook(name: String!, input: [String!]!) on FIELD` to the
schema only when any hook is registered.
- `src/Visitor/HookFieldRemover.php` – strips every FIELD carrying `@hook`
from a document. Invoked twice in `Planner::planOperation`: once against
a copy fed to `DocumentValidator::validate` (hook field names are not in
the schema, so default `FieldsOnCorrectType` would reject them), and once
against the document printed into `OPERATION_DEFINITION`. The original AST
passed to the planner keeps its hook fields so they produce properties.
- `src/Planner/PayloadShapeBuilder.php` – skips FIELDs with `@hook` when
building the `@param array{...} $data` shape, keeping the payload pure.
- `src/Planner/SelectionSetPlanner::processFieldSelection` – when it sees a
field with `@hook`, looks up the `HookDefinition`, adds a `HookPropertyType`
entry to the field collection (carrying hook name + input paths + return
type), and returns early instead of resolving against the schema.
- `src/Type/HookPropertyType.php` – a marker type that wraps the hook's
return type. Implements `WrappingTypeInterface` so existing type dumpers
(DataClassGenerator, `TypeDumper`, `AbstractGenerator::dumpPHPType`) treat
it transparently and emit the real PHP type on the property annotation.
- `src/Planner.php::propagateUsedHooks` – runs after all plans are built.
Walks every `DataClassPlan`'s fields in one pass, collecting both direct
hook uses and child-class FQCNs, then iterates to a fixed point so every
plan that *transitively* constructs a hook-using child knows to accept
and forward the `$hooks` constructor arg.
- `src/TypeInitializer/ClassHookUsageRegistry.php` – populated by
`PlanExecutor::execute()` before emission. Consulted by
`ObjectTypeInitializer` so `new Child($data)` becomes `new Child($data,
$this->hooks)` whenever the child needs hooks, and by
`OperationClassGenerator` so the generated operation class knows what
hooks the root Data class requires.
Runtime shape
At every class that transitively hosts a hook, the generator emits a new
constructor parameter with a typed shape:
/**
* @param array{
* 'findUserById': FindUserByIdHook,
* } $hooks
*/
public function __construct(
private readonly array $data,
private readonly array $hooks,
) {}
PHPStan sees `$this->hooks['findUserById']` as `FindUserByIdHook`, catching
renamed-hook typos at static-analysis time.
The hook property getter uses `dumpCall()` so call formatting matches the
rest of the generator (single-arg calls inline, multi-arg calls wrap).
Symfony autowire
`Config::enableSymfonyAutowireHooks()` opts into a Symfony-specific
convenience on the generated operation class only (not on intermediate
Data classes – the array is built once on the client boundary and threaded
down untouched):
public function __construct(
private TestClient $client,
#[Autowire([
'findUserById' => new Autowire(service: FindUserByIdHook::class)
])]
private array $hooks,
) {}
With this in place the whole graph compose via `ContainerBuilder` with
`setAutowired(true)` – `HooksWithSymfonyAutowireTest::testQueryWithContainer`
exercises it end-to-end by actually resolving the query from a live container
and asserting hooks return populated entities.
ruudk
added a commit
that referenced
this pull request
Apr 18, 2026
The `@hook` directive added in #43 forwards `$this->hooks` into child constructors via `ObjectTypeInitializer`, which consults a `ClassHookUsageRegistry` owned by `PlanExecutor`. Projects that registered their own `ObjectTypeInitializer` via `Config::withTypeInitializer()` to customize a specific value object (Money, Url, …) broke the hook forwarding: the dedup-by-class-name in `DelegatingTypeInitializer` let the user instance — constructed without the registry — overwrite the built-in one, so every `new Child($data)` was emitted without hooks. Splitting the two roles removes the footgun. `ObjectTypeInitializer` is now strictly the internal catch-all (registry injected via constructor, no extension surface). Userland registers plain `TypeInitializer` instances that match only their specific class; the delegation chain runs them ahead of the catch-all, so type-specific handlers win for their own types and everything else falls through to the built-in one with hooks intact. Breaking change for any config that wrapped a handler in `ObjectTypeInitializer` — unwrap it and pass the inner `TypeInitializer` directly to `withTypeInitializer()`.
ruudk
added a commit
that referenced
this pull request
Apr 18, 2026
…#44) The `@hook` directive added in #43 forwards `$this->hooks` into child constructors via `ObjectTypeInitializer`, which consults a `ClassHookUsageRegistry` owned by `PlanExecutor`. Projects that registered their own `ObjectTypeInitializer` via `Config::withTypeInitializer()` to customize a specific value object (Money, Url, …) broke the hook forwarding: the dedup-by-class-name in `DelegatingTypeInitializer` let the user instance — constructed without the registry — overwrite the built-in one, so every `new Child($data)` was emitted without hooks. Splitting the two roles removes the footgun. `ObjectTypeInitializer` is now strictly the internal catch-all (registry injected via constructor, no extension surface). Userland registers plain `TypeInitializer` instances that match only their specific class; the delegation chain runs them ahead of the catch-all, so type-specific handlers win for their own types and everything else falls through to the built-in one with hooks intact. Breaking change for any config that wrapped a handler in `ObjectTypeInitializer` — unwrap it and pass the inner `TypeInitializer` directly to `withTypeInitializer()`.
ruudk
added a commit
that referenced
this pull request
Apr 18, 2026
PR #43 taught data classes to accept and store $hooks, and the planner marks every transitive parent as hook-using -- but the fragment-spread getter templates in DataClassGenerator kept hard-coding `new Child($this->data)`, so the stored hooks never reached the child. PHPStan flagged each intermediate class with arguments.count on the child constructor and property.onlyWritten on its own $hooks property. Delegate the constructor expression to $typeInitializer, which already consults ClassHookUsageRegistry via ObjectTypeInitializer, instead of rebuilding `new X(...)` by hand in four places.
ruudk
added a commit
that referenced
this pull request
Apr 18, 2026
PR #43 taught data classes to accept and store $hooks, and the planner marks every transitive parent as hook-using -- but the fragment-spread getter templates in DataClassGenerator kept hard-coding `new Child($this->data)`, so the stored hooks never reached the child. PHPStan flagged each intermediate class with arguments.count on the child constructor and property.onlyWritten on its own $hooks property. Delegate the constructor expression to $typeInitializer, which already consults ClassHookUsageRegistry via ObjectTypeInitializer, instead of rebuilding `new X(...)` by hand in four places.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Generated response objects can now expose properties that are not backed by the GraphQL response but resolved at access time by user-supplied code. The common case: the server returns a scalar (typically an id), and the application wants the corresponding domain object (fetched from a local database or cache) to appear on the response next to the existing fields, as if the GraphQL server had returned it.
The directive is a generator-only marker. It is stripped from the operation document before schema validation and before the request is sent, so the server never sees it.
Usage
Define an invokable class and tag it with
#[Hook(name: ...)]:Register it once:
And reference it from a query with a synthetic field:
inputis a list of dotted paths resolved against the enclosing selection. Each path becomes a positional argument to__invokein order, so a hook withinput: ["a.b", "c"]is invoked as$hook->__invoke($data[a][b], $data[c]).The generated Project class gains a lazy
$userproperty that caches after first access:How it works
src/Attribute/Hook.php– attribute carrying the hook name; lives on the hook class itself.src/Config/HookDefinition.phpandConfig::withHook()– register a hook. The class must define__invoke. The return type is inferred from the__invokesignature via Symfony TypeInfo's ReflectionReturnTypeResolver, so config never has to restate it.src/GraphQL/HookDirectiveSchemaExtender.php– mirrorsIndexByDirective: addsdirective @hook(name: String!, input: [String!]!) on FIELDto the schema only when any hook is registered.src/Visitor/HookFieldRemover.php– strips every FIELD carrying@hookfrom a document. Invoked twice inPlanner::planOperation: once against a copy fed toDocumentValidator::validate(hook field names are not in the schema, so defaultFieldsOnCorrectTypewould reject them), and once against the document printed intoOPERATION_DEFINITION. The original AST passed to the planner keeps its hook fields so they produce properties.src/Planner/PayloadShapeBuilder.php– skips FIELDs with@hookwhen building the@param array{...} $datashape, keeping the payload pure.src/Planner/SelectionSetPlanner::processFieldSelection– when it sees a field with@hook, looks up theHookDefinition, adds aHookPropertyTypeentry to the field collection (carrying hook name + input paths + return type), and returns early instead of resolving against the schema.src/Type/HookPropertyType.php– a marker type that wraps the hook's return type. ImplementsWrappingTypeInterfaceso existing type dumpers (DataClassGenerator,TypeDumper,AbstractGenerator::dumpPHPType) treat it transparently and emit the real PHP type on the property annotation.src/Planner.php::propagateUsedHooks– runs after all plans are built. Walks everyDataClassPlan's fields in one pass, collecting both direct hook uses and child-class FQCNs, then iterates to a fixed point so every plan that transitively constructs a hook-using child knows to accept and forward the$hooksconstructor arg.src/TypeInitializer/ClassHookUsageRegistry.php– populated byPlanExecutor::execute()before emission. Consulted byObjectTypeInitializersonew Child($data)becomesnew Child($data, $this->hooks)whenever the child needs hooks, and byOperationClassGeneratorso the generated operation class knows what hooks the root Data class requires.Runtime shape
At every class that transitively hosts a hook, the generator emits a new constructor parameter with a typed shape:
PHPStan sees
$this->hooks['findUserById']asFindUserByIdHook, catching renamed-hook typos at static-analysis time.The hook property getter uses
dumpCall()so call formatting matches the rest of the generator (single-arg calls inline, multi-arg calls wrap).Symfony autowire
Config::enableSymfonyAutowireHooks()opts into a Symfony-specific convenience on the generated operation class only (not on intermediate Data classes – the array is built once on the client boundary and threaded down untouched):With this in place the whole graph compose via
ContainerBuilderwithsetAutowired(true)–HooksWithSymfonyAutowireTest::testQueryWithContainerexercises it end-to-end by actually resolving the query from a live container and asserting hooks return populated entities.Tests
tests/Hooks/– plain fixture: one invokable hook class, one directive use, byte-for-bytetestGenerateand atestQueryusing manually-wired hooks.tests/HooksWithSymfonyAutowire/– same shape withenableSymfony AutowireHooks()enabled. Verifies the generated#[Autowire]attribute is correct, and that a real SymfonyContainerBuildercan autowire the query (catches attribute-shape regressions the byte-comparison cannot).README gets a "Local Resolution with @hook Directive" section covering hook class definition, config, directive syntax, runtime usage, and the Symfony autowire shortcut.