Skip to content

Add readonlySuperglobals and superglobalTypes parameters - #6866

Open
westonruter wants to merge 7 commits into
phpstan:2.3.xfrom
westonruter:superglobal-types
Open

westonruter wants to merge 7 commits into
phpstan:2.3.xfrom
westonruter:superglobal-types

Conversation

@westonruter

@westonruter westonruter commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Refs phpstan/phpstan#8037

This adds two opt-in parameters for projects that treat the superglobals as a contract instead of as arbitrary mutable state:

parameters:
	# Assume the shapes PHP populates the superglobals with.
	readonlySuperglobals: true

	# Or declare (or override) the type of any superglobal.
	superglobalTypes:
		_GET: 'array<string|array<string|array<mixed>>>'
		_SESSION: 'array<string, mixed>'

With neither set, which is the default, nothing changes: every superglobal stays array<mixed>.

Motivation: WordPress core

The motivating codebase is WordPress core, which runs PHPStan with its own configuration and extensions. CI runs at level 5 with per-identifier baselines, and work is under way to fix the errors reported at higher levels.

WordPress reads request input straight from the superglobals throughout. In core (excluding the bundled third-party libraries), 181 files read $_GET, $_POST, $_REQUEST, or $_COOKIE directly, about 2,670 reads in total. Every one of those reads is mixed. For example, in WP_REST_Server::serve_request():

if ( isset( $_GET['_envelope'] ) ) {
	$embed  = isset( $_GET['_embed'] ) ? rest_parse_embed_param( $_GET['_embed'] ) : false;

rest_parse_embed_param() takes string|array, which is exactly what $_GET['_embed'] can be. Today this is reported as expects array|string, mixed given. The available fixes are an is_string() || is_array() guard that can never fail on real request input, or an inline @var. Adding @global types to the function's docblock does not help either, since a superglobal is never imported with global.

At the same time, WordPress cannot simply assume the request shape, because core itself writes into these arrays, and not always strings. edit-form-advanced.php does $_GET['message'] = absint( $_GET['message'] );. So the type has to come with a check on what is written.

What it does

  • readonlySuperglobals (default false) assumes the shapes PHP gives the superglobals it populates from the request and the environment:
    • $_GET, $_POST, $_COOKIE, $_REQUEST: array<string|array<string|array<mixed>>>. Values are strings, nested into arrays by brackets in a name (?a[]=1&a[b][]=2). The nesting is unbounded, so the type stops at two levels.
    • $_FILES: the upload entry shape, for both single and multiple uploads.
    • $_SERVER: array{PHP_SELF: string, REQUEST_TIME: int, REQUEST_TIME_FLOAT: float, argc?: int<0, max>, argv?: list<string>, ...<string>}, an unsealed shape. Everything in it is a string (the request variables, the HTTP_* headers, and in the CLI the environment) except the entries PHP adds itself.
      • Three keys are required. PHP sets REQUEST_TIME and REQUEST_TIME_FLOAT for every request, and every bundled SAPI registers PHP_SELF: CGI and FPM fall back to an empty string, and Apache, the CLI, the built-in server, LiteSpeed, phpdbg, and embed set it unconditionally. The exception is a variables_order without S, which leaves $_SERVER empty; neither php.ini that PHP ships does that.
      • Every other key is optional. Headers are only there when the client sends them. SCRIPT_NAME and SCRIPT_FILENAME come from the web server's parameters under CGI and FPM, are not set by the embed SAPI, and CGI unsets SCRIPT_FILENAME when it cannot find the script.
      • argc and argv are not the non-empty CLI types used for $argc and $argv: on the web with register_argc_argv on and no query string, argc is 0 and argv is empty.
    • $_ENV: array<string>.
    • The keys are not narrowed to string anywhere: like any array key, a numeric name (?1=a, an environment variable named 123) becomes an int.
    • $_SESSION and $GLOBALS are left as array<mixed>, since what they hold is up to the application.
  • superglobalTypes (default []) declares the type of any superglobal, and takes precedence over the assumed one. A key that is not a superglobal name, like a typo, $_GET written with its $, or a list entry, fails the configuration instead of being silently ignored.
  • The type is only the starting point. Assignments, narrowing (isset(), is_string(), …), and the invalidation of tracked superglobals after impure calls work as before. After invalidation, reads fall back to the declared type instead of array<mixed>.
  • The type is a PHPDoc type. PHP does not enforce it, so the native type stays array<mixed>. With treatPhpDocTypesAsCertain: false, a defensive is_string($_GET['id']) is therefore not reported as always true.
  • Writes that break the type are reported. The new level 3 rule assign.superglobalType checks writes against the superglobal's type, like assign.propertyType does for properties. This covers assignments, offset writes, compound assignments, unset(), foreach targets, and by-reference arguments. The type stays sound only while what is written fits it, because reads fall back to it once the written value is forgotten. Writes that keep the contract, like $_GET['order'] = 'DESC'; or unset($_GET['posted']);, pass. A write is checked against the type unioned with what the superglobal held before it, so a violation is reported once, where it is introduced, rather than again at every later write in the function.

This is meant to answer the objection in phpstan/phpstan#12252, that superglobals "can be reassigned to anything by the user script". With these parameters a project opts in to the contract, and PHPStan reports where the project's own code breaks it.

Results on WordPress core

WordPress trunk at WordPress/wordpress-develop@8c780cd (7.2 Beta 2), analyzed at level 10 with core's own configuration, with readonlySuperglobals: false and then true. Errors are matched by file, line, and identifier:

Errors
Total, readonlySuperglobals: false 27,336
Total, readonlySuperglobals: true 27,099
No longer reported 547
Still reported, with a more precise message 681
Newly reported 310
  • No longer reported: mostly argument.type (197), cast.int (182), offsetAccess.nonOffsetAccessible (104), and binaryOp.invalid (46) on mixed reads. Many of the argument.type and binaryOp.invalid ones are $_SERVER reads that are now string.
  • More precise: almost all argument.type, changing from mixed given to array<array<mixed>|string>|string given. These now point at the real problem: the value can be an array.
  • Newly reported:
    • 31 assign.superglobalType errors. Each is a write that breaks the contract: ints from absint(), (int), or get_current_user_id(); mixed from get_option() or array_replace_recursive(); $_FILES entries passed by reference to wp_handle_upload(), which can write back any array; preg_replace()'s possible null written into $_SERVER['PHP_SELF']; and $_SERVER = add_magic_quotes( $_SERVER );, where the function's general array return type loses the shape.
    • 177 offsetAccess.notFound errors for $_SERVER keys that might not exist, which the shape reports while the general arrays do not. Some are real: HTTP_USER_AGENT (25), HTTP_HOST (20), and REMOTE_ADDR (8) are headers or values a request can lack, and reading them unguarded raises an "Undefined array key" warning. Others, like REQUEST_URI (84) and REQUEST_METHOD (13), are always set on the web but not in the CLI, which WordPress also runs under through WP-CLI.
    • 102 errors that come from knowing a value can be an array. For example, check_admin_referer( 'edit-dashboard-widget_' . $_POST['widget_id'], … ) concatenates what may be an array, which ?widget_id[]=x would turn into "Array to string conversion".

superglobalTypes also lets a project declare guarantees of its own. Early in its bootstrap, WordPress's wp_fix_server_vars() merges in empty-string defaults for REQUEST_URI and SERVER_SOFTWARE. Declaring those two as required on top of the shape above brings the total to 27,006 and leaves 85 missing-key reports, mostly headers.

Why not an extension

The typing cannot be done from an extension. Two attempts with an ExpressionTypeResolverExtension on PHPStan 2.3.2 both failed:

  • Answering for $_GET itself narrows the variable, but $_GET['_embed'] stays mixed. Offset reads take the container's type from MutatingScope::getVariableType(), which does not consult the extensions.
  • Answering for offset reads rooted in $_GET as well only works when PHPStan asks about that exact expression directly. $_GET['a'] alone comes out narrowed, but $_GET['a'] ?? null, isset($_GET['_embed']) followed by passing it to a function, $_GET['a']['b'] ?? null, and f($_GET['q'] ?? '') all stay mixed. Those handlers build their types from their sub-expressions' internal results, which do not go through the extension. After $_GET['n'] = 1;, reading another offset even came out as 1|array|string|null.

A parser visitor that seeds an inline @var for $_GET at the top of each function would not work either: invalidateVolatileExpressions() forgets tracked superglobal types after any impure call, so reads fall back to array<mixed>.

Every one of those paths starts from getVariableType(), which is why the type is resolved there. The write rule on its own could be an extension, but it is only useful together with the types.

Implementation notes

  • SuperglobalTypeResolver resolves the configured types once, lazily through TypeStringResolver. It is held in ScopeServices and called from MutatingScope::getVariableType() where the hard-coded array<mixed> was.
  • turbo-ext/src/MutatingScope.cpp makes the same call in place of building the type natively. The resolver itself is not ported: it only runs when an untracked superglobal is read, so it would not absorb enough call frames to pay off.
  • foreach ($array as $_POST['x']), and likewise foreach ($ints as $this->prop) under assign.propertyType, is reported once per loop pass, because enterForeach() forwards the real node callback on every pass. That is unrelated to this change, and the tests avoid it.

Open questions

  • Naming. readonlySuperglobals comes from more precise Superglobals / readonlySuperglobals phpstan#8037. With writes now type-checked instead of banned, a name like requestSuperglobalTypes may describe it better.
  • $_SERVER as a shape. Unlike the general arrays, the shape reports reads of keys that might not exist. That catches real bugs with headers, but also reports keys like REQUEST_URI that only a CLI script lacks. A general array type would avoid those reports, at the cost of typing every read as a union of all the value types.

Possible follow-up: merging offsets into the assumed types

To declare the two wp_fix_server_vars() keys above, WordPress has to copy the whole $_SERVER shape into superglobalTypes and add them to it. That copy then drifts from PHPStan's: if the built-in shape is later corrected or extended, the project silently keeps the old one.

A later change could let a superglobal's entry in superglobalTypes be a map of offsets that is merged into the assumed type, while a string still replaces the type outright:

parameters:
	readonlySuperglobals: true
	superglobalTypes:
		_SERVER:
			REQUEST_URI: string
			SERVER_SOFTWARE: string
		_SESSION: 'array<string, mixed>'

The merge would go through Type::setOffsetValueType() rather than through the type string, so it works on any base type. On the $_SERVER shape it adds required keys. On a general array like $_GET it gives non-empty-array<…>&hasOffsetValue('page', string), the type isset($_GET['page']) already narrows to. That way $_GET does not become a shape, which would make every unguarded read of another key report "might not exist". dynamicConstantNames already accepts both plain entries and NAME: 'type' pairs, so a mixed form has precedent. This is left out to keep the change focused.

Testing

  • SuperglobalTypesTest and ReadonlySuperglobalsTypesTest cover configured, assumed, unconfigured, and native types, offsets, narrowing, writes, and invalidation. With the parameters removed, their assertions fail.
  • SuperglobalAssignTypeRuleTest and SuperglobalAssignTypeRuleWithoutTypesTest cover the rule with and without types.
  • InvalidSuperglobalTypesTest covers the configuration error for a key that is not a superglobal name.
  • The NodeScopeResolverTest, Rules/Variables, Rules/Properties, and Process suites pass both with and without the turbo extension. The extension's smoke.php, scope-family.php, signature-parity.php, and side-by-side.php checks pass too.

🤖 Generated with Claude Code

westonruter and others added 7 commits October 27, 2026 10:59
Superglobals not tracked by the scope are typed `array<mixed>`, so every
read of `$_GET['foo']` is `mixed`. A project can now declare the shape it
treats as a contract:

    parameters:
        superglobalTypes:
            _GET: 'array<string|array<string|array<mixed>>>'

The configured type is the starting point only. Assignments, narrowing,
and invalidation after impure calls behave as before, falling back to the
configured type rather than `array<mixed>`.

It is a PHPDoc type: the native type stays `array<mixed>`, so with
`treatPhpDocTypesAsCertain: false` a defensive `is_string($_GET['id'])`
is not reported as always true.

The type is resolved by the new SuperglobalTypeResolver service, held in
ScopeServices and called from MutatingScope::getVariableType() and its
native mirror in turbo-ext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When enabled, the superglobals PHP populates from the request and the
environment get the shapes PHP gives them, without each project having
to declare them in superglobalTypes:

* `$_GET`, `$_POST`, `$_COOKIE`, and `$_REQUEST`: strings, nested into
  arrays by brackets in a name, tracked two levels deep.
* `$_FILES`: the upload entry shape, for both single and multiple files.
* `$_ENV`: `array<string>`.

`$_SERVER`, `$_SESSION`, and `$GLOBALS` are left as `array<mixed>`, since
their contents depend on the SAPI, the host, and the application. A type
in superglobalTypes takes precedence over the assumed one, so any of
them can still be declared.

Assuming these types is only sound while nothing writes to the
superglobals, so the flag also enables ReadonlySuperglobalRule, which
reports assignments, offset writes, compound assignments, increments,
`unset()`, and by-reference arguments that target a superglobal.

The flag is off by default, leaving every superglobal `array<mixed>`
unless superglobalTypes says otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A declared or assumed superglobal type stays sound as long as what is
written into the superglobal fits it: once the scope forgets the written
value, reads fall back to that type. Banning writes outright went further
than that and reported writes that keep the contract, like
`$_GET['order'] = 'DESC';` or `unset($_GET['posted']);`, as well as
writes to `$GLOBALS` and `$_SERVER`, which the parameters do not type.

SuperglobalAssignTypeRule replaces ReadonlySuperglobalRule. Like the
property type check, it reports a write whose value the superglobal's
type does not accept, such as an int written into `$_GET`, and it applies
to any superglobal with a type, from readonlySuperglobals or
superglobalTypes. A write is checked against that type unioned with what
the superglobal held before it, so a violation is reported where it is
introduced rather than again at every later write in the function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under every standard SAPI, `$_SERVER` holds strings: the request
variables, the headers, and in the CLI the environment. The exceptions
are the few entries PHP adds itself, so readonlySuperglobals now types
it as an unsealed shape:

    array{argc?: int<0, max>, argv?: list<string>, REQUEST_TIME?: int, REQUEST_TIME_FLOAT?: float, ...<string>}

`argc` and `argv` are not the non-empty CLI types used for `$argc` and
`$argv`, since on the web with `register_argc_argv` on and no query
string, `argc` is 0 and `argv` is empty.

As a shape, `$_SERVER` reports reads of keys that might not exist, such
as `HTTP_USER_AGENT`, which a client can omit.

The keys of `$_SERVER` and `$_ENV` stay `int|string`: an environment
variable named `123` becomes the int key 123, like any numeric array key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP sets both for every request under every SAPI, in the CLI as well as
on the web, so reading them needs no isset(). They are absent only when
variables_order leaves out `S`, which leaves `$_SERVER` empty; neither
php.ini that PHP ships does that.

Every other key stays optional. `PHP_SELF`, `SCRIPT_NAME`, and
`SCRIPT_FILENAME` are present in the CLI and the built-in server, but on
a real web server they come from its configuration, and a header like
`HTTP_HOST` is only there when the client sends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every bundled SAPI registers `PHP_SELF`: CGI and FPM fall back to an
empty string when there is no request URI, Apache uses the request URI,
and the CLI, the built-in server, LiteSpeed, phpdbg, and embed set it
unconditionally. It can be empty, but it is always there.

`SCRIPT_NAME` and `SCRIPT_FILENAME` stay optional: the embed SAPI does not
set them, under CGI and FPM they come from the web server's parameters,
and CGI unsets `SCRIPT_FILENAME` when it cannot find the script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key that names no superglobal was silently ignored: a typo like
`_GTE`, the variable written with its sigil as `$_GET`, or a list entry,
whose key is an int. The project would believe it had declared a type
while the superglobal kept its default. The schema now limits the keys
to the names in Scope::SUPERGLOBAL_VARIABLES, so such a key fails the
configuration with a message listing the valid names.

The test expects the container to fail to build, so ContainerInitializer
skips it like InvalidIgnoredErrorExceptionTest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@westonruter
westonruter marked this pull request as ready for review October 27, 2026 22:44
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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.

2 participants