Skip to content

Posts, Post Types: Add conditional return types to post retrieval, sanitization, and insertion functions#12426

Closed
westonruter wants to merge 11 commits into
WordPress:trunkfrom
westonruter:fix/phpstan-post-types
Closed

Posts, Post Types: Add conditional return types to post retrieval, sanitization, and insertion functions#12426
westonruter wants to merge 11 commits into
WordPress:trunkfrom
westonruter:fix/phpstan-post-types

Conversation

@westonruter

@westonruter westonruter commented Jul 6, 2026

Copy link
Copy Markdown
Member

Adds precise, conditional PHPStan return types to the core post retrieval, sanitization, and insertion APIs, along with a small amount of behavior-preserving runtime hardening (and one genuine latent-bug fix) that these types surfaced.

Conditional return types

Functions whose return type depends on an argument now carry a @phpstan-return keyed on that argument, so static analysis can narrow the result at each call site:

  • get_post(), get_page(), get_page_by_path() — keyed on $output: array<string, mixed>|null for ARRAY_A, array<int, mixed>|null for ARRAY_N, WP_Post|null otherwise.
  • get_children() — keyed first on $args['fields'] ('ids'int[], mirroring get_posts()) and then on $output.
  • get_posts() and WP_Query::query()$args with fields => 'ids'int[], otherwise WP_Post[].
  • wp_get_recent_posts()ARRAY_Aarray<int, array<string, mixed>>, otherwise WP_Post[]|false.
  • get_post_field() / sanitize_post_field() — keyed on $field: int for ID/post_parent/menu_order, non-negative-int[] for ancestors, string otherwise (get_post_field() additionally includes the '' failure return).
  • sanitize_post() — same type in as out (WP_Post, stdClass, or array).
  • get_post_types()'names'string[], otherwise WP_Post_Type[].
  • wp_insert_post(), wp_update_post(), wp_insert_attachment()$wp_error === falseint, otherwise int|WP_Error.

Matching @phpstan-param tags were added where the analyzed return depends on a narrowed input (e.g. $output, $context, $filter).

Runtime changes

Most of the diff is documentation-only, but a few small code changes were needed for the types to hold:

  • get_post() now null-guards the WP_Post::filter( 'raw' ) result. filter( 'raw' ) calls WP_Post::get_instance(), which can return false for a since-deleted post; the previous code would then call ->to_array() on false. This is a real latent fix, not just a typing change. WP_Post::filter() is retyped WP_Post|false accordingly and its missing summary docblock is filled in.
  • WP_Post::get_instance() cache-hit guard uses instanceof stdClass/instanceof WP_Post (behavior-preserving vs. the old truthiness check) and casts $_post->ID to int before caching.
  • get_post() routes non-numeric scalar $post values straight to null instead of issuing a pointless WHERE ID = 0 query.
  • sanitize_post_field() casts $value to array before array_map( 'absint', ... ) for the ancestors field.
  • WP_Post::get_category() / get_tags() guard against a WP_Error from get_the_terms().
  • Assorted (int) casts on post IDs passed to get_post()/get_instance().

Verified with phpstan-diff against trunk: no new errors on changed lines.

Trac ticket: https://core.trac.wordpress.org/ticket/64898

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Drafting the conditional return type annotations and the accompanying runtime hardening under my direction. I reviewed, tested, and take responsibility for all of the changes.


✅ Committed in r62648 (578d09b)

westonruter and others added 11 commits July 6, 2026 11:52
… sanitization.

Annotate `get_post()`, `get_post_field()`, `get_post_types()`, `sanitize_post()`, `sanitize_post_field()`, `WP_Post::filter()`, and `WP_Post::get_instance()` with `@phpstan-param`/`@phpstan-return` tags so the analyzed return type reflects the value of the `$output`, `$field`, `$context`, and `$filter` arguments. `WP_Post::$filter` and `WP_Post::to_array()` gain matching annotations.

While tightening the types, a few genuinely loose spots surfaced by the stricter analysis are addressed:

* `WP_Post::get_instance()` treats a cached value as a hit only when it is a `stdClass` or `WP_Post` instance rather than any truthy value, and casts the ID to `int` before caching.
* `WP_Post::get_category()`/`get_tags()` bail when `get_the_terms()` returns a `WP_Error`, not just on an empty result.
* `WP_Post::filter( 'raw' )` is documented and typed to return `false` when the underlying post has been deleted, and `get_post()` returns `null` in that case.
* `get_post()` resolves numeric input through `WP_Post::get_instance()` and returns `null` for any other unrecognized input.
* `sanitize_post()`/`sanitize_post_field()` cast to the documented field types ( `(int)` ID, `(array)` ancestors ) before use.

The `sanitize_post()` object branch is typed as `stdClass|WP_Post` — the only object shapes the function is handed, and the only ones on which its dynamic property writes type-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t_post_field()` type.

`get_post_field()` returns `''` when the post cannot be resolved or the requested field is not set, before it ever reaches `sanitize_post_field()`. The `@phpstan-return` conditional therefore has to widen the integer branches to `int|''` and `non-negative-int[]|''`; otherwise a caller of `get_post_field( 'ID', ... )` would be typed as receiving an `int` yet could get `''` at runtime — masking, for example, a `TypeError` on `get_post_field( 'ID', $x ) + 1` when the post is missing.

`sanitize_post_field()` itself keeps the tighter `int`/`non-negative-int[]` branches, since it has no such early return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…()`.

`get_page_by_path()` returns whatever `get_post( $id, $output )` returns, or `null`, so its analyzed return type can be keyed on `$output` exactly as `get_post()` already is: an associative array for `ARRAY_A`, a numeric array for `ARRAY_N`, and a `WP_Post` otherwise, each nullable. `$output` and `$post_type` gain matching `@phpstan-param` tags.

Since `get_post()`'s conditional branches already include `null`, the function's own `return null` paths are covered without widening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sts()`.

Key the analyzed return type on `$output`: for `ARRAY_A` each post is converted with `get_object_vars()`, so the result is `array<int, array<string, mixed>>` (an empty array, never `false`, on failure); otherwise the underlying `get_posts()` result is passed through as `WP_Post[]|false`. `$output` gains a matching `@phpstan-param` of `'OBJECT'|'ARRAY_A'`.

Because `get_posts()` is already typed to return `WP_Post[]` unless `fields` is `'ids'` — which the parsed arguments here never statically are — the object branch resolves to `WP_Post` elements without further narrowing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`get_page()` is a deprecated alias that forwards `$page`, `$output`, and `$filter` to `get_post()` unchanged, so it carries the same `@phpstan-param`/`@phpstan-return` tags: the return type is keyed on `$output` (associative array for `ARRAY_A`, numeric array for `ARRAY_N`, `WP_Post` otherwise, each nullable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Key the analyzed return type on the request shape. When `$args` selects `fields => 'ids'` the function short-circuits and hands back the raw `get_posts()` result, so that case mirrors `get_posts()`'s own `int[]` branch. Otherwise the results are re-keyed by post ID and shaped by `$output`: `array<int, array<string, mixed>>` for `ARRAY_A`, `array<int, array<int, mixed>>` for `ARRAY_N`, and `WP_Post[]` otherwise. `$output` gains a matching `@phpstan-param`.

The empty-array early returns (no ambient post, or no children found) are subtypes of every branch, and full analysis confirms none of the six return statements conflict with the declared type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@westonruter

Copy link
Copy Markdown
Member Author

PHPStan impact: trunk → this branch

Ran two full PHPStan passes (local phpstan.neon, level 10) on trunk and on this branch to quantify the effect.

Metric Value
Errors in trunk 34,431
Errors in this branch 34,149
Net change −282
Fixed 310
Introduced 28
% of baseline fixed 0.90%
Net reduction 0.82%

Reconciliation checks out: 310 − 28 = 282. A 0.82% net reduction from a mostly-annotation diff — the precise return types let PHPStan resolve mixed/object returns at hundreds of downstream call sites. Top beneficiaries: post.php (−64), class-wp-xmlrpc-server.php (−52), media.php (−15), ajax-actions.php/admin/post.php/class-wp-customize-nav-menus.php (−12 each).

Errors fixed by identifier (310)

Count Identifier
110 property.nonObject
77 argument.type
75 offsetAccess.nonOffsetAccessible
13 encapsedStringPart.nonString
9 missingType.iterableValue
8 return.type
7 assign.propertyType
4 offsetAccess.invalidOffset
3 property.notFound
3 method.nonObject
1 binaryOp.invalid

Triage of the 28 "introduced"

None are regressions in the changed code, with one known-trade-off exception. They fall into four buckets:

1. Latent call-site bugs exposed by sharper types (~15). Since get_post() / get_page_by_path() now return array|WP_Post|null precisely (instead of mixed/object), PHPStan can finally see call sites that assume a bare WP_Post:

  • media.php (5) and class-wp-customize-nav-menus.php (7): Cannot access property $ID/$post_title on array|WP_Post|null, expects WP_Post, array|WP_Post|null given.
  • class-wp-query.php (1): passes mixed to get_page_by_path()'s now-typed $post_type.

These are pre-existing fragilities (unchecked null, ignored ARRAY_A case) surfaced by the new types — good candidates for follow-up under this same ticket rather than expanding this PR.

2. Dead-check / unused-type warnings that are artifacts of the narrowed types (3):

  • WP_Post::get_instance()empty( $_post->filter ) flagged because $filter is now the 'raw'|'edit'|… enum (never falsy); it's a defensive check.
  • nav-menu.phpisset() on the non-nullable WP_Post::$ID.
  • _fix_attachment_links()return.unusedType, because precise types made a WP_Error branch provably unreachable.

3. Genuine imprecision in this branch's annotation (2):

post.php — sanitize_post_field() should return array<int<0, max>>|int|string but returns mixed  (×2)

The 'raw' context returns $value unchanged (mixed), but the conditional return declares string for that field class. In practice raw DB values are strings, but PHPStan can't prove it. Leaving this as a deliberate precision-vs-soundness trade-off; the net result is still −282.

4. Nondeterministic churn unrelated to the diff (~7). The WP_Taxonomy (upgrade.php, block-template-utils.php), wp-mail.php, and Twenty Fourteen entries — verified the totals are identical before/after (e.g. WP_Taxonomy-related errors are 260 in both runs), just redistributed between passes. None of that code is touched here.


Analysis performed by Claude (Opus 4.8) via Claude Code, at the request of and reviewed by @westonruter.

pento pushed a commit that referenced this pull request Jul 6, 2026
Annotate the core post retrieval, sanitization, and insertion APIs with PHPStan conditional return types keyed on their mode arguments, so static analysis can resolve the concrete result at each call site:

* `get_post()`, `get_page()`, `get_page_by_path()`, `get_children()`, and `get_post_types()` narrow on `$output`.
* `get_posts()` and `WP_Query::query()` narrow on the `fields` argument.
* `get_post_field()` and `sanitize_post_field()` narrow on `$field`.
* `wp_insert_post()`, `wp_update_post()`, and `wp_insert_attachment()` narrow on `$wp_error`.

Matching `@phpstan-param` tags are added where the analyzed return depends on a narrowed input.

Separately, several previously generic or underspecified types are filled in where the precise type is known:

* `WP_Post::to_array()` is typed `array<string, mixed>` rather than a bare `array`.
* `WP_Post::$filter` is annotated with its recognized context values rather than a bare `string`.
* `WP_Post::filter()` is corrected to return `WP_Post|false` (previously `WP_Post`), and its missing summary is documented.
* `sanitize_post()`'s `$post` parameter is narrowed from `object` to `stdClass|WP_Post`.

Because these functions were previously typed as returning `mixed` or a bare `object`, the sharper types let analysis see through to hundreds of downstream call sites, for a net reduction of roughly 280 PHPStan errors across the tree.

A few supporting runtime changes make the types hold:

* `get_post()` now guards against the `false` that `WP_Post::filter( 'raw' )` can return for a since-deleted post.
* `WP_Post::get_instance()` tightens its cache-hit check.
* post IDs are cast to `int` where passed on.

Developed in #12426.
Follow-up to r61789.

See #64898, #64896.


git-svn-id: https://develop.svn.wordpress.org/trunk@62648 602fd350-edb4-49c9-b593-d223f7449a82
@westonruter westonruter closed this Jul 6, 2026
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Jul 6, 2026
Annotate the core post retrieval, sanitization, and insertion APIs with PHPStan conditional return types keyed on their mode arguments, so static analysis can resolve the concrete result at each call site:

* `get_post()`, `get_page()`, `get_page_by_path()`, `get_children()`, and `get_post_types()` narrow on `$output`.
* `get_posts()` and `WP_Query::query()` narrow on the `fields` argument.
* `get_post_field()` and `sanitize_post_field()` narrow on `$field`.
* `wp_insert_post()`, `wp_update_post()`, and `wp_insert_attachment()` narrow on `$wp_error`.

Matching `@phpstan-param` tags are added where the analyzed return depends on a narrowed input.

Separately, several previously generic or underspecified types are filled in where the precise type is known:

* `WP_Post::to_array()` is typed `array<string, mixed>` rather than a bare `array`.
* `WP_Post::$filter` is annotated with its recognized context values rather than a bare `string`.
* `WP_Post::filter()` is corrected to return `WP_Post|false` (previously `WP_Post`), and its missing summary is documented.
* `sanitize_post()`'s `$post` parameter is narrowed from `object` to `stdClass|WP_Post`.

Because these functions were previously typed as returning `mixed` or a bare `object`, the sharper types let analysis see through to hundreds of downstream call sites, for a net reduction of roughly 280 PHPStan errors across the tree.

A few supporting runtime changes make the types hold:

* `get_post()` now guards against the `false` that `WP_Post::filter( 'raw' )` can return for a since-deleted post.
* `WP_Post::get_instance()` tightens its cache-hit check.
* post IDs are cast to `int` where passed on.

Developed in WordPress/wordpress-develop#12426.
Follow-up to r61789.

See #64898, #64896.

Built from https://develop.svn.wordpress.org/trunk@62648


git-svn-id: http://core.svn.wordpress.org/trunk@61933 1a063a9b-81f0-0310-95a4-ce76da25c4cd
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