Repository navigation
Add readonlySuperglobals and superglobalTypes parameters - #6866
Open
westonruter wants to merge 7 commits into
Open
westonruter wants to merge 7 commits into
westonruter wants to merge 7 commits into
Conversation
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
marked this pull request as ready for review
October 27, 2026 22:44
Collaborator
|
This pull request has been marked as ready for review. |
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.
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:
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$_COOKIEdirectly, about 2,670 reads in total. Every one of those reads ismixed. For example, inWP_REST_Server::serve_request():rest_parse_embed_param()takesstring|array, which is exactly what$_GET['_embed']can be. Today this is reported asexpects array|string, mixed given. The available fixes are anis_string() || is_array()guard that can never fail on real request input, or an inline@var. Adding@globaltypes to the function's docblock does not help either, since a superglobal is never imported withglobal.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.phpdoes$_GET['message'] = absint( $_GET['message'] );. So the type has to come with a check on what is written.What it does
readonlySuperglobals(defaultfalse) 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, theHTTP_*headers, and in the CLI the environment) except the entries PHP adds itself.REQUEST_TIMEandREQUEST_TIME_FLOATfor every request, and every bundled SAPI registersPHP_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 avariables_orderwithoutS, which leaves$_SERVERempty; neitherphp.inithat PHP ships does that.SCRIPT_NAMEandSCRIPT_FILENAMEcome from the web server's parameters under CGI and FPM, are not set by the embed SAPI, and CGI unsetsSCRIPT_FILENAMEwhen it cannot find the script.argcandargvare not the non-empty CLI types used for$argcand$argv: on the web withregister_argc_argvon and no query string,argcis0andargvis empty.$_ENV:array<string>.stringanywhere: like any array key, a numeric name (?1=a, an environment variable named123) becomes anint.$_SESSIONand$GLOBALSare left asarray<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,$_GETwritten with its$, or a list entry, fails the configuration instead of being silently ignored.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 ofarray<mixed>.array<mixed>. WithtreatPhpDocTypesAsCertain: false, a defensiveis_string($_GET['id'])is therefore not reported as always true.assign.superglobalTypechecks writes against the superglobal's type, likeassign.propertyTypedoes for properties. This covers assignments, offset writes, compound assignments,unset(),foreachtargets, 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';orunset($_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
trunkat WordPress/wordpress-develop@8c780cd (7.2 Beta 2), analyzed at level 10 with core's own configuration, withreadonlySuperglobals: falseand thentrue. Errors are matched by file, line, and identifier:readonlySuperglobals: falsereadonlySuperglobals: trueargument.type(197),cast.int(182),offsetAccess.nonOffsetAccessible(104), andbinaryOp.invalid(46) onmixedreads. Many of theargument.typeandbinaryOp.invalidones are$_SERVERreads that are nowstring.argument.type, changing frommixed giventoarray<array<mixed>|string>|string given. These now point at the real problem: the value can be an array.assign.superglobalTypeerrors. Each is a write that breaks the contract: ints fromabsint(),(int), orget_current_user_id();mixedfromget_option()orarray_replace_recursive();$_FILESentries passed by reference towp_handle_upload(), which can write back anyarray;preg_replace()'s possiblenullwritten into$_SERVER['PHP_SELF']; and$_SERVER = add_magic_quotes( $_SERVER );, where the function's general array return type loses the shape.offsetAccess.notFounderrors for$_SERVERkeys that might not exist, which the shape reports while the general arrays do not. Some are real:HTTP_USER_AGENT(25),HTTP_HOST(20), andREMOTE_ADDR(8) are headers or values a request can lack, and reading them unguarded raises an "Undefined array key" warning. Others, likeREQUEST_URI(84) andREQUEST_METHOD(13), are always set on the web but not in the CLI, which WordPress also runs under through WP-CLI.check_admin_referer( 'edit-dashboard-widget_' . $_POST['widget_id'], … )concatenates what may be an array, which?widget_id[]=xwould turn into "Array to string conversion".superglobalTypesalso lets a project declare guarantees of its own. Early in its bootstrap, WordPress'swp_fix_server_vars()merges in empty-string defaults forREQUEST_URIandSERVER_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
ExpressionTypeResolverExtensionon PHPStan 2.3.2 both failed:$_GETitself narrows the variable, but$_GET['_embed']staysmixed. Offset reads take the container's type fromMutatingScope::getVariableType(), which does not consult the extensions.$_GETas 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, andf($_GET['q'] ?? '')all staymixed. 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 as1|array|string|null.A parser visitor that seeds an inline
@varfor$_GETat the top of each function would not work either:invalidateVolatileExpressions()forgets tracked superglobal types after any impure call, so reads fall back toarray<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
SuperglobalTypeResolverresolves the configured types once, lazily throughTypeStringResolver. It is held inScopeServicesand called fromMutatingScope::getVariableType()where the hard-codedarray<mixed>was.turbo-ext/src/MutatingScope.cppmakes 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 likewiseforeach ($ints as $this->prop)underassign.propertyType, is reported once per loop pass, becauseenterForeach()forwards the real node callback on every pass. That is unrelated to this change, and the tests avoid it.Open questions
readonlySuperglobalscomes from more precise Superglobals / readonlySuperglobals phpstan#8037. With writes now type-checked instead of banned, a name likerequestSuperglobalTypesmay describe it better.$_SERVERas 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 likeREQUEST_URIthat 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$_SERVERshape intosuperglobalTypesand 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
superglobalTypesbe a map of offsets that is merged into the assumed type, while a string still replaces the type outright:The merge would go through
Type::setOffsetValueType()rather than through the type string, so it works on any base type. On the$_SERVERshape it adds required keys. On a general array like$_GETit givesnon-empty-array<…>&hasOffsetValue('page', string), the typeisset($_GET['page'])already narrows to. That way$_GETdoes not become a shape, which would make every unguarded read of another key report "might not exist".dynamicConstantNamesalready accepts both plain entries andNAME: 'type'pairs, so a mixed form has precedent. This is left out to keep the change focused.Testing
SuperglobalTypesTestandReadonlySuperglobalsTypesTestcover configured, assumed, unconfigured, and native types, offsets, narrowing, writes, and invalidation. With the parameters removed, their assertions fail.SuperglobalAssignTypeRuleTestandSuperglobalAssignTypeRuleWithoutTypesTestcover the rule with and without types.InvalidSuperglobalTypesTestcovers the configuration error for a key that is not a superglobal name.NodeScopeResolverTest,Rules/Variables,Rules/Properties, andProcesssuites pass both with and without the turbo extension. The extension'ssmoke.php,scope-family.php,signature-parity.php, andside-by-side.phpchecks pass too.🤖 Generated with Claude Code