Framework
Features

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:

phpstan.neon
includes:
    - vendor/phpstan/phpstan-strict-rules/rules.neon
    - phpstan-rules.neon

parameters:
    phpVersion: 80300
    level: 10
    paths:
        - src

Run it from the project root:

./vendor/bin/phpstan analyse
The custom rules live in 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:

phpstan.neon
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 intfloat 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
}

preferListTypelist<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")]   // ✓