Wp phpstan stubs
WordPress plugin development skills for AI coding agents — Claude Code, Gemini CLI, Cursor, Windsurf, Cline, Codex, Copilot, opencode, and more
npx -y skills add mralaminahamed/wp-dev-skills --skill wp-phpstan-stubsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
What its author says it does
Copied from the file, not written here
Use when creating a new PHPStan stubs package for a WordPress plugin, theme, or Composer package — scaffolding the full repo structure (composer.json, configs/bootstrap.php, configs/finder.php, bin/generate.sh, bin/release-latest-versions.sh, .github/workflows/release.yml, phpstan.neon), fetching version lists from WP.org plugin API (api.wordpress.org/plugins) or Packagist API, using php-stubs/generator, or setting up the GitHub release workflow with jq. Triggers: "create stubs for X plugin", "scaffold PHPStan stubs", "new stubs package", "add PHPStan stubs for WooCommerce", "stubs repo setup", "generate stub file", "PHPStan cannot find this class", "missing type hints for third-party plugin", "add stubs for this Composer package", "php-stubs/generator", "bin/generate.sh", "configs/bootstrap.php for stubs", "configs/finder.php", "release-latest-versions.sh", "WP.org plugin API for versions", "Packagist API for versions", "jq regex error in stubs script", "GitHub Actions release workflow for stubs", "phpstan.neon for stubs package". Not for: configuring phpstan.neon in a plugin project or generating baselines — use the official `wp-phpstan` skill.
SKILL.md
18.1 KB, as published. Nobody here has run it
PHPStan Stubs Scaffold
Model note: File scaffolding from a fixed template —
haikuhandles end-to-end. Only reach forsonnetif the source plugin has complex namespace hierarchies needing stub organization decisions.
Scaffold a complete PHPStan stubs package from scratch, following the my-org/phpstan-freemius-stubs standard structure.
When to use
- "Create stubs for X", "new stubs package", "scaffold phpstan stubs", "add stubs for plugin/composer package".
Not for: Configuring phpstan.neon or generating baselines — use the official wp-phpstan skill. Adding PHPStan to a plugin that already has stubs installed.
References
references/wp-org-api.md— WP.org plugin API, version listing, download URLs, cleanup patternsreferences/packagist-api.md— Packagist API, version filtering, source/composer.json update patternreferences/common-errors.md— Known errors and fixes (jq\d, unzip prompt, find+set-e, git reset, missing source/composer.json)references/github-setup.md— Repo creation, branch rename trunk→main, topics, secrets, all 11 current repos
Gather Required Info
Before writing any files, collect (ask user if missing):
- Plugin/package name — human-readable (e.g. "SureCart", "Action Scheduler")
- Source type — one of:
wp-plugin— downloadable from WordPress.org (slug known)composer— Composer package on Packagist (e.g.woocommerce/action-scheduler)paid— paid plugin, user places source manually
- WP.org slug (if
wp-plugin) — e.g.surecart,forminator - Packagist package (if
composer) — e.g.woocommerce/action-scheduler - Source dir path inside package — where the plugin/package files land:
wp-plugin:source/<slug>/composer:source/vendor/<vendor>/<package>/paid:source/<slug>/
- Packagist name —
my-org/phpstan-<slug>-stubs - GitHub repo name —
phpstan-<slug>-stubs - Versions to release — for
wp-plugin/composer: minor version series (e.g.3.4 3.5 3.6); forpaid: manual - GitHub assignee — default
my-org
Standard Directory Layout
phpstan-<slug>-stubs/
├── bin/
│ ├── generate.sh
│ └── release-latest-versions.sh # (not for paid)
├── configs/
│ ├── bootstrap.php
│ └── finder.php
├── source/
│ ├── composer.json # only if wp-plugin or paid (no composer deps)
│ └── .gitignore
├── .github/
│ └── workflows/
│ └── release.yml # (not for paid)
├── .editorconfig
├── .gitattributes
├── .gitignore
├── composer.json
├── phpstan.neon
└── <slug>-stubs.php # empty placeholder (generated)
<slug>-constants-stubs.php # empty placeholder (generated)
File Contents
composer.json
{
"name": "my-org/phpstan-<slug>-stubs",
"description": "<PluginName> function and class declaration stubs for static analysis.",
"type": "library",
"keywords": [
"<slug>",
"wordpress",
"static analysis",
"phpstan",
"stubs"
],
"homepage": "https://github.com/my-org/phpstan-<slug>-stubs",
"license": "MIT",
"authors": [
{
"name": "Al Amin Ahamed",
"homepage": "https://github.com/my-org"
}
],
"require": {
"php": ">=7.4",
"php-stubs/wordpress-stubs": "^5.3 || ^6.0"
},
"require-dev": {
"php-stubs/generator": "^0.8.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
"squizlabs/php_codesniffer": "^3.7"
},
"minimum-stability": "stable",
"prefer-stable": true,
"config": {
"allow-plugins": {
"php-stubs/generator": true
},
"sort-packages": true,
"optimize-autoloader": true,
"preferred-install": "dist",
"platform": {
"php": "7.4.0"
}
},
"scripts": {
"post-install-cmd": [
"@composer --working-dir=source/ update --no-interaction"
],
"post-update-cmd": [
"@composer --working-dir=source/ update --no-interaction"
],
"generate": "bash bin/generate.sh",
"release": "bash bin/release-latest-versions.sh"
},
"support": {
"issues": "https://github.com/my-org/phpstan-<slug>-stubs/issues",
"source": "https://github.com/my-org/phpstan-<slug>-stubs"
}
}
For composer source type — source/ has its own composer.json, so omit post-install-cmd/post-update-cmd from root composer.json and add them pointing to the source subdir instead. The source/composer.json requires the actual package:
{
"require": {
"php": ">=5.6",
"<vendor>/<package>": "<latest-stable-version>"
},
"minimum-stability": "stable"
}
For wp-plugin and paid — create source/composer.json:
{"minimum-stability": "stable"}
And source/.gitignore:
/vendor/
/composer.lock
configs/bootstrap.php
Standard WordPress constants file — copy verbatim from freemius, then append plugin-specific constants at bottom if needed:
<?php
declare(strict_types=1);
// phpcs:disable Squiz.PHP.DiscouragedFunctions,NeutronStandard.Constants.DisallowDefine
define('ABSPATH', './');
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true);
define('WP_PLUGIN_DIR', './');
define('WPMU_PLUGIN_DIR', './');
define('EMPTY_TRASH_DAYS', 30 * 86400);
define('SCRIPT_DEBUG', false);
define('WP_LANG_DIR', './');
define('WP_CONTENT_DIR', './');
define('MINUTE_IN_SECONDS', 60);
define('HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS);
define('DAY_IN_SECONDS', 24 * HOUR_IN_SECONDS);
define('WEEK_IN_SECONDS', 7 * DAY_IN_SECONDS);
define('MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS);
define('YEAR_IN_SECONDS', 365 * DAY_IN_SECONDS);
define('KB_IN_BYTES', 1024);
define('MB_IN_BYTES', 1024 * KB_IN_BYTES);
define('GB_IN_BYTES', 1024 * MB_IN_BYTES);
define('TB_IN_BYTES', 1024 * GB_IN_BYTES);
define('OBJECT', 'OBJECT');
define('OBJECT_K', 'OBJECT_K');
define('ARRAY_A', 'ARRAY_A');
define('ARRAY_N', 'ARRAY_N');
define('FS_CONNECT_TIMEOUT', 30);
define('FS_TIMEOUT', 30);
define('FS_CHMOD_DIR', 0755);
define('FS_CHMOD_FILE', 0644);
configs/finder.php
<?php
use StubsGenerator\Finder;
return Finder::create()
->in(array(
'<source-dir-path>',
))
->sortByName(true)
;
Replace <source-dir-path> with the actual path (relative to project root):
wp-plugin:source/<slug>composer:source/vendor/<vendor>/<package>paid:source/<slug>
Add ->notPath(...) calls to exclude test dirs, docs, or large asset dirs that bloat the stubs.
bin/generate.sh
#!/usr/bin/env bash
#
# Generate <PluginName> stubs from the source directory.
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
HEADER=$'/**\n * Generated stub declarations for <PluginName>.\n * @see <homepage-url>\n * @see https://github.com/my-org/phpstan-<slug>-stubs\n */'
FILE="$ROOT_DIR/<slug>-stubs.php"
FILE_CONSTANTS="$ROOT_DIR/<slug>-constants-stubs.php"
GENERATOR_BIN="$ROOT_DIR/vendor/bin/generate-stubs"
FINDER_FILE="$ROOT_DIR/configs/finder.php"
set -e
test -f "$FILE" || touch "$FILE"
test -f "$FILE_CONSTANTS" || touch "$FILE_CONSTANTS"
test -d "$ROOT_DIR/<source-dir-path>"
"$GENERATOR_BIN" \
--include-inaccessible-class-nodes \
--force \
--finder="$FINDER_FILE" \
--header="$HEADER" \
--functions \
--classes \
--interfaces \
--traits \
--out="$FILE"
"$GENERATOR_BIN" \
--include-inaccessible-class-nodes \
--force \
--finder="$FINDER_FILE" \
--header="$HEADER" \
--constants \
--out="$FILE_CONSTANTS"
bin/release-latest-versions.sh — WP.org plugin
Iterates minor version series (e.g. 2.0 2.1 ... 2.20), finds latest patch, skips if tag exists, downloads, unzips, generates, commits, tags:
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
set -e
git -C "$ROOT_DIR" fetch --all
git -C "$ROOT_DIR" reset --hard origin/main
WP_JSON="$(wget -q -O- "https://api.wordpress.org/plugins/info/1.0/<slug>.json")"
for V in <minor-versions>; do
printf -v JQ_FILTER '."versions" | keys[] | select(test("^%s\\.%s\\.[0-9]+$"))' "${V%.*}" "${V#*.}"
LATEST="$(jq -r "$JQ_FILTER" <<<"$WP_JSON" | sort -t "." -k 3 -g | tail -n 1)"
if [ -z "$LATEST" ]; then continue; fi
if git -C "$ROOT_DIR" rev-parse "refs/tags/v${LATEST}" >/dev/null 2>&1; then
echo "Tag exists for v${LATEST}, skipping..."
continue
fi
rm -rf "$ROOT_DIR/source/<slug>" 2>/dev/null || true
rm -f "$ROOT_DIR/source/<slug>."*.zip 2>/dev/null || true
wget -q -P "$ROOT_DIR/source/" "https://downloads.wordpress.org/plugin/<slug>.${LATEST}.zip"
unzip -q -o -d "$ROOT_DIR/source/" "$ROOT_DIR/source/<slug>.${LATEST}.zip"
rm -f "$ROOT_DIR/source/<slug>.${LATEST}.zip"
echo "Generating stubs for <PluginName> ${LATEST}..."
"$SCRIPT_DIR/generate.sh"
if git -C "$ROOT_DIR" diff-index --quiet HEAD --; then
echo "No changes for ${LATEST}, skipping commit..."
else
git -C "$ROOT_DIR" commit --all -m "Generate stubs for <PluginName> ${LATEST}"
git -C "$ROOT_DIR" tag "v${LATEST}"
fi
done
git -C "$ROOT_DIR" push origin main --follow-tags
echo "Done."
Minor version list format: space-separated MAJOR.MINOR values, e.g. 1.0 1.1 1.2 ... 1.20 2.0 2.1. The jq filter selects all patch versions matching ^MAJOR.MINOR.[0-9]+$ then takes the latest.
CRITICAL jq regex note: In jq string literals, use [0-9] not \d. The printf -v JQ_FILTER approach above uses \\.[0-9]+ which renders correctly.
bin/release-latest-versions.sh — Composer package
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
set -e
git -C "$ROOT_DIR" fetch --all
git -C "$ROOT_DIR" reset --hard origin/main
PACKAGIST_JSON="$(wget -q -O- "https://packagist.org/packages/<vendor>/<package>.json")"
VERSIONS=(<minor-versions>)
for V in "${VERSIONS[@]}"; do
printf -v JQ_FILTER '."package"."versions" | keys[] | select(test("^%s\\.%s\\.[0-9]+$"))' "${V%.*}" "${V#*.}"
LATEST="$(jq -r "$JQ_FILTER" <<<"$PACKAGIST_JSON" | sort -t "." -k 3 -g | tail -n 1)"
if [ -z "$LATEST" ]; then continue; fi
if git -C "$ROOT_DIR" rev-parse "refs/tags/v${LATEST}" >/dev/null 2>&1; then
echo "Tag exists for v${LATEST}, skipping..."
continue
fi
printf -v SED_EXP 's#\("<vendor>/<package>"\): "[^"]*"#\1: "%s"#' "${LATEST}"
sed -i -e "$SED_EXP" "$ROOT_DIR/source/composer.json"
composer --working-dir="$ROOT_DIR/source" update --no-interaction
"$SCRIPT_DIR/generate.sh"
if git -C "$ROOT_DIR" diff-index --quiet HEAD --; then
echo "No changes for ${LATEST}, skipping commit..."
else
git -C "$ROOT_DIR" commit --all -m "Generate stubs for <PluginName> ${LATEST}"
git -C "$ROOT_DIR" tag "v${LATEST}"
fi
done
git -C "$ROOT_DIR" push origin main --follow-tags
echo "Done."
bin/release-latest-versions.sh — Paid plugin
Accept VERSION as first argument, source must be pre-placed at source/<slug>/:
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
set -e
VERSION="${1:?Usage: $0 <version>}"
if git -C "$ROOT_DIR" rev-parse "refs/tags/v${VERSION}" >/dev/null 2>&1; then
echo "Tag v${VERSION} already exists. Skipping."
exit 0
fi
if [ ! -d "$ROOT_DIR/source/<slug>" ]; then
echo "ERROR: Place <PluginName> ${VERSION} source at source/<slug>/ first."
exit 1
fi
"$SCRIPT_DIR/generate.sh"
git -C "$ROOT_DIR" commit --all -m "Generate stubs for <PluginName> ${VERSION}"
git -C "$ROOT_DIR" tag "v${VERSION}"
git -C "$ROOT_DIR" push origin main --follow-tags
echo "Done."
.github/workflows/release.yml
name: Release new version
on:
push:
paths:
- ".github/workflows/release.yml"
schedule:
- cron: '0 * * * *'
jobs:
release-new-stubs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
extensions: json zip xdebug
coverage: none
tools: composer
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: "${{ runner.os }}-${{ hashFiles('**/composer.lock') }}"
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-suggest --no-progress --no-interaction --ignore-platform-reqs
- name: Set up Git user
run: |
git config user.name 'github-actions'
git config user.email '[email protected]'
- name: Release new stubs from latest version
run: bash bin/release-latest-versions.sh
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.PERSONAL_TOKEN }}
commit-message: Generate stubs from latest version
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com>
signoff: false
delete-branch: true
title: 'Generate stubs from latest version'
body: |
This PR updates stubs from the latest available version.
labels: |
update
automated pr
assignees: my-org
draft: false
Omit this workflow entirely for paid source type.
phpstan.neon
parameters:
paths:
- <slug>-stubs.php
scanFiles:
- <slug>-constants-stubs.php
bootstrapFiles:
- configs/bootstrap.php
level: 5
ignoreErrors:
- '#but return statement is missing\.$#'
- '#has an unused parameter#'
- '#^(Property|Static property|Method|Static method) \S+ is unused\.$#'
- '#is never read, only written\.$#'
- '#has invalid (return )?type (WP_Error|WP_Customize_Manager|WP_Theme|WP_User|WP_Site|WP_Upgrader)#'
.gitignore
/vendor/
/composer.lock
/report.txt
.gitattributes
/.gitattributes export-ignore
/.gitignore export-ignore
/.travis.yml export-ignore
/source export-ignore
.editorconfig
root = true
[*]
charset = utf-8
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.yml]
indent_size = 2
Execution Steps
After writing all files:
-
Init git and make first commit:
cd /path/to/phpstan-<slug>-stubs git init git branch -m trunk main git add . git commit -m "chore: initial scaffold for <PluginName> stubs" -
Create GitHub repo (public):
gh repo create my-org/phpstan-<slug>-stubs \ --public \ --description "<PluginName> function and class declaration stubs for static analysis." \ --source=. \ --remote=origin \ --pushThen ensure default branch is
main:gh repo edit my-org/phpstan-<slug>-stubs --default-branch main -
Add GitHub topics:
gh repo edit my-org/phpstan-<slug>-stubs \ --add-topic phpstan \ --add-topic php \ --add-topic stubs \ --add-topic wordpress \ --add-topic <slug> -
Install local dependencies:
composer install --ignore-platform-reqs -
Run first release (if not
paid):bash bin/release-latest-versions.shFor
paid: instruct user to place source atsource/<slug>/then runbash bin/release-latest-versions.sh <VERSION>.
Common Mistakes to Avoid
| Mistake | Correct |
|---|---|
\d in jq string literal | Use [0-9] |
git reset --hard origin/main before committing fix | Always push fixes before re-running release |
unzip without -o flag | Always use unzip -q -o to avoid interactive prompts |
find -exec rm -rf {} + with set -e | Use `rm -rf source/<slug> 2>/dev/null |
gh repo create defaults to trunk branch | Always rename: git branch -m trunk main then gh repo edit --default-branch main |
Packagist API: repo.packagist.org/p2/ | Correct URL: packagist.org/packages/<vendor>/<package>.json |
Packagist JSON path: packages.x[] | Correct path: ."package"."versions" |
Missing source/composer.json for wp-plugin | Creates post-install-cmd failure; always create source/composer.json |