Skip to content

Add @hook directive for locally resolved lazy properties#43

Merged
ruudk merged 1 commit intomainfrom
hooks
Apr 18, 2026
Merged

Add @hook directive for locally resolved lazy properties#43
ruudk merged 1 commit intomainfrom
hooks

Conversation

@ruudk
Copy link
Copy Markdown
Owner

@ruudk ruudk commented Apr 18, 2026

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.

Tests

  • tests/Hooks/ – plain fixture: one invokable hook class, one directive use, byte-for-byte testGenerate and a testQuery using manually-wired hooks.
  • tests/HooksWithSymfonyAutowire/ – same shape with enableSymfony AutowireHooks() enabled. Verifies the generated #[Autowire] attribute is correct, and that a real Symfony ContainerBuilder can 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.

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 ruudk merged commit 3f3ecdc into main Apr 18, 2026
3 checks passed
@ruudk ruudk deleted the hooks branch April 18, 2026 11:55
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant