Skip to content

Resolve offsetSet() template types for ArrayAccess offset writes - #6903

Draft
westonruter wants to merge 8 commits into
phpstan:2.3.xfrom
westonruter:fix-arrayaccess-offsetset-method-template
Draft

westonruter wants to merge 8 commits into
phpstan:2.3.xfrom
westonruter:fix-arrayaccess-offsetset-method-template

Conversation

@westonruter

@westonruter westonruter commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

An offset write $object[$offset] = $value was checked against the parameters of offsetSet() taken from getOnlyVariant(), so method-level template types were never resolved. With an offsetSet() declared like this:

/**
 * @template TOffset of key-of<TArray>
 * @param TOffset $offset
 * @param TArray[TOffset] $value
 */
public function offsetSet(mixed $offset, mixed $value): void;

the accepted offset type stayed the unresolved TOffset, and the accepted value type stayed TArray[TOffset]. As a result, every write was rejected, valid ones included (offsetAssign.dimType and offsetAssign.valueType). An explicit $object->offsetSet($offset, $value) call was already accepted, since a method call resolves the templates from its arguments.

Compound assignments had the same problem on the read side. A plain read $object[$offset] is analysed as a simulated offsetGet() call, so it already resolved @return TArray[TOffset]. But ??=, +=, and .= read the current value through ObjectType::getOffsetValueType(), which also used getOnlyVariant(). For Options<array{name: string, age: int}>, $options['age'] ??= 5 was reported as "does not accept int|string", and $options['age'] += 1 was inferred as an error type.

Changes

  • ObjectType::setOffsetValueType() selects the offsetSet() variant from the written offset and value types, and ObjectType::getOffsetValueType() selects the offsetGet() variant from the offset type, with ParametersAcceptorSelector::selectFromTypes(), as a method call does.
  • When the method has a single variant without template or late-resolvable types (ArrayObject, most collections), that variant is used as is, without resolution. On a file of 6,000 writes and compound assignments to an ArrayObject and a plain ArrayAccess<int, int> class, resolving every access cost about 15% more user CPU time. With this fast path, the difference is about 2% without the turbo extension and about 1% with it, which is within the noise of that measurement (6 interleaved runs each).
  • OffsetAccessValueAssignmentRule checks the value against the written offset (null for an append) instead of mixed, so TArray[TOffset], or a conditional type such as ($offset is null ? int : string), resolves for that key. When the offset itself is rejected, the rule falls back to mixed, since OffsetAccessAssignmentRule already reports that offset. An accepted write costs one setOffsetValueType() call.
  • The native ObjectType in turbo-ext gets the same changes. turbo-ext/tests/type-family.php gains observations on a templated ArrayAccess fixture, an offset-conditional one, and a generic ArrayObject. Against a build of the native ObjectType without these changes, 96 of them differ from the PHP twin. turbo-ext/src/generated/ObjectType.h is regenerated for the new private method.

Left unchanged:

  • ObjectType::hasOffsetValueType() still reads offsetSet() through getOnlyVariant(). For ArrayAccess, it answers only no or maybe, and the bound of the unresolved TOffset (key-of<TArray>) already gives the right "no". Resolving the template gave identical results for unset, isset, ??, compound assignments, and union keys.
  • AssignHandler::collectOffsetSetUsage() also still uses getOnlyVariant(). With unresolvedTemplateArguments enabled, an array-access write already infers the same template arguments as the equivalent explicit offsetSet() call.

Known limitation and a possible follow-up

setOffsetValueType() returns a single ErrorType whether the offset or the value is rejected. So OffsetAccessValueAssignmentRule tells the two apart by probing: it checks the write, then the offset alone, and then the value against a mixed offset when the offset itself is rejected. As a result, when an offset is only partly accepted, a wrong value for its accepted part is not reported separately. For example, with Options<array{name: string, age: int}> and 'age'|'nope' $key, $options[$key] = 'foo' gets only Cannot assign offset 'age'|'nope' … from OffsetAccessAssignmentRule, not also a value error for 'age'. The line is still reported once. Before this change, it got both errors, as did every valid write.

A way to remove the probing would be for ObjectType to expose the accepted offset and value types of the resolved offsetSet() variant. The rule could then check the value against the value type for the accepted part of the offset, with no mixed fallback, and a rejected write would cost one variant resolution instead of up to three. That would add API to ObjectType (and its native twin), so it is left out of this PR. It could be added if wanted.

Tests

The regression fixture starts with the issue's playground snippet verbatim. Further cases follow it: an unknown key, an append, a wrong value for a second key, an array<string, mixed> map, compound assignments, and union keys. tests/PHPStan/Analyser/nsrt/bug-8549.php covers the inferred types of reads and compound assignments. Each new test fails without its source change and passes with it. The full test suite passes with and without the turbo extension loaded, as does turbo-ext/tests/smoke.php. make lint-turbo and make sanitize-turbo were not run locally.

Where this came up

This was found in Gutenberg, through WordPress/gutenberg#86110. In the php-stubs/wordpress-stubs package, WP_REST_Request is declared @template T of array = array<string, mixed>, and its offsetSet() and set_param() both use the TOffset of key-of<T> / T[TOffset] pattern above. Gutenberg's attachments controller builds a request with array access:

$response_request = new WP_REST_Request( WP_REST_Server::READABLE, rest_get_route_for_post( $attachment_id ) );
$response_request['context'] = 'edit';
if ( isset( $request['_fields'] ) ) {
	$response_request['_fields'] = $request['_fields'];
}

PHPStan reported both writes, although T is array<string, mixed> and both are valid:

Cannot assign offset 'context' to WP_REST_Request<array<string, mixed>>.  (offsetAssign.dimType)
WP_REST_Request<array<string, mixed>> does not accept string.             (offsetAssign.valueType)
Cannot assign offset '_fields' to WP_REST_Request<array<string, mixed>>.  (offsetAssign.dimType)

The errors had been suppressed in Gutenberg's PHPStan baseline. That PR removes the baseline entries by rewriting the writes as $response_request->set_param( 'context', 'edit' ) and $response_request->set_param( '_fields', $request['_fields'] ). The workaround passes because set_param() is analysed as a method call, which resolves TOffset from its argument. Only the array-access path skipped that step. Array access on WP_REST_Request is idiomatic across WordPress core and plugins, though, so rewriting call sites to avoid a false positive is not a general fix.

With this change, the original array-access code passes as it is, so the baseline entries can be dropped without the rewrite. Array-access writes also get the same per-key checking a set_param() call gets. With WP_REST_Request<array{context: 'view'|'edit'|'embed', _fields?: string}>, $request['context'] = 'edit' is accepted, while $request['context'] = 'nonsense', $request['_fields'] = 1, and $request['nope'] = 'x' are reported. Before this change, all of these writes were reported, valid and invalid alike, so the errors carried no signal.

Closes phpstan/phpstan#8549

🤖 Generated with Claude Code using Claude Opus 5.5 · high effort

An offset write `$object[$offset] = $value` was checked against the
parameters of offsetSet() taken from getOnlyVariant(), so method-level
template types were never resolved. With an offsetSet() declared as

    @template TOffset of key-of<T>
    @PARAM TOffset $offset
    @PARAM T[TOffset] $value

the accepted offset type stayed the unresolved TOffset and the accepted
value type T[TOffset], so every write was rejected, including valid
ones. The explicit $object->offsetSet($offset, $value) call was already
accepted, since a method call resolves the templates from its arguments.

ObjectType::setOffsetValueType() now selects the offsetSet() variant
from the written offset and value types, as a call does.
OffsetAccessValueAssignmentRule passes the written offset type, not
mixed, so T[TOffset] resolves to the value type for that key. When the
offset itself is rejected, the rule falls back to mixed, since
OffsetAccessAssignmentRule already reports that offset.

The native ObjectType in turbo-ext gets the same change.

Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
westonruter and others added 7 commits November 1, 2026 22:46
A plain read `$object[$offset]` is analysed as a simulated offsetGet()
call, so a method-level `@template TOffset of key-of<T>` with
`@return T[TOffset]` already resolved for it. Compound assignments
(`??=`, `+=`, `.=`) read the current value through
ObjectType::getOffsetValueType() instead, which took offsetGet()'s
return type from getOnlyVariant() with TOffset unresolved. For
`Options<array{name: string, age: int}>`, `$options['age'] ??= 5` read
the value as `int|string` and reported it as not accepted, and
`$options['age'] += 1` was inferred as an error type.

getOffsetValueType() now selects the offsetGet() variant from the
offset type, as setOffsetValueType() does for offsetSet().

In the native ObjectType, the getMethod() lookup and the variant
selection move into helpers that the offsetGet() and offsetSet() paths
share.

Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
Resolving the offsetGet()/offsetSet() variant against the offset and
value types runs on every ArrayAccess read in a compound assignment and
on every ArrayAccess write. Most ArrayAccess classes (ArrayObject,
collections, option bags) declare no method-level template types, so
the resolution only allocated a resolved variant equal to the original.

When the method has a single variant without template or late-resolvable
types, it is now used as is, as before. On a file of 6,000 writes and
compound assignments to an ArrayObject and a plain ArrayAccess<int, int>
class, analysis took about 15% more user CPU time than before the
resolution was added. It now takes about 2% more, which is within the
noise of that measurement.

The native ObjectType gets the same fast path.

Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
OffsetAccessValueAssignmentRule probed every write with
setOffsetValueType($dimType, mixed) to decide which offset type to check
the value against, and then called setOffsetValueType() again with the
value. It now checks the write with the written offset first, and only
if that is rejected does it probe the offset alone, falling back to a
mixed offset when the offset itself is rejected. The outcome is the
same, but an accepted write costs one setOffsetValueType() call instead
of two.

Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
With a union offset such as 'name'|'age', TOffset resolves to the union
and T[TOffset] to the union of the value types for those keys, so a value
of either key's type is accepted and a value of neither is reported. An
offset that is only partly known, such as 'name'|'nope', is reported once
by OffsetAccessAssignmentRule, and OffsetAccessValueAssignmentRule does
not report its value as well.

Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
For an append (`$object[] = $value`), OffsetAccessValueAssignmentRule
checked the value against a mixed offset, while the write itself goes
through ObjectType::setOffsetValueType() with a null offset. Since
offsetSet()'s parameters are now resolved against the written offset,
a value type depending on the offset, such as
`@param ($offset is null ? int : string) $value`, resolved to
`int|string` for the check and `$object[] = 'str'` was no longer
reported.

The rule now passes the append's null offset on to
setOffsetValueType(), and falls back to a mixed offset only when the
append itself is rejected, as for any other rejected offset.

Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
The native ObjectType now selects the offsetGet()/offsetSet() variant
from the offset and value types, but type-family.php only probed the
offset operations on plain ObjectType(ArrayAccess) and ArrayObject,
which never reach the template resolution.

A fixture adds an ArrayAccess class whose offsetGet() and offsetSet()
declare `@template TOffset of key-of<TArray>`, and one whose offsetSet()
value type is conditional on the offset. getOffsetValueType(),
hasOffsetValueType() and setOffsetValueType(), appends included, are
observed on them with known, unknown and union keys, alongside a generic
ArrayObject for the path without anything to resolve. Against a build of
the native ObjectType without the variant selection, 96 of these
observations differ from the PHP twin.

Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
The private ObjectType::selectArrayAccessVariant() added for the
ArrayAccess fast path is part of the declarations that
turbo-ext/bin/generate-declarations.php derives from the PHP class, so
turbo-ext/src/generated/ObjectType.h was stale and
turbo-ext/bin/side-by-side.php reported it.

Co-Authored-By: Claude Opus 5.5 (high effort) <noreply@anthropic.com>
Claude-Code-Session-Id: 781e7324-e13c-4956-a86a-9047e742d5b9
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.

Support for array shape for ArrayAccess classes not working.

1 participant