refactor(hooks): consolidate proceed() via getOriginalCallable, stub-type the hook layer, final concrete hooks - #205
Merged
lisachenko merged 6 commits intoAug 14, 2026
Conversation
Twenty-two hooks hand-rolled the same prologue before invoking the captured engine pointer: a hasOriginalHandler() check throwing a literal LogicException, followed by an untyped `($this->originalHandler)(...)` invocation that static analysis can only see as "trying to invoke FFI\CData". AbstractHook::getOriginalCallable() already is that prologue - it throws the exact same exception and hands back a value narrowed to `callable` - and seven hooks were converted to it already (five of them kept the now-dead guard in front of the call). The remaining ones are converted here and the dead guards are dropped, so "no original handler" is worded in exactly one place. CreateObjectHook keeps its two-branch shape (it falls back to ReflectionClass::newInstanceRaw when no handler was captured) but asks hasOriginalHandler() instead of comparing the raw property, and takes the handler through the same accessor. Twelve callable.nonCallable baseline entries and two inline @PHPStan-Ignore comments become unnecessary and are removed. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
…guarantee Half the hook family declared `public function proceed()` with no return type at all, so every consumer of a proceed() result had to guess and PHPStan carried a missingType.return entry per method. The types are derivable: each class documents the C callback typedef it implements, and the value proceed() hands back either comes straight out of that engine handler (guaranteed shape: FFI returns an int for an int-returning function pointer and CData-or-null for a pointer-returning one) or out of a ReflectionValue accessor. Declared accordingly - int for the verdict-returning handlers (compare, do_operation, interface_gets_implemented, cast_object), ?object for the pointer-returning ones (get_property_ptr_ptr, get_properties_for), object for write_property (the standard handler reports the written slot or &EG(error_zval), never NULL), void for unset_property and zend_ast_process, mixed for the accessors that materialize an engine value into a PHP one. One deliberate exception: GetPropertyPointerHook::handle() stays `mixed`, because its value comes from the USER handler rather than the engine. A stricter declaration would turn a userland contract violation into a TypeError raised inside an FFI trampoline, where nothing can catch it (issue #50). 21 more baseline entries (missingType.return plus two return.type) drop out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
…d struct stubs
The hook layer is the one subsystem that missed the struct-stub cutover: every
engine pointer a hook holds was declared as a bare FFI\CData, and since the
libffi trampoline delivers the callback arguments as `mixed`, each assignment
produced an "(FFI\CData) does not accept mixed" entry in the baseline - 84 of
them across src/ClassExtension/Hook, a fifth of the whole file.
Applied the convention from AGENTS.md ("Engine structs are typed by generated
stub classes"), exactly as the Reflection\* and Type\* layers already do it:
the field is declared `object` and carries a `/** @var <stub> */` docblock
naming its ZEngine\Generated view, and handle() - the single boundary where the
raw callback arguments arrive - narrows them once with a `@var` block over the
destructuring. That narrowing replaces the `assert($x instanceof CData)`
scaffolding the same statements used to carry (AGENTS.md calls out that weak
form as what the stub migration supersedes); the asserts that check real engine
or handler invariants stay.
Fields the stubs deliberately do not model keep their CData declaration with a
note saying why: the `void **` property cache slot, the `int *`/`zend_long *`
out-parameters of get_debug_info and count_elements, and the zend_object** /
zend_function** / zend_class_entry** double pointers of get_method and
get_closure (the stubs model structs, not pointer-to-pointer handles).
AstProcessHook keeps a raw zend_ast handle too, because the AST wrappers that
own that struct (NodeFactory/Node) have not been migrated yet.
Two owning boundaries were widened to accept a stub view alongside CData, the
usual `@param CData|Stub` shape: Executor::setFakeScope()/withFakeScope() (every
property hook now passes a typed `$object->ce`) and
ReflectionClass::newInstanceRaw().
Also removed CompareValuesHook::$returnValue, a protected property that was
declared but never read or written - compare_values has no result slot.
52 more baseline entries drop out; hook entries are down from 91 to 6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
AbstractMethodResolutionHook::resolveRawFunction() reached into a class entry by hand: it looked the entry up in the engine class table, read ->function_table off the raw struct, took its address and built a Type\HashTable around it - the exact "callers never reach into a raw CData" pattern AGENTS.md forbids, and a duplicate of what ReflectionClass already does when it is constructed. It now goes through the owning object: `new ReflectionClass($className)` does the class-table lookup and raises the "should be in the engine" ReflectionException itself, and getMethodTable() is the accessor that owns ce->function_table. Eleven lines become three, and no call site outside ReflectionClass touches zend_class_entry.fields any more. Note on the alternative: PR #196 proposed a static entry-level helper (ReflectionClass::entryMethodTable()) for this shape. That helper is being withdrawn as against the framework's object model - consumers hold Reflection* objects, not raw entries plus static utilities - so this uses the instance API and does not depend on that PR. The extra cost (a native reflection constructor and three unused table wrappers) is paid only on the slow path: a wrapper produced by proceed() short-circuits on the identity fast path above, and the VM inline-caches the resolved function per call site for compile-time constant method names. While here, $proceedRawFunction and the two handle() return docs get their zend_function|zend_internal_function stub views, retiring the last hook return.type baseline entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
IteratorBridge kept the per-iteration state as an array shape
(`array{iterator: Iterator, pointer: CData, broken: bool}`). PHP arrays are
value types, so what each vtable callback read out of the registry was a
snapshot: marking an iteration broken could not be done on the record in hand
and had to go back through a second registry lookup by address, with an isset()
recheck around it - a shape that only existed to work around the copy.
The record is now a tiny `@internal` BridgedIterator with public typed
properties, so it is shared by reference. Every callback preamble is one lookup,
breakIteration() takes the state it is given and writes the flag straight
through it, and the iterator/pointer pair is readonly and named rather than
string-keyed.
Behavior is unchanged for every iteration path; the one difference is in the
pathological case where the engine iterator is destroyed from inside the
userland callback that then throws - the swallowed Throwable is now reported as
the E_USER_WARNING it always should have been, instead of being dropped because
the registry entry had already gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
Every concrete hook in ClassExtension\Hook and System\Hook is a leaf: it implements one specific engine callback typedef and is instantiated by the extension machinery, never derived from. Nothing in src/ or tests/ extends one, and the two hooks that already implement HookInterface directly (OpCodeHook, IteratorBridge) have been final since they were written. Marking the remaining 27 final makes the extension point explicit - hooks are customized by passing a user handler closure, not by subclassing - and keeps the Abstract* bases as the only place a new hook shape can be introduced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
lisachenko
marked this pull request as ready for review
August 14, 2026 14:12
lisachenko
deleted the
claude/php-repo-modernization-6pfu7y-hook-consolidation
branch
August 14, 2026 14:12
This was referenced Aug 14, 2026
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.
Stacked on #197 — contains its commits; merge #197 first and this diff reduces to the hook-consolidation changes.
Six focused refactors of the engine hook layer (
src/ClassExtension/Hook,src/System/Hook), no behavior change. #197 had just rewritten the fake-scope handling in seven of these hooks, so this builds on that shape rather than fighting it.What changed
1. One
proceed()prologue instead of twenty-two. Every hook hand-rolledif (!$this->hasOriginalHandler()) { throw new \LogicException('Original handler is not available'); }followed by an untyped($this->originalHandler)(...)call.AbstractHook::getOriginalCallable()already is that prologue — it throws the same exception and returns a value narrowed tocallable— and two hooks used it already. The rest are converted; five hooks that already called it kept a now-dead guard in front, which is deleted.CreateObjectHookkeeps its two-branch shape (it falls back toReflectionClass::newInstanceRaw()when no handler was captured) but askshasOriginalHandler()instead of comparing the raw property.2. Return types on the converted
proceed()methods. Half of them were barepublic function proceed(). Each type is derived from the C callback typedef the class documents and from what the value actually is:intfor the verdict-returning handlers (compare, do_operation, interface_gets_implemented, cast_object),?objectfor the pointer-returning ones (get_property_ptr_ptr, get_properties_for),objectfor write_property (zend_std_write_propertyreports the written slot or&EG(error_zval), never NULL),voidfor unset_property and zend_ast_process,mixedfor accessors that materialize an engine value into a PHP one.GetPropertyPointerHook::handle()deliberately staysmixed: its value comes from the user handler, and a stricter declaration would turn a userland contract violation into a TypeError raised inside an FFI trampoline where nothing can catch it (issue #50).3. Stub-typed engine state. The hook layer was the one subsystem that missed the generated-struct-stub cutover, which is where the bulk of its baseline came from: the libffi trampoline delivers callback arguments as
mixed, and every assignment into a bareFFI\CDatafield produced anassign.propertyTypeentry. Applied the AGENTS.md convention exactly asReflection\*/Type\*do it — the field is declaredobjectand carries a@vardocblock naming itsZEngine\Generatedstub view, with one narrowing at thehandle()boundary where the raw arguments arrive. That narrowing replaces theassert($x instanceof CData)scaffolding those statements carried (AGENTS.md names that weak form as what the stub migration supersedes); asserts that check real engine or handler invariants stay.Handles the stubs deliberately do not model keep a
CDatadeclaration with a note saying why: thevoid **property cache slot, theint */zend_long *out-parameters of get_debug_info and count_elements, and thezend_object **/zend_function **/zend_class_entry **double pointers of get_method and get_closure.AstProcessHookkeeps a rawzend_asthandle too, because the AST wrappers that own that struct (NodeFactory/Node) have not been migrated yet.Two owning boundaries were widened to the usual
@param CData|Stubshape:Executor::setFakeScope()/withFakeScope()(every property hook now passes a typed$object->ce) andReflectionClass::newInstanceRaw(). Also removedCompareValuesHook::$returnValue, declared but never read or written — compare_values has no result slot.4.
AbstractMethodResolutionHookgoes through the owning reflection.resolveRawFunction()looked the class up in the engine class table, read->function_tableoff the raw struct, took its address and wrapped it in aType\HashTable— the "callers never reach into a raw CData" rule, and a duplicate of whatReflectionClassdoes when constructed. It is now(new ReflectionClass($className))->getMethodTable(): the constructor performs the class-table lookup and raises the "should be in the engine"ReflectionExceptionitself, andgetMethodTable()is the accessor that ownsce->function_table. Eleven lines become three.On the alternative: PR #196 proposed a static entry-level helper (
ReflectionClass::entryMethodTable()) for this shape. That helper is being withdrawn as against the framework's object model — consumers holdReflection*objects, not raw entries plus static utilities — so this uses the instance API and takes no dependency on #196. The extra cost (a native reflection constructor plus three unused table wrappers) is paid only on the slow path: a wrapper produced byproceed()short-circuits on the identity fast path above it, and the VM inline-caches the resolved function per call site for compile-time constant method names.5.
IteratorBridgestate is shared, not copied. The per-iteration record was an array shape, and PHP arrays are value types — so what each vtable callback read out of the registry was a snapshot, and marking an iteration broken had to go back through a second registry lookup by address with anisset()recheck around it. It is now a tiny@internalBridgedIteratorwith public typed properties: one lookup per callback preamble, andbreakIteration()writes the flag straight through the record it is handed. The one observable difference is in the pathological case where the engine iterator is destroyed from inside the userland callback that then throws — the swallowedThrowableis now reported as theE_USER_WARNINGit always should have been, instead of being dropped because the registry entry had already gone.6.
finalon the concrete hooks. Every concrete hook is a leaf implementing one engine callback typedef; nothing insrc/ortests/extends one, and the two that implementHookInterfacedirectly (OpCodeHook,IteratorBridge) were final already. 27 more are sealed, which makes the extension point explicit: hooks are customized by passing a user-handler closure, not by subclassing.Baseline delta
phpstan-baseline.neon, total entriessrc/ClassExtension/Hook+src/System/HookEntries were pruned by hand, verified with a throwaway override config setting
reportUnmatchedIgnoredErrors: true(phpstan.dist.neonturns it off). Under that config the only remaining findings are the eight pre-existing unmatched inline@phpstan-ignoreidentifiers inCountElementsHook/GetClosureHook/GetMethodHook— unchanged in count and kind by this PR. Two further inline@phpstan-ignore callable.nonCallablecomments were removed because the code no longer produces the error.The five hook-related baseline entries that remain are outside this PR's scope:
Hook\AbstractHook(2),Hook\HookInterface(1) andEngineExtension\Hook\ExtensionConstructorHook(2).Validation
vendor/bin/phpstan analyseclean at level max andPHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-runclean after every commit.Tests were not run. This branch targets PHP 8.4 and the container runs PHP 8.5, so per the AGENTS.md version rule nothing that reaches
Core::init()may be executed here — validation is static-only. CI runs the suite on the matching interpreter.🤖 Generated with Claude Code
https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG