We run into a lot of technical challenges while developing WP Fusion, but I think getting our IDE to properly and consistently lint, beautify, run static analysis, and test our code has been the biggest thorn in my side for the last 5 years.
I’ll get one part of it working just to have another part break. Or I’ll have everything working on my system, but the same configuration doesn’t work for another member of the development team.
I’ve spent the last two days working on this, starting from scratch, and everything has finally come together in a way that’s easy to implement across projects, and it works universally across our development team, using either Cursor or VS Code.
Goals
- We want all code we write to follow the WordPress code and documentation standards.
This helps other developers read and understand our work better, but also ensures every member of the team is contributing work in a consistent style. - We want to be able to extend the default WordPress coding standards PHPCS package with our own custom rules— for example to enforce class prefixes, option prefixes, and our textdomain.
- We want to use PHP Static Analysis, to point out potential bugs in the code before running it (for example unexpected return types, missing parameters, incompatibility with specific PHP versions, etc.)
- We want any errors to show in the IDE with Cursor (rather than running the checks via the command line), so we can use the Iterate on Lints feature when working with the AI to automatically fix linter errors and anticipate bugs.
- Once all of that is configured, we want any developer on our team to automatically run the same set of tools, at the same standards, and receive updates if we change anything (for example a minimum WordPress version, PHP version, etc). This should all happen with minimal editing of configuration files.
Recommended extensions
There are a lot of VS Code extensions for phpcs
and phpcbf
, I’ve probably tried all of them.
Now that we know which ones are working best together, I’ve created a file in our project root at .vscode/extensions.json
. It contains the following:
{
"recommendations": [
"valeryanm.vscode-phpsab",
"ikappas.phpcs",
"sanderronde.phpstan-vscode",
"bmewburn.vscode-intelephense-client"
]
}
When a developer first opens the project, this will prompt them to install the extensions if they aren’t already installed.
The extensions we’re using are:
- PHP Sniffer & Beautifier by Samuel Hilson: This extension runs
phpcbf
when we save a file, and will automatically fix any linter errors. In theory it should also highlight issues in the editor, but I could never get it working, and I see the same problem reported elsewhere, so we disable the sniffer using"phpsab.snifferEnable": false
(more on that below). - phpcs by Ioannis Kappas: This handles linting in the editor, and highlights any code that doesn’t conform to the WordPress code or documentation standards:

- phpstan by SanderRonde: This provides static analysis, essentially reading the code and inline documentation to anticipate potential bugs.

- PHP Intelephense by Ben Mewburn: This provides a ton of features to speed up developing with PHP. You can hover over any function or class name to see its properties and parameters, or right click and choose Go To Definition to jump to the source file (even in other plugins).

MeprUser
class and be taken straight to the file where the class is defined.Automatic package installation
Up until this year, I’d installed phpcs
and phpcbf
globally via Homebrew, and then attempted to point Cursor to their paths to run them in the IDE.
This sort of works, but it means I needed to hard-code the paths in my configuration file, which means it can’t be version controlled or shared with the rest of the team.
I’d frequently run into issues with versions getting mis-matched between projects, and I’d lose linting and automated checks for days or weeks until I had time to untangle it again.
Now, each plugin has a composer.json file, which installs all the dependencies for that project into the /vendor/
folder in the plugin.
Here’s an example for WP Fusion:
{
"name": "verygoodplugins/wp-fusion",
"description": "WP Fusion connects your website to your CRM or marketing automation tool, with support for dozens of CRMs and 100+ WordPress plugins.",
"type": "project",
"require-dev": {
"phpunit/phpunit": "^9.6",
"yoast/phpunit-polyfills": "^1.0",
"szepeviktor/phpstan-wordpress": "^2.0",
"phpstan/extension-installer": "^1.4",
"squizlabs/php_codesniffer": "^3.11",
"wp-coding-standards/wpcs": "^3.1",
"digitalrevolution/php-codesniffer-baseline": "^1.1",
"php-stubs/woocommerce-stubs": "^9.1",
"php-stubs/wordpress-stubs": "^6.6",
"phpcompatibility/phpcompatibility-wp": "*",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0"
},
"scripts": {
"test": "./vendor/bin/phpunit -c phpunit.xml",
"phpstan": "./vendor/bin/phpstan analyse --memory-limit=2G",
"phpcs": "./vendor/bin/phpcs --standard=phpcs.xml"
},
"license": "GPL-3.0-or-later",
"authors": [
{
"name": "Jack Arturo",
"email": "[email protected]"
}
],
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"phpstan/extension-installer": true,
"digitalrevolution/php-codesniffer-baseline": true
}
}
}
We want to make sure that the development dependencies are installed automatically when someone starts working on the project (otherwise the extensions installed in the first step won’t work).
To automate that, we have a /.cursor/tasks.json
file, with the following contents:
{
"tasks": [
{
"name": "Install PHP Dependencies",
"command": "composer update",
"description": "Install required PHP dependencies via Composer",
"runAtStart": true,
"skipIfFileExists": "vendor/autoload.php"
}
]
}
When someone first opens the WP Fusion package, they will be prompted to install the required VS Code / Cursor extensions.
The task file checks to see if Composer has been run— if not, it prompts the user to run composer update
to install all dependencies.
Once these steps are done the project is automatically enabled for linting, auto-formatting on save, and static analysis, with basically no input or configuration steps required for the developer.
Workspace settings
We want to keep the workspace settings configuration to a minimum, as folks may already have other tools or customizations they want to use for the workspace. We exclude the workspace file from version control, so this is the only part of the setup that needs to be applied manualy.
This is the wp-fusion.code-workspace
file:
{
"folders": [
{
"path": "."
},
{
"path": "../../.."
}
],
"settings": {
"phpstan.suppressWorkspaceMessage": true,
"phpstan.binPath": "./vendor/bin/phpstan",
"phpstan.binCommand": [],
"phpstan.configFile": "phpstan.neon",
"phpstan.memoryLimit": "2G",
"phpsab.snifferEnable": false,
"phpcs.executablePath": "./vendor/bin/phpcs",
},
}
There are a few things happening here:
- Even though we’re working in
/wp-fusion/
as the primary project directory, we want the entire WordPress install (including core and all plugins) to be part of the project context. This allows us to jump to any definition with Intellephense. The"path": "../../.."
adds thepublic_html
directory to the workspace. - PHPStan can only run in a single directory at a time.
"phpstan.suppressWorkspaceMessage"
disables the warning that appears each time you open a multi-folder project. "phpstan.binPath": "./vendor/bin/phpstan"
ensures that we’re using thephpstan
version installed by Composer, just in case you have configured a different version in your user settings.phpstan.binCommand
is set to blank, in case you’ve installedphpstan
system-wide via Homebrew or another package manager— this ensures we use the Composer version instead."phpstan.memoryLimit": "2G",
The default PHPStan memory limit is 1GB which isn’t enough for WP Fusion (at over 400 files). You can add this if you get errors that the initialization timed out."phpsab.snifferEnable": false,
This disables the “sniffer” (i.e. linting as you type) in PHP Sniffer & Beautifier. Since we’re using thephpcs
extension for this, it’s not necessary."phpcs.executablePath"
ensures that we’re using the version ofphpcs
installed via Composer, in case it’s been overridden in the user settings.
Note: While we don’t version control the workspace configuration file, it is possible to create a .vscode/settings.json
file with a minimal settings configuration. If this is present, it will take priority over the user and workspace settings.
The reason we’re not using this strategy is that, for some reason related to PHPStan supporting multiple project root folders, the PHPStan configuration doesn’t work inside of settings.json
, it has to be configured at the workspace level. Rather than splitting the configuration between two places, we just put it all in the workspace file.
PHPStan configuration
PHPStan automatically looks for a phpstan.neon
file in the project root. Here are the contents of that file for WP Fusion:
includes:
- phpstan-baseline.neon
parameters:
level: 5
checkExplicitMixedMissingReturn: false
reportUnmatchedIgnoredErrors: false
bootstrapFiles:
- ./stubs.php
excludePaths:
- **/node_modules/*
- **/vendor/*
- **/tests/*
- !**/*.php
paths:
- ./
The phpstan-baseline.neon
is optional— WP Fusion is an old project and much of the code doesn’t meet our current standards. When we first implemented PHPStan, we created a baseline, which is a list of already known errors that can be ignored for the time being.
This way a developer adding functionality for an older integration only needs to ensure their new code passes PHPStan’s validation— they aren’t required to rewrite the whole file to conform to standards.
level: 5
. This controls the strictness of the analysis, from 0 to 10. For more information on the levels, see the PHPStan documentation.
checkExplicitMixedMissingReturn: false
: This saves us from having to use a @return void
docblock comment on functions that don’t return anything, which is compliant with the WordPress documentation standards.
bootstrapFiles:
WP Fusion integrates with a lot of other plugins. We want PHPStan to understand the structure and methods of each class we’re working with.
A shotgun approach to this is to set the scanDirectories
directive to your web root or /wp-content/plugins/
folder— but this can cause PHPStan to take hours to perform a scan. So instead we use “stubs”.
Stubs are the minimal code required for PHPStan to analyze a class for its properties and methods. Stubs can be included in two ways:
Via Composer: You can see in our composer.json we have:
"php-stubs/woocommerce-stubs": "^9.1",
"php-stubs/wordpress-stubs": "^6.6",
These two packages are automatically detected and scanned by PHPStan. It allows PHPStan to understand all the available functions and classes in WordPress core and WooCommerce, without having to scan every file.
You can see what the stub file for WordPress core looks like on GitHub.
Our custom stubs.php
also includes stubs for plugins WP Fusion integrates with that don’t have their own independent packages.
When we encounter a PHPStan error (for example in the screenshot above showing “Unknown class MeprUser
“) I’ll point a Cursor agent to the class definition file, and ask it to generate stubs to add to our stubs.php. This file is version controlled so the whole team can contribute stubs as they work with new integrations.
excludePaths
: These paths are excluded from static analysis, since they are dependencies and aren’t developed by us. It speeds things up to not have PHPStan checking all the /node_modules/
or test files.
PHPCS configuration
The PHPCS extension for VS Code / Cursor will automatically read your composer.json file and load the wp-coding-standards/wpcs
package as a standard for linting and formatting on save.
However, we do some things that don’t fit the conventions exactly, and so it’s important for us to be able to override the defaults with our own custom rules. This is achieved by adding a phpcs.xml
file in the project root directory.
This file is automatically detected by both the phpcs and PHP Sniffer & Beautifier extensions, and it looks like this:
<?xml version="1.0"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="WP Fusion" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/PHPCSStandards/PHP_CodeSniffer/master/phpcs.xsd">
<description>A custom set of rules to check for a WPized WordPress project</description>
<file>.</file>
<!-- Exclude WP Core folders and files from being checked. -->
<exclude-pattern>/docroot/wp-admin/*</exclude-pattern>
<exclude-pattern>/docroot/wp-includes/*</exclude-pattern>
<exclude-pattern>/docroot/wp-*.php</exclude-pattern>
<exclude-pattern>/docroot/index.php</exclude-pattern>
<exclude-pattern>/docroot/xmlrpc.php</exclude-pattern>
<exclude-pattern>/docroot/wp-content/plugins/*</exclude-pattern>
<exclude-pattern>**.asset.php</exclude-pattern>
<exclude-pattern>**.css</exclude-pattern>
<!-- Exclude the Composer Vendor directory. -->
<exclude-pattern>**/vendor/*</exclude-pattern>
<!-- Exclude the Node Modules directory. -->
<exclude-pattern>**/node_modules/*</exclude-pattern>
<!-- Exclude the tests directory. -->
<exclude-pattern>**/tests/*</exclude-pattern>
<!-- Exclude minified Javascript files. -->
<exclude-pattern>*.min.js</exclude-pattern>
<exclude-pattern>*.js</exclude-pattern>
<!-- Check up to 8 files simultaneously. -->
<arg name="parallel" value="8"/>
<!-- Include the WordPress-Extra standard. -->
<rule ref="WordPress-Extra"/>
<!-- Let's also check that everything is properly documented. -->
<rule ref="WordPress-Docs"/>
<!-- Point out TODO comments. -->
<rule ref="Generic.Commenting.Todo.CommentFound">
<message>Please review this TODO comment: %s</message>
<severity>3</severity>
</rule>
<config name="minimum_wp_version" value="6.0"/>
<config name="testVersion" value="7.4-"/>
<rule ref="PHPCompatibilityWP">
<include-pattern>*\.php</include-pattern>
</rule>
<!-- Ignore invalid class file names. -->
<rule ref="WordPress">
<exclude name="WordPress.Files.FileName.InvalidClassFileName" />
</rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array">
<element value="wpf_"/>
<element value="WP_Fusion"/>
<element value="WPF_"/>
</property>
</properties>
</rule>
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="wp-fusion" />
</property>
</properties>
</rule>
</ruleset>
We started with the default configuration file provided by the WordPress coding standards package, and then made a few additions:
Generic.Commenting.Todo.CommentFound
: Flags an alert if any@todo
comments are left in the code- The
PHPCompatibilityWP
evaluates our code for PHP 7.4. We develop using 8.3, so this ensures we don’t accidentally use a feature that’s available in the newer version of PHP but would break with 7.4. InvalidClassFileName
: Technically all file names should match their class names, so the classWPF_WooCommerce
should be namedclass-wpf-woocommerce.php
. In WP Fusion, the files are named likeclass-woocommerce.php
. At the moment it would be too much work to change them, so we suppress this rule.PrefixAllGlobals
: This ensures that all our class names, function names, globals, action hooks, and option keys start with an allowed prefix. You can see this rule working in the screenshot below. If I create a filter calleduser_register_wp_fusion
the linter throws an error. The filter must start withwpf_
to pass.

WordPress.WP.I18n
: This ensures all translatable text uses thewp-fusion
textdomain.

For more information on the available options and custom rules, check out the WordPress-Coding-Standards package on GitHub.
In summary
This was a lot of work to put together 😅. I’ve tried probably 10 different VS Code extensions with varying success, and at one point ended up with so many conflicting levels of configuration files I had to reset everything and start over fresh.
But now that it’s working, we get a lot of benefits:
- Everyone is writing code using the same style, and is consistent in their documentation.
- Opening the project folder prompts the user to install the necessary extensions, initializes the project via Composer, and pre-configures the project for automatic linting.
- When someone submits a pull request with a fix or feature, we automatically run the same lints and tests via Github actions— if anything fails, it needs to be fixed before the PR can be merged.
- Using the baseline files, we’re only enforcing standards on new code, meaning we can work on old files and validate them without having to refactor them completely.
- We’re reducing the likelihood of typos or type mismatch related errors using PHPCS and PHPStan.
- Any work we do with AI agents automatically incorporates feedback from the linter and static analysis, making the AI less likely to hallucinate or introduce bugs.
This last point is especially helpful as we integrate AI into more of our workflows. You can see here how I use a Cursor agent to update our User Meta integration (from 2016) to meet the new code and documentation standards.
What would have taken fifteen minutes of work is now handled automatically by the agent in less than a minute. And having it work alongside the PHPStan and PHPCS results in a much higher quality output from a shorter prompt, vs having to explain exactly everything that needs to be fixed.

The only problem it introduced was changing the $userMeta
global into $user_meta
. This is a problem in the User Meta plugin itself so there’s nothing we can do about it. We can tell PHPCS to ignore it by adding a comment
// @phpcs:ignore WordPress.NamingConventions.ValidVariableName
above the offending line.
Thanks for reading! I hope you’ve found this useful.
I’d followed several tutorials while trying to piece these components together over the years, but each of them only covered one aspect, and they usually turned out to be incompatible with eachother.
If everything breaks again in a few weeks I’ll update the post with a warning 😉
– Jack