From dave-liddament
Create a custom PHPStan rule with tests and fixtures. Use when the user wants to create a new PHPStan rule for their project.
How this skill is triggered — by the user, by Claude, or both
Slash command
/dave-liddament:phpstan-custom-ruleThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Create a custom PHPStan rule based on the user's requirements. If no arguments are provided, ask the user what the rule should do.
Create a custom PHPStan rule based on the user's requirements. If no arguments are provided, ask the user what the rule should do.
IMPORTANT: This skill works on both new and existing projects. Before each step, check what already exists and only add what is missing.
Check if the custom PHPStan rule environment is already set up. If not, perform the following:
Create the following directories if they don't exist:
utils/phpstan/src/Rules/utils/phpstan/tests/Rules/Add dave-liddament/phpstan-rule-test-helper as a dev dependency if not already present:
composer require --dev dave-liddament/phpstan-rule-test-helper
Ensure composer.json has the following PSR-4 autoload-dev entries (merge with existing):
"autoload-dev": {
"psr-4": {
"Utils\\Phpstan\\": "utils/phpstan/src/",
"Utils\\Phpstan\\Tests\\": "utils/phpstan/tests/"
}
}
After adding, run composer dump-autoload.
Ensure phpunit.xml (or phpunit.xml.dist) contains a phpstan test suite:
<testsuite name="phpstan">
<directory>utils/phpstan/tests</directory>
</testsuite>
Update phpstan.neon to include the custom rule source and test directories in the analysed paths, and exclude fixture files:
Add to parameters.paths:
utils/phpstan/srcutils/phpstan/testsAdd to parameters.excludePaths:
utils/phpstan/tests/Rules/*/FixturesUpdate .php-cs-fixer.php to include the custom rule directories but exclude fixtures:
Add to the finder:
->in(__DIR__ . '/utils/phpstan/src')->in(__DIR__ . '/utils/phpstan/tests')->notPath('utils/phpstan/tests/Rules/*/Fixtures')Based on $ARGUMENTS or by asking the user, have a conversation to understand:
Then agree on a rule class name with the user (e.g. DisallowEchoRule). The class name should be a concise summary — it does not need to capture every nuance of what the rule does. The full behaviour is expressed in the implementation and tests, not the name. Either suggest a name for the user to approve, or let them propose one.
Create fixture files in utils/phpstan/tests/Rules/<RuleName>/Fixtures/.
Fixtures must cover:
// ERROR comments.// ERROR comments.It is critical to have good coverage of both. Think carefully about edge cases and code that looks similar to a violation but isn't.
Example fixture for a "disallow echo" rule:
<?php
echo "hello"; // ERROR
print "hello"; // This is not echo, should not be flagged
$result = 1 + 2; // Unrelated code, should not be flagged
IMPORTANT: Show the fixture files to the user and get confirmation before proceeding. Ask if they want to add or change any test cases.
Ask the user for the rule identifier. Explain that:
myProject.disallowEcho)Create the test class at utils/phpstan/tests/Rules/<RuleName>/<RuleName>Test.php.
The test class must:
Utils\Phpstan\Tests\Rules\<RuleName>DaveLiddament\PhpstanRuleTestHelper\AbstractRuleTestCasegetRule() returning an instance of the rulegetErrorFormatter() returning the error message template$this->assertIssuesReported()Example:
<?php
declare(strict_types=1);
namespace Utils\Phpstan\Tests\Rules\DisallowEchoRule;
use DaveLiddament\PhpstanRuleTestHelper\AbstractRuleTestCase;
use PHPStan\Rules\Rule;
use Utils\Phpstan\Rules\DisallowEchoRule;
/** @extends AbstractRuleTestCase<DisallowEchoRule> */
class DisallowEchoRuleTest extends AbstractRuleTestCase
{
protected function getRule(): Rule
{
return new DisallowEchoRule();
}
protected function getErrorFormatter(): string
{
return 'Echo statements are not allowed.';
}
public function testFixtures(): void
{
$this->assertIssuesReported(__DIR__ . '/Fixtures/echo.php');
}
}
Create the rule class at utils/phpstan/src/Rules/<RuleName>.php.
The rule class must:
Utils\Phpstan\RulesPHPStan\Rules\Rule@implements Rule<Node\...> generic annotationgetNodeType() returning the appropriate node classprocessNode() with the rule logicRuleErrorBuilder::message()->identifier()->build() to create errorsExample:
<?php
declare(strict_types=1);
namespace Utils\Phpstan\Rules;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/** @implements Rule<Node\Stmt\Echo_> */
class DisallowEchoRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\Echo_::class;
}
/** @return list<\PHPStan\Rules\RuleError> */
public function processNode(Node $node, Scope $scope): array
{
return [
RuleErrorBuilder::message('Echo statements are not allowed.')
->identifier('myProject.disallowEcho')
->build(),
];
}
}
Add the rule as a service in phpstan.neon:
services:
-
class: Utils\Phpstan\Rules\DisallowEchoRule
tags:
- phpstan.rules.rule
Run the PHPStan test suite to verify the rule works:
./vendor/bin/phpunit --testsuite phpstan
If tests fail, investigate and fix the rule implementation.
Reference for dave-liddament/phpstan-rule-test-helper. Use this when creating tests for custom PHPStan rules.
Tests extend DaveLiddament\PhpstanRuleTestHelper\AbstractRuleTestCase which extends PHPStan's RuleTestCase. It removes the need to manually track line numbers in tests.
Mark lines in fixture files with // ERROR to indicate where the rule should report an error. Lines without // ERROR must not trigger errors.
When all errors have the same message, define it once via getErrorFormatter():
protected function getErrorFormatter(): string
{
return "Echo statements are not allowed.";
}
Fixture:
echo "hello"; // ERROR
print "hello"; // This should not be flagged
Use pipe-separated (|) values after // ERROR to substitute into {0}, {1}, etc. placeholders in the error message:
protected function getErrorFormatter(): string
{
return "Can not call {0} from within class {1}";
}
Fixture:
class SomeCode
{
public function go(): void
{
$item = new Item("hello");
$item->updateName("world"); // ERROR Item::updateName|SomeCode
}
public function go2(): void
{
$item = new Item("hello");
$item->remove(); // ERROR Item::remove|SomeCode
}
}
This produces:
Instead of using getErrorFormatter(), you can put the complete expected error message after // ERROR:
$item->updateName("world"); // ERROR Can not call method
For complex formatting logic, return an ErrorMessageFormatter from getErrorFormatter():
use DaveLiddament\PhpstanRuleTestHelper\ErrorMessageFormatter;
protected function getErrorFormatter(): ErrorMessageFormatter
{
return new class() extends ErrorMessageFormatter {
public function getErrorMessage(string $errorContext): string
{
$parts = $this->getErrorMessageAsParts($errorContext);
$calledFrom = count($parts) === 2
? 'class ' . $parts[1]
: 'outside an object';
return sprintf('Can not call %s from %s', $parts[0], $calledFrom);
}
};
}
Fixture:
class SomeCode
{
public function go(): void
{
$item->updateName("world"); // ERROR Item::updateName|SomeCode
}
}
$item->remove(); // ERROR Item::remove
Produces:
assertIssuesReported() call per fixture file// ERROR are expected to trigger the rule// ERROR must NOT trigger the ruleReflectionProvider, use $this->createReflectionProvider() in getRule()npx claudepluginhub daveliddament/php-claude-skills --plugin dave-liddamentGuides PHPStan error resolution prioritizing refactoring over phpDoc and ignoring, with Nette patterns, baseline management, and type tests. Use before running PHPStan or fixing errors.
Generates PHPStan configs (phpstan.neon, baselines) for PHP 8.4+ projects with levels, paths, strict rules, and DDD/Doctrine support. For new, legacy, or domain-driven apps.
Generates Shopware-compliant PHPUnit unit tests for PHP classes in tests/unit/, skipping coverage-excluded or trivial ones, validating with PHPStan, ECS, and PHPUnit.