Skip to content

Code Quality: Improve PHPStan type coverage for WP_Post#12491

Closed
westonruter wants to merge 12 commits into
WordPress:trunkfrom
westonruter:add/post-properties
Closed

Code Quality: Improve PHPStan type coverage for WP_Post#12491
westonruter wants to merge 12 commits into
WordPress:trunkfrom
westonruter:add/post-properties

Conversation

@westonruter

@westonruter westonruter commented Jul 13, 2026

Copy link
Copy Markdown
Member

✅ Committed in r62717 (068b578)


Improves PHPStan static-analysis coverage for WP_Post, tightening the types of its array form and properties to what the code actually guarantees.

Changes

  • WP_Post::to_array() — adds a Data_Array @phpstan-type shape describing every key it returns, used as the method's @phpstan-return. The shape is open (...) because WP_Post is #[AllowDynamicProperties] and instances routinely carry extra columns, so sealing it would produce false "offset does not exist" errors. This doesn't have a lot of value at the moment other than to calls to WP_Post::to_array(), but once Allow top-level @phpdoc-type aliases not bound to classes for use in functions phpstan/phpstan#9164 lands then this will become much more useful as get_post() will be able to import the type and return it for calls to get_post( ..., ARRAY_A ).
  • Refined value types, only where the value set is genuinely constrained:
    • ID, post_parentnon-negative-int (never negative; 0 is valid for unsaved objects, so deliberately not positive-int).
    • comment_countnumeric-string.
    • post_authornumeric-string|'' (a user ID, or an empty string for a default post that has not yet been assigned an author, e.g. from get_default_post_to_edit()). The '' is kept in the type rather than narrowed away so (int) casts stay meaningful without excluding that real state.
    • post_status, comment_status, ping_status, post_typenon-empty-string. Each is a NOT NULL column that always holds a non-empty slug, but the type is left open (not an enum) so custom statuses/types registered via register_post_status() / register_post_type() remain valid.
    • The always-present magic keys become precise lists: ancestors and post_category are list<non-negative-int> (from get_post_ancestors() and wp_list_pluck( …, 'term_id' )), and tags_input is list<non-empty-string> (wp_list_pluck( …, 'name' )). WP_Post::__isset() returns true for all four magic keys, so to_array() always appends them.
    • Fields that can legitimately be empty stay string: post_content, post_title, post_excerpt, post_name, post_password, guid, to_ping, pinged, post_content_filtered, post_mime_type, page_template, and the datetime fields post_date, post_date_gmt, post_modified, post_modified_gmt. The datetimes are NOT NULL in the DB, but get_default_post_to_edit() constructs a WP_Post with empty date strings, so non-empty-string would be inaccurate (and there is no finer type for a possibly-empty datetime string). menu_order stays int since it can be negative.
  • WP_Post::$filter — corrected to string|null / the six sanitize contexts plus 'sample' plus null. It had been mistyped (as string, then as the six contexts), both of which wrongly excluded null (a WP_Post built from a raw row has no filter until sanitized, and core relies on this via isset()/empty() checks) and 'sample' (assigned by get_sample_permalink() since [8526] / WP 2.7.1 to keep the mutated object out of the cache). sanitize_post_field()'s $context is widened to accept 'sample' accordingly — it already handles it at runtime, falling through to the display branch.
  • get_post_ancestors()list<non-negative-int> return type.
  • trackback_url_list() — fetches the WP_Post and calls to_array() so the accessed keys are known, guarding against a null post.
  • WP_Customize_Nav_Menu_Item_Setting and inject_ignored_hooked_blocks_metadata_attributes() — cast the int from get_current_user_id() to string when defaulting post_author on an object passed to new WP_Post(), so it stays a numeric-string.

Follow-up

Warning

get_default_post_to_edit() (wp-admin/includes/post.php) sets $post->post_category = get_option( 'default_category' ), which assigns a scalar term ID where an array of category IDs is expected, and writes to the @property-read post_category magic property (it only works because $post is a stdClass there, so the write creates a dynamic property that shadows the getter). This is a pre-existing inconsistency, out of scope for this PR, and should be addressed in a follow-up.

PHPStan impact (level 10)

Net −8 errors (24 fixed, 16 "introduced").

The 16 "introduced" are not regressions. They are pre-existing latent errors at distant call sites (e.g. esc_attr( $post->ID ), isset( $post->ID )) that PHPStan now prints with the sharper int<0, max> type instead of int — each has a byte-identical counterpart on trunk at the same line, so a message-based diff counts one re-worded error as one fixed + one introduced. None of those sites is in a file this PR edits.

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: Static-analysis-driven iteration and drafting the PHPDoc/type annotations. Every change was reviewed, verified against PHPStan (level 10) and PHPCS, and edited by me; I take responsibility for the result.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

@github-actions

github-actions Bot commented Jul 13, 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, mukesh27.

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

@github-actions

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.

Comment thread src/wp-includes/post.php
Comment on lines -6152 to +6157
$postdata = get_post( $post_id, ARRAY_A );

if ( ! $postdata ) {
$post = get_post( $post_id );
if ( ! $post ) {
return;
}
$postdata = $post->to_array();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WP already did that https://github.com/WordPress/wordpress-develop/blob/7.0/src/wp-includes/post.php#L1143 what is advantage of doing it separately?

@westonruter westonruter Jul 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The advantage is that WP_Post::to_array() has the array shape information. This is lost in the indirect call to get_post() because the Data_Array type is not imported from WP_Post (and it can't be until phpstan/phpstan#9164 is implemented).

So implementing this change here fixes two PHPStan errors in this function:

Parameter #1 $str of function strip_tags expects string, mixed given.

at

$excerpt = strip_tags( $postdata['post_excerpt'] ? $postdata['post_excerpt'] : $postdata['post_content'] );

And

phpstan: Parameter #2 $title of function trackback expects string, mixed given.

trackback( $tb_url, wp_unslash( $postdata['post_title'] ), $excerpt, $post_id );

Essentially, the fix to trackback_url_list() is a proof of concept for what other callers will expect to see with that type available.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other words, the result is identical. The change is purely so that type information is available.

westonruter and others added 4 commits July 13, 2026 09:50
Define a Data_Array shape describing the associative array returned by
WP_Post::to_array(), and use it as the method's @phpstan-return. Each key
is given a refined value type wherever the value set is genuinely closed:

- ID and post_parent: non-negative-int (never negative; 0 is valid for
  unsaved objects, so not positive-int).
- post_author and comment_count: numeric-string (integer DB columns).
- post_date(_gmt) and post_modified(_gmt): non-empty-string (NOT NULL
  DATETIME columns).
- ancestors, page_template, post_category and tags_input, which are
  always present because WP_Post::__isset() returns true for all four,
  so to_array() always appends them.

The shape is open (trailing ...) because WP_Post is
#[AllowDynamicProperties] and real instances routinely carry extra
columns. filter is left as string: core also assigns 'sample' and
restores arbitrary prior values, so it is not a closed set.

Mirror the non-negative-int and non-empty-string refinements onto the
corresponding property @phpstan-var tags. WP_Customize_Nav_Menu_Item_Setting
assigned the int from get_current_user_id() to the numeric-string
post_author property; cast it to string to honor the documented type.

Give get_post_ancestors() a precise list<non-negative-int> return type,
and rework trackback_url_list() to fetch the WP_Post and call to_array()
so the accessed keys are known, guarding against a null post.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ld()

WP_Post::$filter has been mistyped in every prior state: as string, and
then as the six sanitize_post_field() contexts. Both wrongly excluded:

- null. The property has no default, so a WP_Post built from a raw row
  (not via get_instance(), which sanitizes) has $filter === null. Core
  relies on this: get_instance() does empty( $_post->filter ) and __get()
  guards with if ( $this->filter ). Typing it non-nullable made PHPStan
  report those isset()/empty() checks as pointless.
- 'sample'. get_sample_permalink() has assigned $post->filter = 'sample'
  since [8526] (WP 2.7.1) to keep the mutated post object out of the
  cache. It is a real, long-standing value the six-context enum omitted,
  so the enum rejected the assignment.

Type the property as string|null / the seven-value enum plus null, and
update the Data_Array shape to match.

WP_Post::__get() passes $this->filter to sanitize_post_field(), so add
'sample' to that function's $context type as well. sanitize_post_field()
already handles it at runtime: 'sample' is not raw/edit/db, so it falls
through to the display branch. The conditional return keys off $field,
not $context, so it needs no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Narrow the remaining `WP_Post` types where the value set is genuinely
constrained:

- The magic array accessors become precise lists: `ancestors` and
  `post_category` are `list<non-negative-int>` (built by
  `get_post_ancestors()` and `wp_list_pluck( …, 'term_id' )`), and
  `tags_input` is `list<non-empty-string>` (`wp_list_pluck( …, 'name' )`,
  names being non-empty per `wp_insert_term()`). `ancestors` now matches
  the `list<non-negative-int>` return type this branch gives
  `get_post_ancestors()`.
- `post_status`, `comment_status`, `ping_status`, and `post_type` become
  `non-empty-string`: each is a `NOT NULL` column that always holds a
  slug and is never empty, but the set stays open so custom
  statuses/types remain valid. A full-level check across every writer of
  these properties confirms the assignment sites all satisfy
  `non-empty-string`.

Properties that can legitimately be empty (`post_content`, `post_title`,
`post_excerpt`, `post_name`, `post_password`, `guid`, `to_ping`,
`pinged`, `post_content_filtered`, `post_mime_type`, `page_template`) are
left as `string`, and `menu_order` stays `int` since it can be negative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves PHPStan level-10 type coverage for WP_Post, primarily by introducing a precise PHPStan array-shape for WP_Post::to_array() and refining several WP_Post property/value types to better reflect what core code expects.

Changes:

  • Adds a @phpstan-type Data_Array shape for WP_Post::to_array() and uses it as the method’s @phpstan-return.
  • Widens PHPStan’s accepted $context for sanitize_post_field() to include 'sample', aligning with how WP_Post::$filter is used by get_sample_permalink().
  • Updates nav-menu customize code and its PHPUnit test to cast post_author to string to satisfy the refined numeric-string typing, and adjusts trackback_url_list() to avoid a nullable-array access pattern.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
tests/phpunit/tests/customize/nav-menu-item-setting.php Updates assertion to match post_author now being treated as a numeric-string.
src/wp-includes/post.php Adjusts PHPStan typing/docs for ancestors/context and refactors trackback_url_list() to use WP_Post::to_array().
src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php Casts post_author to string when defaulting to the current user ID.
src/wp-includes/class-wp-post.php Introduces Data_Array shape for to_array() and refines several PHPStan property/value types.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/wp-includes/post.php Outdated
Comment thread src/wp-includes/class-wp-post.php Outdated
westonruter and others added 2 commits July 13, 2026 11:11
inject_ignored_hooked_blocks_metadata_attributes() builds a plain object
(via a (object) cast) and assigns the int result of
get_current_user_id() to post_author before passing it to
new WP_Post(). WP_Post::__construct() copies properties verbatim, so the
resulting object would carry an int post_author, contradicting the
numeric-string typing. Cast it to string, mirroring the same fix already
applied in WP_Customize_Nav_Menu_Item_Setting.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread src/wp-includes/post.php Outdated
Comment on lines +3013 to +3017
@@ -3013,7 +3014,7 @@ function sanitize_post( $post, $context = 'display' ) {
* 'db', 'display', 'attribute' and 'js'. Default 'display'.
* @return mixed Sanitized value.
*
* @phpstan-param 'raw'|'edit'|'db'|'display'|'attribute'|'js' $context
* @phpstan-param 'raw'|'edit'|'db'|'display'|'attribute'|'js'|'sample' $context

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4269e6b

Comment thread src/wp-includes/class-wp-post.php Outdated
Comment on lines +21 to +25
* @phpstan-type Data_Array array{
* ID: non-negative-int,
* post_author: numeric-string,
* post_date: non-empty-string,
* post_date_gmt: non-empty-string,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d10c9d4

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normalizing should be done in a follow-up ticket per #12491 (comment).

westonruter and others added 2 commits July 13, 2026 12:23
get_default_post_to_edit() builds a WP_Post whose post_date and
post_date_gmt are empty strings, so the non-empty-string guarantee does
not actually hold. Revert the four datetime fields (post_date,
post_date_gmt, post_modified, post_modified_gmt) to string. There is no
finer type available, since a datetime string that may be empty is just
string.

For the same reason post_author may be an empty string on that default
post, so type it as numeric-string|'' rather than numeric-string. This
still excludes arbitrary non-numeric strings (so (int) casts remain
meaningful) while allowing the not-yet-assigned case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@westonruter

Copy link
Copy Markdown
Member Author

👉🏻 In a follow-up ticket, there are fixes needed for get_default_post_to_edit(). It is erroneously setting a read-only post_category, it is setting post_author to an empty string when it could be set to '0', and it is setting post_date and post_date_gmt to empty strings when they should be '0000-00-00 00:00:00'. This would allow undoing some of d10c9d4 to re-narrow those types.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/wp-includes/block-template-utils.php Outdated
…Post

inject_ignored_hooked_blocks_metadata_attributes() can receive a post
whose post_author is an int: WP_REST_Templates_Controller::prepare_item_for_database()
assigns (int) $request['author'] to $changes->post_author, which flows
through the rest_pre_insert_wp_template(_part) filter into this function
and on to new WP_Post(). Cast the value to a string in the non-empty
branch as well, not only when defaulting to the current user, so the
object honors the numeric-string|'' type of WP_Post::$post_author.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/wp-includes/post.php
The dynamic `{$field}` and `post_{$field}` filter docblocks in
sanitize_post_field() still listed only the six original contexts for
their $context parameter. Now that the function accepts 'sample', note
it in those inline docblocks too, matching the function's own @param.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread src/wp-includes/class-wp-post.php
Comment thread src/wp-includes/post.php
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/wp-includes/block-template-utils.php Outdated
The previous normalization cast post_author through (int) before
stringifying, which needlessly round-trips an existing numeric string
and could truncate a large user ID on 32-bit PHP. Cast to a string only
when the value is actually an int (e.g. from a REST controller); an
existing string is left untouched, preserving its full precision. A
direct (string) cast is not used because the value is mixed on the
merged object, which PHPStan rejects; is_int() narrows it first.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
*
* @phpstan-type Data_Array array{
* ID: non-negative-int,
* post_author: numeric-string|'',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An empty string is only “needed” by get_default_post_to_edit(). See note in description.

Comment on lines +24 to +25
* post_date: string,
* post_date_gmt: string,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally these would be non-empty-string, and they usually are, except for get_default_post_to_edit(). To be addressed later.

Comment on lines +36 to +37
* post_modified: string,
* post_modified_gmt: string,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally these would be non-empty-string, and they usually are, except for get_default_post_to_edit(). To be addressed later.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

pento pushed a commit that referenced this pull request Jul 14, 2026
Add a `Data_Array` array-shape type describing every key returned by `WP_Post::to_array()`, and tighten each `WP_Post` property to the narrowest type the schema and code guarantee:

* `ID` and `post_parent` become `non-negative-int`.
* `comment_count` becomes `numeric-string`, and `post_author` becomes `numeric-string|''` (a user ID, or an empty string for a default post that has not yet been assigned an author).
* The always-populated `post_status`, `comment_status`, `ping_status`, and `post_type` slugs become `non-empty-string`, left open rather than enumerated so custom statuses and types remain valid.
* The magic `ancestors`, `post_category`, and `tags_input` accessors become precise lists (`list<non-negative-int>` and `list<non-empty-string>`).

The shape stays open because `WP_Post` permits dynamic properties. Fields that can legitimately be empty, including the datetime fields (which `get_default_post_to_edit()` may leave empty), stay `string`, and `menu_order` stays `int` since it can be negative.

Correct `WP_Post::$filter`, which was previously typed without `null` or the `'sample'` context: an unsanitized post has no `filter` (checked in core via `isset()` and `empty()`), and `get_sample_permalink()` has assigned `'sample'` since [8526]. Widen `sanitize_post_field()`'s `$context` to accept `'sample'` to match, which it already treats as a `'display'` context.

Also type `get_post_ancestors()` as returning `list<non-negative-int>`, read the post in `trackback_url_list()` via `to_array()` while guarding against a missing post, and cast `post_author` to a string in `WP_Customize_Nav_Menu_Item_Setting` and `inject_ignored_hooked_blocks_metadata_attributes()`, which each populate an object before passing it to `new WP_Post()`.

Developed in #12491.
Follow-up to r62648, r62694.

See #64898, #64896.


git-svn-id: https://develop.svn.wordpress.org/trunk@62717 602fd350-edb4-49c9-b593-d223f7449a82
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Jul 14, 2026
Add a `Data_Array` array-shape type describing every key returned by `WP_Post::to_array()`, and tighten each `WP_Post` property to the narrowest type the schema and code guarantee:

* `ID` and `post_parent` become `non-negative-int`.
* `comment_count` becomes `numeric-string`, and `post_author` becomes `numeric-string|''` (a user ID, or an empty string for a default post that has not yet been assigned an author).
* The always-populated `post_status`, `comment_status`, `ping_status`, and `post_type` slugs become `non-empty-string`, left open rather than enumerated so custom statuses and types remain valid.
* The magic `ancestors`, `post_category`, and `tags_input` accessors become precise lists (`list<non-negative-int>` and `list<non-empty-string>`).

The shape stays open because `WP_Post` permits dynamic properties. Fields that can legitimately be empty, including the datetime fields (which `get_default_post_to_edit()` may leave empty), stay `string`, and `menu_order` stays `int` since it can be negative.

Correct `WP_Post::$filter`, which was previously typed without `null` or the `'sample'` context: an unsanitized post has no `filter` (checked in core via `isset()` and `empty()`), and `get_sample_permalink()` has assigned `'sample'` since [8526]. Widen `sanitize_post_field()`'s `$context` to accept `'sample'` to match, which it already treats as a `'display'` context.

Also type `get_post_ancestors()` as returning `list<non-negative-int>`, read the post in `trackback_url_list()` via `to_array()` while guarding against a missing post, and cast `post_author` to a string in `WP_Customize_Nav_Menu_Item_Setting` and `inject_ignored_hooked_blocks_metadata_attributes()`, which each populate an object before passing it to `new WP_Post()`.

Developed in WordPress/wordpress-develop#12491.
Follow-up to r62648, r62694.

See #64898, #64896.

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


git-svn-id: http://core.svn.wordpress.org/trunk@62001 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.

3 participants