diff --git a/.github/workflows/php-static-analysis.yml b/.github/workflows/php-static-analysis.yml
new file mode 100644
index 0000000000000..2c1d4a4c95c0d
--- /dev/null
+++ b/.github/workflows/php-static-analysis.yml
@@ -0,0 +1,97 @@
+name: PHPStan Static Analysis
+
+on:
+ # PHPStan testing was introduced in 7.0.0.
+ push:
+ branches:
+ - trunk
+ - '[7-9].[0-9]'
+ tags:
+ - '[7-9].[0-9]'
+ - '[7-9]+.[0-9].[0-9]+'
+ pull_request:
+ branches:
+ - trunk
+ - '[7-9].[0-9]'
+ paths:
+ # This workflow only scans PHP files.
+ - '**.php'
+ # These files configure Composer. Changes could affect the outcome.
+ - 'composer.*'
+ # These files configure PHPStan. Changes could affect the outcome.
+ - 'phpstan.neon.dist'
+ - 'tests/phpstan/base.neon'
+ - 'tests/phpstan/baseline.php'
+ # Confirm any changes to relevant workflow files.
+ - '.github/workflows/php-static-analysis.yml'
+ - '.github/workflows/reusable-php-static-analysis.yml'
+ workflow_dispatch:
+
+# Cancels all previous workflow runs for pull requests that have not completed.
+concurrency:
+ # The concurrency group contains the workflow name and the branch name for pull requests
+ # or the commit hash for any other events.
+ group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
+ cancel-in-progress: true
+
+# Disable permissions for all available scopes by default.
+# Any needed permissions should be configured at the job level.
+permissions: {}
+
+jobs:
+ # Runs PHPStan Static Analysis.
+ phpstan:
+ name: PHP static analysis
+ uses: ./.github/workflows/reusable-php-static-analysis.yml
+ permissions:
+ contents: read
+ if: ${{ github.repository == 'WordPress/wordpress-develop' || ( github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' ) }}
+
+ slack-notifications:
+ name: Slack Notifications
+ uses: ./.github/workflows/slack-notifications.yml
+ permissions:
+ actions: read
+ contents: read
+ needs: [ phpstan ]
+ if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }}
+ with:
+ calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }}
+ secrets:
+ SLACK_GHA_SUCCESS_WEBHOOK: ${{ secrets.SLACK_GHA_SUCCESS_WEBHOOK }}
+ SLACK_GHA_CANCELLED_WEBHOOK: ${{ secrets.SLACK_GHA_CANCELLED_WEBHOOK }}
+ SLACK_GHA_FIXED_WEBHOOK: ${{ secrets.SLACK_GHA_FIXED_WEBHOOK }}
+ SLACK_GHA_FAILURE_WEBHOOK: ${{ secrets.SLACK_GHA_FAILURE_WEBHOOK }}
+
+ failed-workflow:
+ name: Failed workflow tasks
+ runs-on: ubuntu-24.04
+ permissions:
+ actions: write
+ needs: [ slack-notifications ]
+ if: |
+ always() &&
+ github.repository == 'WordPress/wordpress-develop' &&
+ github.event_name != 'pull_request' &&
+ github.run_attempt < 2 &&
+ (
+ contains( needs.*.result, 'cancelled' ) ||
+ contains( needs.*.result, 'failure' )
+ )
+
+ steps:
+ - name: Dispatch workflow run
+ uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
+ with:
+ retries: 2
+ retry-exempt-status-codes: 418
+ script: |
+ github.rest.actions.createWorkflowDispatch({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ workflow_id: 'failed-workflow.yml',
+ ref: 'trunk',
+ inputs: {
+ run_id: `${context.runId}`,
+ }
+ });
diff --git a/.github/workflows/reusable-php-static-analysis.yml b/.github/workflows/reusable-php-static-analysis.yml
new file mode 100644
index 0000000000000..6773008ec449d
--- /dev/null
+++ b/.github/workflows/reusable-php-static-analysis.yml
@@ -0,0 +1,121 @@
+##
+# A reusable workflow that runs PHP Static Analysis tests.
+##
+name: PHP Static Analysis
+
+on:
+ workflow_call:
+ inputs:
+ php-version:
+ description: 'The PHP version to use.'
+ required: false
+ type: 'string'
+ default: 'latest'
+
+# Disable permissions for all available scopes by default.
+# Any needed permissions should be configured at the job level.
+permissions: {}
+
+jobs:
+ # Runs PHP static analysis tests.
+ #
+ # Violations are reported inline with annotations.
+ #
+ # Performs the following steps:
+ # - Checks out the repository.
+ # - Sets up PHP.
+ # - Logs debug information.
+ # - Installs Composer dependencies.
+ # - Configures caching for PHP static analysis scans.
+ # - Make Composer packages available globally.
+ # - Runs PHPStan static analysis (with Pull Request annotations).
+ # - Saves the PHPStan result cache.
+ # - Ensures version-controlled files are not modified or deleted.
+ phpstan:
+ name: Run PHP static analysis
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ timeout-minutes: 20
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ show-progress: ${{ runner.debug == '1' && 'true' || 'false' }}
+ persist-credentials: false
+
+ - name: Set up Node.js
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
+ with:
+ node-version-file: '.nvmrc'
+ cache: npm
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@20529878ed81ef8e78ddf08b480401e6101a850f # v2.35.3
+ with:
+ php-version: ${{ inputs.php-version }}
+ coverage: none
+ tools: cs2pr
+
+ # This date is used to ensure that the Composer cache is cleared at least once every week.
+ # http://man7.org/linux/man-pages/man1/date.1.html
+ - name: "Get last Monday's date"
+ id: get-date
+ run: echo "date=$(/bin/date -u --date='last Mon' "+%F")" >> "$GITHUB_OUTPUT"
+
+ - name: General debug information
+ run: |
+ npm --version
+ node --version
+ composer --version
+
+ # Since Composer dependencies are installed using `composer update` and no lock file is in version control,
+ # passing a custom cache suffix ensures that the cache is flushed at least once per week.
+ - name: Install Composer dependencies
+ uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # v3.1.1
+ with:
+ custom-cache-suffix: ${{ steps.get-date.outputs.date }}
+
+ - name: Make Composer packages available globally
+ run: echo "${PWD}/vendor/bin" >> "$GITHUB_PATH"
+
+ - name: Get Gutenberg ref
+ id: gutenberg-ref
+ run: echo "ref=$(node -e 'console.log(require("./package.json").gutenberg.ref)')" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Gutenberg
+ uses: actions/cache@v4
+ with:
+ path: |
+ gutenberg
+ .gutenberg-hash
+ key: gutenberg-${{ steps.gutenberg-ref.outputs.ref }}-${{ hashFiles('tools/gutenberg/*') }}
+
+ - name: Install npm dependencies
+ run: npm ci --ignore-scripts
+
+ - name: Build WordPress
+ run: npm run build:dev
+
+ - name: Cache PHP Static Analysis scan cache
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: .cache # This is defined in the base.neon file.
+ key: "phpstan-result-cache-${{ github.run_id }}"
+ restore-keys: |
+ phpstan-result-cache-
+
+ - name: Run PHP static analysis tests
+ id: phpstan
+ run: phpstan analyse -vvv --error-format=checkstyle | cs2pr
+
+ - name: "Save result cache"
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ if: ${{ !cancelled() }}
+ with:
+ path: .cache
+ key: "phpstan-result-cache-${{ github.run_id }}"
+
+ - name: Ensure version-controlled files are not modified or deleted
+ run: git diff --exit-code
diff --git a/.gitignore b/.gitignore
index 3997df4c9d603..2705dadd54b2b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ wp-tests-config.php
/gutenberg
/tests/phpunit/build
/wp-cli.local.yml
+/phpstan.neon
/jsdoc
/composer.lock
/vendor
diff --git a/composer.json b/composer.json
index 2c5b20f7879a9..2c5923e9e3257 100644
--- a/composer.json
+++ b/composer.json
@@ -23,6 +23,7 @@
"squizlabs/php_codesniffer": "3.13.5",
"wp-coding-standards/wpcs": "~3.3.0",
"phpcompatibility/phpcompatibility-wp": "~2.1.3",
+ "phpstan/phpstan": "~2.1.33",
"yoast/phpunit-polyfills": "^1.1.0"
},
"config": {
@@ -32,6 +33,7 @@
"lock": false
},
"scripts": {
+ "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",
"compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source",
"format": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf --report=summary,source",
"lint": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --report=summary,source",
diff --git a/package.json b/package.json
index 766e241ff8d6d..fcd29cb1b49ad 100644
--- a/package.json
+++ b/package.json
@@ -130,6 +130,7 @@
"test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt",
"test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js",
"test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js",
+ "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan",
"gutenberg:checkout": "node tools/gutenberg/checkout-gutenberg.js",
"gutenberg:build": "node tools/gutenberg/build-gutenberg.js",
"gutenberg:copy": "node tools/gutenberg/copy-gutenberg-build.js",
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index a8387b3604c9b..efb679fb6c13b 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -81,6 +81,9 @@
/tests/phpunit/build*
/tests/phpunit/data/*
+
+ /tests/phpstan/*
+
/tools/*
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000000000..e74e6ec1a441b
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,36 @@
+# PHPStan configuration for WordPress Core.
+#
+# To overload this configuration, copy this file to phpstan.neon and adjust as needed.
+#
+# https://phpstan.org/config-reference
+
+includes:
+ # The base configuration file for using PHPStan with the WordPress core codebase.
+ - tests/phpstan/base.neon
+
+ # The baseline file includes preexisting errors in the codebase that should be ignored.
+ # https://phpstan.org/user-guide/baseline
+ - tests/phpstan/baseline.php
+
+parameters:
+ # https://phpstan.org/user-guide/rule-levels
+ level: 0
+ reportUnmatchedIgnoredErrors: true
+
+ ignoreErrors:
+ # Level 0:
+ - # Inner functions aren't supported by PHPStan.
+ message: '#Function wxr_[a-z_]+ not found#'
+ path: src/wp-admin/includes/export.php
+ -
+ identifier: function.inner
+ path: src/wp-admin/includes/export.php
+ count: 13
+ -
+ identifier: function.inner
+ path: src/wp-admin/includes/file.php
+ count: 1
+ -
+ identifier: function.inner
+ path: src/wp-includes/canonical.php
+ count: 1
diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php
index 9e0cb885b0bcc..30bd38c3cf2f2 100644
--- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php
+++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php
@@ -672,6 +672,7 @@ public function size( $file ) {
* Default 0.
*/
public function touch( $file, $time = 0, $atime = 0 ) {
+ // @phpstan-ignore-next-line
// Not implemented.
}
diff --git a/src/wp-admin/press-this.php b/src/wp-admin/press-this.php
index c91df1c96b84b..45021964364a3 100644
--- a/src/wp-admin/press-this.php
+++ b/src/wp-admin/press-this.php
@@ -22,8 +22,8 @@ function wp_load_press_this() {
403
);
} elseif ( is_plugin_active( $plugin_file ) ) {
- include WP_PLUGIN_DIR . '/press-this/class-wp-press-this-plugin.php';
- $wp_press_this = new WP_Press_This_Plugin();
+ include WP_PLUGIN_DIR . '/press-this/class-wp-press-this-plugin.php'; // @phpstan-ignore include.fileNotFound
+ $wp_press_this = new WP_Press_This_Plugin(); // @phpstan-ignore class.notFound
$wp_press_this->html();
} elseif ( current_user_can( 'activate_plugins' ) ) {
if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) {
diff --git a/src/wp-content/themes/twentyfourteen/inc/featured-content.php b/src/wp-content/themes/twentyfourteen/inc/featured-content.php
index b98ab983919df..3193aa8b93549 100644
--- a/src/wp-content/themes/twentyfourteen/inc/featured-content.php
+++ b/src/wp-content/themes/twentyfourteen/inc/featured-content.php
@@ -212,7 +212,7 @@ public static function delete_transient() {
* @since Twenty Fourteen 1.0
*
* @param WP_Query $query WP_Query object.
- * @return WP_Query Possibly-modified WP_Query.
+ * @return void
*/
public static function pre_get_posts( $query ) {
diff --git a/src/wp-content/themes/twentytwenty/inc/template-tags.php b/src/wp-content/themes/twentytwenty/inc/template-tags.php
index fdf51ccee9624..e15ae6652bbec 100644
--- a/src/wp-content/themes/twentytwenty/inc/template-tags.php
+++ b/src/wp-content/themes/twentytwenty/inc/template-tags.php
@@ -29,7 +29,7 @@
*
* @param array $args Arguments for displaying the site logo either as an image or text.
* @param bool $display Display or return the HTML.
- * @return string Compiled HTML based on our arguments.
+ * @return string|void Compiled HTML based on our arguments.
*/
function twentytwenty_site_logo( $args = array(), $display = true ) {
$logo = get_custom_logo();
@@ -107,7 +107,7 @@ function twentytwenty_site_logo( $args = array(), $display = true ) {
* @since Twenty Twenty 1.0
*
* @param bool $display Display or return the HTML.
- * @return string The HTML to display.
+ * @return string|void The HTML to display.
*/
function twentytwenty_site_description( $display = true ) {
$description = get_bloginfo( 'description' );
@@ -249,7 +249,7 @@ function twentytwenty_edit_post_link( $link, $post_id, $text ) {
*
* @param int $post_id The ID of the post.
* @param string $location The location where the meta is shown.
- * @return string Post meta HTML.
+ * @return string|void Post meta HTML.
*/
function twentytwenty_get_post_meta( $post_id = null, $location = 'single-top' ) {
diff --git a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php
index 53db8dffb216f..d4bc16e20171d 100644
--- a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php
+++ b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-customize.php
@@ -35,8 +35,12 @@ public function __construct() {
public function register( $wp_customize ) {
// Change site-title & description to postMessage.
- $wp_customize->get_setting( 'blogname' )->transport = 'postMessage'; // @phpstan-ignore-line. Assume that this setting exists.
- $wp_customize->get_setting( 'blogdescription' )->transport = 'postMessage'; // @phpstan-ignore-line. Assume that this setting exists.
+ foreach ( array( 'blogname', 'blogdescription' ) as $setting_id ) {
+ $setting = $wp_customize->get_setting( $setting_id );
+ if ( $setting ) {
+ $setting->transport = 'postMessage';
+ }
+ }
// Add partial for blogname.
$wp_customize->selective_refresh->add_partial(
diff --git a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php
index d5672c4054f3c..6644e7d02eeab 100644
--- a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php
+++ b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-dark-mode.php
@@ -98,7 +98,7 @@ public function enqueue_scripts() {
if ( is_rtl() ) {
$url = get_template_directory_uri() . '/assets/css/style-dark-mode-rtl.css';
}
- wp_enqueue_style( 'tt1-dark-mode', $url, array( 'twenty-twenty-one-style' ), wp_get_theme()->get( 'Version' ) ); // @phpstan-ignore-line. Version is always a string.
+ wp_enqueue_style( 'tt1-dark-mode', $url, array( 'twenty-twenty-one-style' ), wp_get_theme()->get( 'Version' ) );
}
/**
diff --git a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php
index 398ef82bbd7c8..e32d050b4e455 100644
--- a/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php
+++ b/src/wp-content/themes/twentytwentyone/classes/class-twenty-twenty-one-svg-icons.php
@@ -189,10 +189,9 @@ public static function get_svg( $group, $icon, $size ) {
if ( array_key_exists( $icon, $arr ) ) {
$repl = sprintf( '