shipmonk/phpstan-rules
40 super-strict PHPStan rules from ShipMonk to close gaps even in extra-strict setups. Installs as an extension, configurable per rule (enable/disable, tweak defaults), with options like safer comparisons, enum generics checks, and more.
About 40 super-strict rules we found useful in ShipMonk. We tend to have PHPStan set up as strict as possible, but that still was not strict enough for us. This set of rules should fill the missing gaps we found.
If you find some rules opinionated, you can easily disable them.
composer require --dev shipmonk/phpstan-rules
Use official extension-installer or enable all rules manually by:
# phpstan.neon
includes:
- vendor/shipmonk/phpstan-rules/rules.neon
You can easily disable or reconfigure any rule, for example:
parameters:
shipmonkRules:
enforceReadonlyPublicProperty:
enabled: false
forbidUnsafeArrayKey:
reportMixed: false
Or you can disable all rules and enable only those you want:
parameters:
shipmonkRules:
enableAllRules: false
allowComparingOnlyComparableTypes:
enabled: true
When you try to configure any default array, PHPStan config is merged by default, so if you want to enforce only your values and not to include our defaults, use exclamation mark:
parameters:
shipmonkRules:
forbidCast:
enabled: true
blacklist!: ['(unset)'] # force the blacklist to be only (unset)
Few rules are enabled, but do nothing unless configured, those are marked with *.
>,<,<=,>=,<=> over anything other than int|string|float|DateTimeInterface or same size tuples containing comparable types. Null is not allowed.function example1(Money $fee1, Money $fee2) {
if ($fee1 > $fee2) {} // comparing objects is denied
}
new DateTime() > '2040-01-02'; // comparing different types is denied
200 > '1e2'; // comparing different types is denied
parameters:
stubFiles:
- BackedEnum.php.stub # see article or BackedEnumGenericsRuleTest
ignoreErrors:
- '#^Enum .*? has @implements tag, but does not implement any interface.$#'
enum MyEnum: string { // missing @implements tag
case MyCase = 'case1';
}
superclass => suffix mapping shipmonkRules:
classSuffixNaming:
superclassToSuffixMapping!:
\Exception: Exception
\PHPStan\Rules\Rule: Rule
\PHPUnit\Framework\TestCase: Test
\Symfony\Component\Console\Command\Command: Command
mixed is not available thereallowMissingTypeWhenInferred: true to allow missing typehint when it can be inferred from the context/**
* @param list<Entity> $entities
* @return list<Uuid>
*/
public function getIds(array $entities): array {
return array_map(
function ($entity) { // missing native typehint; not reported with allowMissingTypeWhenInferred: true
return $entity->id;
},
$entities
);
}
match ($enum) instead of exhaustive conditions like if ($enum === Enum::One) elseif ($enum === Enum::Two)enum MyEnum {
case Foo;
case Bar;
}
if ($enum === MyEnum::Foo) {
// ...
} elseif ($enum === MyEnum::Bar) { // always true reported by phpstan (for versions 1.10.0 - 1.10.34)
// ...
} else {
throw new LogicException('Unknown case'); // phpstan knows it cannot happen
}
Which someone might fix as:
if ($enum === MyEnum::Foo) {
// ...
} elseif ($enum === MyEnum::Bar) {
// ...
}
Or even worse as:
if ($enum === MyEnum::Foo) {
// ...
} else {
// ...
}
We believe that this leads to more error-prone code since adding new enum case may not fail in tests.
Very good approach within similar cases is to use match construct so that (ideally with forbidMatchDefaultArmForEnums enabled) phpstan fails once new case is added.
PHPStan even adds tip about match in those cases since 1.10.11.
For those reasons, this rule detects any always-true/false enum comparisons and forces you to rewrite it to match ($enum).
Since PHPStan 1.10.34, the behaviour is much better as it does not report error on the last elseif in case that it is followed by else with thrown exception. Such case raises exception in your tests if you add new enum case, but it is still silent in PHPStan. This leaves space for error being deployed to production. So we still believe this rule makes sense even in latest PHPStan.
$preserve_keys) as the default value true is generally dangerous (risk of data loss / failure)true and false there, but doing so is intentional choice now$fn = function () {
yield new stdClass => 1;
};
iterator_to_array($fn()); // denied, would fail
list<T> when list is always returned from a class method or function/**
* @return array<string>
*/
public function returnList(): array // error, return phpdoc is generic array, but list is always returned
{
return ['item'];
}
treatPhpDocTypesAsCertain: falseclass NoNativeReturnTypehint {
/**
* @return list<string>
*/
public function returnList() // error, missing array typehint
{
return ['item'];
}
}
readonly modifierclass EnforceReadonlyPublicPropertyRule {
public int $foo; // fails, no readonly modifier
public readonly int $bar;
}
parameters:
shipmonkRules:
enforceReadonlyPublicProperty:
excludePropertyWithDefaultValue: true # defaults to false
float, int and BcMath\Number is allowed)allowNumericString: true configuration%) allows only integers as it emits deprecation otherwisefloat and BcMath\Number cannot be combined as it emits deprecationsfunction add(string $a, string $b) {
return $a + $b; // denied, non-numeric types are allowed
}
(array) - denied by default(object) - denied by default(unset) - denied by default(bool)(int)(string)(float) - forbids using also (double) and (real)$empty = (array) null; // denied cast
$notEmpty = (array) 0; // denied cast
parameters:
shipmonkRules:
forbidCast:
blacklist!: ['(array)', '(object)', '(unset)']
@param-immediately-invoked-callable, see docs)parameters:
shipmonkRules:
forbidCheckedExceptionInCallable:
allowedCheckedExceptionCallables:
'Symfony\Component\Console\Question::setValidator': 0 # symfony automatically converts all thrown exceptions to error output, so it is safe to throw anything here
parameters:
exceptions:
check:
missingCheckedExceptionInThrows: true # enforce checked exceptions to be stated in @throws
tooWideThrowType: true # report invalid @throws (exceptions that are not actually thrown in annotated method)
throwTypeCovariance: true # enable LSP checks
implicitThrows: false # no @throws means nothing is thrown (otherwise Throwable is thrown)
checkedExceptionClasses:
- YourApp\TopLevelRuntimeException # track only your exceptions (children of some, typically RuntimeException)
class TransactionManager {
/**
* @param-immediately-invoked-callable $callback
*/
public function transactional(callable $callback): void {
// ...
$callback();
// ...
}
}
class UserEditFacade
{
/**
* @throws UserNotFoundException
*/
public function updateUserEmail(UserId $userId, Email $email): void
{
$this->transactionManager->transactional(function () use ($userId, $email) {
$user = $this->userRepository->get($userId); // can throw checked UserNotFoundException
$user->updateEmail($email);
})
}
public function getUpdateEmailCallback(UserId $userId, Email $email): callable
{
return function () use ($userId, $email) {
$user = $this->userRepository->get($userId); // this usage is denied, it throws checked exception, but you don't know when, thus it cannot be tracked by phpstan
$user->updateEmail($email);
};
}
}
class Provider {
/** @throws CheckedException */
public static function generate(): iterable
{
yield 1;
throw new CheckedException(); // denied, gets thrown once iterated
}
}
$fn = 'sleep'; $fn();parameters:
shipmonkRules:
forbidCustomFunctions:
list:
'Namespace\SomeClass::*': 'Please use different class' # deny all methods by using * (including constructor)
'Namespace\AnotherClass::someMethod': 'Please use anotherMethod' # deny single method
'var_dump': 'Please remove debug code' # deny function
new SomeClass(); // Class SomeClass is forbidden. Please use different class
(new AnotherClass())->someMethod(); // Method AnotherClass::someMethod() is forbidden. Please use anotherMethod
enum MyEnum: string {
case MyCase = 'case1';
}
implode('', [MyEnum::MyCase]); // denied, would fail on implicit toString conversion
forbidMethodCallOnMixedfunction example($unknown) {
$unknown->property; // cannot fetch property on mixed
}
=== or !==DateTimeInterfacefunction isEqual(DateTimeImmutable $a, DateTimeImmutable $b): bool {
return $a === $b; // comparing denied classes
}
parameters:
shipmonkRules:
forbidIdenticalClassComparison:
blacklist!:
- DateTimeInterface
- Brick\Money\MoneyContainer
- Brick\Math\BigNumber
- Ramsey\Uuid\UuidInterface
$i++, $i--, ++$i, --$i with any non-integer$value = '2e0';
$value++; // would be float(3), denied
match() construct when native enum is passed as subjectmatch ($enum) {
MyEnum::Case: 1;
default: 2; // default arm forbidden
}
forbidFetchOnMixedfunction example($unknown) {
$unknown->call(); // cannot call method on mixed
}
checkDisjunctiveNormalForm@param phpdoc@return phpdoc@var phpdoc@var phpdoc@throws phpdoc/**
* @return mixed|false // denied, this is still just mixed
*/
public function getAttribute(string $name)
{
return $this->attributes[$name] ?? false;
}
??= is excludedfunction getCost(int $cost, ?int $surcharge): int {
$cost += $surcharge; // denied, adding possibly-null value
return $cost;
}
===, !==, ??allowComparingOnlyComparableTypes is enabledparameters:
shipmonkRules:
forbidNullInBinaryOperations:
blacklist!: [
'**', '!=', '==', '+', 'and', 'or', '&&', '||', '%', '-', '/', '*', # checked by phpstan-strict-rules
'>', '>=', '<', '<=', '<=>', # checked by AllowComparingOnlyComparableTypesRule
'===', '!==', '??' # valid with null involved
]
function getFullName(?string $firstName, string $lastName): string {
return $firstName . ' ' . $lastName; // denied, null involved in binary operation
}
.) in forbidNullInBinaryOperations so if you blacklisted it there, you might want to disable this rulepublic function output(?string $name) {
echo "Hello $name!"; // denied, possibly null value
}
@return and @param over methods and @var over properties/**
* @param string $param
*/
public function sayHello(?string $param) {} // invalid phpdoc not containing null
enum MyEnum {
protected function isOpen(): bool {} // protected enum method denied
}
class Get {
public static function oneTwoThree(): iterable { // marked as iterable, caller cannot access the return value by Generator::getReturn
yield 1;
yield 2;
return 3;
}
}
iterator_to_array(Get::oneTwoThree()); // [1, 2] - see https://3v4l.org/Leu9j
parameters:
shipmonkRules:
forbidReturnValueInYieldingMethod:
reportRegardlessOfReturnType: true # optional stricter mode, defaults to false
null, float and bool to some nearest int/string
mixed keys via reportMixed configurationisset($array[$invalid]) and $array[$invalid] ?? null via reportInsideIsset configuration$taxRates = [ // denied, float key gets casted to int (causing $taxRates to contain one item)
1.15 => 'reduced',
1.21 => 'standard',
];
parameters:
shipmonkRules:
forbidUnsafeArrayKey:
reportMixed: false # defaults to true
reportInsideIsset: false # defaults to true
$array[] = 1; not yet supportedfunction example(OrderId $id) {
$id = $id->getStringValue(); // denied, type changed from object to string
}
unset over class field as it causes un-initialization, see https://3v4l.org/V8uuPfunction example(MyClass $class) {
unset($class->field); // denied
}
uselessPrivatePropertyDefaultValuepublic function example(int $foo): ?int { // null never returned
if ($foo < 0) {
return 0;
}
return $foo;
}
function validate(): void {
new Exception(); // forgotten throw
}
match with at least one arm returning a value is checkedmatch ($foo) { // unused match result
case 'foo' => 1;
}
fn (int $key, Item $item, string $unused) => $item->ok(); // unused parameter $unused is reported, $key not
throw node inside the catch blocktry {
// some code
} catch (RuntimeException $e) {
throw new LogicException('Cannot happen'); // $e not passed as previous
}
reportEvenIfExceptionIsNotAcceptableByRethrownOne to true and the rule will start reporting even cases where the thrown exception does not have parameter matching the caught exception
parameters:
shipmonkRules:
requirePreviousExceptionPass:
reportEvenIfExceptionIsNotAcceptableByRethrownOne: true
class MyException extends RuntimeException {
public function __construct() {
parent::__construct('My error');
}
}
try {
// some code
} catch (RuntimeException $e) {
throw new MyException(); // reported even though MyException cannot accept it yet
}
forbidUselessNullableReturnclass Example
{
private ?int $field = null; // useless default value
public function __construct()
{
$this->field = 1;
}
}
Some strict behaviour in PHPStan is not enabled by default, consider enabling extra strictness even there:
includes:
- phar://phpstan.phar/conf/config.levelmax.neon
- phar://phpstan.phar/conf/bleedingEdge.neon # https://phpstan.org/blog/what-is-bleeding-edge
- vendor/phpstan/phpstan-strict-rules/rules.neon # https://github.com/phpstan/phpstan-strict-rules
parameters:
checkImplicitMixed: true # https://phpstan.org/config-reference#checkimplicitmixed
checkBenevolentUnionTypes: true # https://phpstan.org/config-reference#checkbenevolentuniontypes
checkUninitializedProperties: true # https://phpstan.org/config-reference#checkuninitializedproperties
checkMissingCallableSignature: true # https://phpstan.org/config-reference#vague-typehints
checkTooWideReturnTypesInProtectedAndPublicMethods: true # https://phpstan.org/config-reference#checktoowidereturntypesinprotectedandpublicmethods
reportAnyTypeWideningInVarTag: true # https://phpstan.org/config-reference#reportanytypewideninginvartag
reportPossiblyNonexistentConstantArrayOffset: true # https://phpstan.org/config-reference#reportpossiblynonexistentconstantarrayoffset
reportPossiblyNonexistentGeneralArrayOffset: true # https://phpstan.org/config-reference#reportpossiblynonexistentgeneralarrayoffset
composer checkcomposer fix:csHow can I help you explore Laravel packages today?