PHPStan Rules
The framework ships a strict static-analysis setup: PHPStan at the maximum level, the strict-rules extension, and a set of custom rules that enforce the framework's own conventions.
The setup
Analysis is configured in phpstan.neon — level 10 (the maximum), scanning
src, with several extra checks turned on and the strict-rules extension included:
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- phpstan-rules.neon
parameters:
phpVersion: 80300
level: 10
paths:
- srcRun it from the project root:
./vendor/bin/phpstan analyse
phpstan-rules.neon, registered as a PHPStan extension
through the phpstan entry in composer.json — so an app that requires the Framework
picks them up automatically.Structural rules
These always run — they are registered in phpstan-rules.neon and check that the attributes the
build relies on are used correctly, so a mistake is caught by analysis instead of at
generation time:
Models
ModelAttribute — every model property carries an attribute.
use Framework\Database\Model\Model;
use Framework\Database\Model\Field;
#[Model]
class ProductModel {
#[Field(isID: true)] public int $id;
#[Field] public string $name; // ✓ has an attribute
public string $slug; // ✗ no attribute
}
ModelUniqueField — a model has at most one isID field.
#[Model]
class ProductModel {
#[Field(isID: true)] public int $id;
#[Field(isID: true)] public int $code; // ✗ a second isID field
}
ModelEnum — an enum used in a model implements Enum and JsonSerializable.
use Framework\Enum\Enum;
use Framework\Enum\IsEnum;
use JsonSerializable;
enum Status { // ✗ a bare enum used as a model field type
case Active;
}
enum Status implements Enum, JsonSerializable { // ✓
use IsEnum;
case None;
case Active;
}
QueryArgument — a query's as alias is passed as a named argument.
$query = new ProductQuery();
$query->request($subRequest, "extra"); // ✗ positional `as`
$query->request($subRequest, as: "extra"); // ✓ named argument
Routes
RouteMethodName — a #[Route] method's name matches its route.
use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\IO\Request;
use Framework\IO\Response;
class UserConstructor {
#[Route("/users/get", Access::General)]
public static function fetch(Request $request): Response { // ✗ must be named get()
return Response::empty();
}
}
RouteParamType — a route takes no params or a single Request.
class UserConstructor {
#[Route("/users/get", Access::General)]
public static function get(int $id): Response { // ✗ only a Request (or nothing) is allowed
return Response::empty();
}
}
RouteReturnType — a route returns a Response.
class UserConstructor {
#[Route("/users/get", Access::General)]
public static function get(Request $request): array { // ✗ must return a Response
return [];
}
}
RouteDuplicate — no two routes share the same path.
class UserConstructor {
#[Route("/users/get", Access::General)]
public static function get(Request $request): Response { /* … */ }
}
class AdminApi {
#[Route("/users/get", Access::Admin)] // ✗ "/users/get" is already taken
public static function get(Request $request): Response { /* … */ }
}
Signals
ListenerReturnVoid — a #[Listener] method returns void.
use Framework\Discovery\Attr\Listener;
class UserHooks {
#[Listener("userCreated")]
public static function onCreate(int $credentialID): bool { // ✗ must return void
return true;
}
}
Enums
EnumHasNoneCase — every enum defines a None case.
use Framework\Enum\Enum;
use Framework\Enum\IsEnum;
enum Status implements Enum {
use IsEnum;
// ✗ missing: case None;
case Active;
case Blocked;
}
EnumInternalMethods — the IsEnum helpers are used, not the native enum methods.
$all = Status::cases(); // ✗ native enum method
$all = Status::getAll(); // ✓ IsEnum helper
Logs
ActionSection — a class with a #[Section] has at least one #[Action] method.
use Framework\Log\Attr\Section;
use Framework\Log\Attr\Action;
#[Section("Users")]
class UserLog {} // ✗ a #[Section] with no #[Action] method
#[Section("Users")]
class UserLog {
#[Action("Create")] // ✓ at least one action
public static function create(): void {}
}
SectionDuplicate — no two log sections share a name.
#[Section("Users")]
class UserLog { /* … */ }
#[Section("Users")] // ✗ the "Users" section name is already used
class TeamLog { /* … */ }
Framework rules
These enforce the framework's house style. Each is toggled individually under the framework:
key in phpstan.neon — they default to off, so flip on the ones you want:
parameters:
framework:
disallowClassCompare: true
disallowDebugPrint: true
disallowEmptyArray: true
disallowEnumNameValue: true
disallowIntFloatWidening: true
disallowNativeDate: true
enumSwitchExhaustive: true
methodReturnNotNeeded: true
preferListType: true
preferMatchOverSwitch: true
privateMethodReturnUnused: true
requireNamedBoolArg: true
requireOptionalComment: true
requireReturnType: true
actionLogLanguages: ['en', 'es']disallowClassCompare — two objects are never compared directly with ==.
$a = Date::now();
$b = Date::now();
if ($a == $b) {} // ✗ comparing objects with ==
if ($a === $b) {} // ✓
disallowDebugPrint — no leftover var_dump() / print_r() debug calls.
public function process(array $data): void {
var_dump($data); // ✗ leftover debug call
print_r($data); // ✗
}
disallowEmptyArray — no array{} empty-shape types.
/**
* @return array{} // ✗ empty array shape
*/
public function options(): array {
return [];
}
disallowEnumNameValue — enum values go through toString(), not ->name / ->value.
$status = Status::Active;
$label = $status->name; // ✗
$label = $status->toString(); // ✓
disallowIntFloatWidening — no implicit int → float widening on arguments.
function setPrice(float $price): void {}
setPrice(10); // ✗ int widened to float
setPrice(10.0); // ✓
disallowNativeDate — dates use the Date class, not native date functions.
use Framework\Date\Date;
$today = date("Y-m-d"); // ✗ native date function
$today = Date::now()->toString(); // ✓
enumSwitchExhaustive — a switch / match on an enum covers every case.
$label = match ($status) {
Status::Active => "Active", // ✗ Status::None / Status::Blocked are not handled
};
methodReturnNotNeeded — no method always returns the same value.
public function save(): bool {
// …
return true; // ✗ always true — declare : void instead
}
preferListType — list<T> is used instead of T[].
/** @return int[] */ // ✗
public function ids(): array {
return [1, 2, 3];
}
/** @return list<int> */ // ✓
public function ids(): array {
return [1, 2, 3];
}
preferMatchOverSwitch — a match is used instead of a switch where it fits.
switch ($type) { // ✗
case "a": return 1;
case "b": return 2;
}
return match ($type) { // ✓
"a" => 1,
"b" => 2,
};
privateMethodReturnUnused — no private method returns a value that is never used.
class Report {
public function run(): void {
$this->log(); // the return value is ignored
}
private function log(): bool { // ✗ returns a value nobody uses
return true;
}
}
requireNamedBoolArg — boolean arguments are passed as named arguments.
$user->save(true); // ✗ what does true mean here?
$user->save(force: true); // ✓
requireOptionalComment — optional parameters carry an Optional. doc note.
/**
* @param int $page Optional. // note the "Optional." on optional params
*/
public function list(int $page = 1): void {}
requireReturnType — every method declares a native return type.
public function count() { // ✗ no return type
return 0;
}
public function count(): int { // ✓
return 0;
}
actionLogLanguages — log #[Action] attributes are named and translated to the given
languages (default [en, es]).
use Framework\Log\Attr\Action;
#[Action("Create", en: "Created")] // ✗ missing es
#[Action("Create", en: "Created", es: "Creado")] // ✓