Skip to content

Complexity Rules

Complexity rules measure how tangled and branching your code is. The more branches, loops, and conditions a method has, the harder it is to understand, test, and change without introducing bugs.

Think of it like directions to someone's house: "go straight, then turn left" is easy. "Go straight, but if there is construction turn right, unless it is a Tuesday, in which case..." -- that is complex.


Cyclomatic Complexity

Rule ID: complexity.ccn

Judged metrics: complexity.ccn, complexity.ccn.max

What it measures

Cyclomatic Complexity (often abbreviated CCN) counts the number of decision points in a method. Every if, elseif, while, for, foreach, case, catch, &&, ||, ??, and ?: adds 1 to the count. A method with no branches at all has a complexity of 1.

The number roughly tells you the minimum number of test cases you need to fully cover the method.

How to read the value:

CCN Interpretation
1--4 Simple, easy to test
5--10 Moderate -- typical for business logic
11--20 Complex -- consider refactoring
21--50 Very complex, hard to maintain and test
50+ Extremely complex -- split immediately

Thresholds

Callable level (enabled by default):

Level Threshold Severity
Warning >= 10 Warning
Error >= 20 Error

Class level (enabled by default) -- checks the maximum CCN among all methods in the class:

Level Threshold Severity
Warning >= 30 Warning
Error >= 50 Error

Example

This method has a cyclomatic complexity of 5 (1 base + 4 decision points):

function processOrder(Order $order): void
{
    if ($order->isPaid()) {               // +1
        if ($order->hasDiscount()) {      // +1
            $this->applyDiscount($order);
        }
        foreach ($order->getItems() as $item) {  // +1
            $this->ship($item);
        }
    } elseif ($order->isPending()) {      // +1
        $this->notify($order);
    }
}

How to fix

  • Extract methods. Move nested logic into well-named helper methods. Each method becomes simpler and easier to test.
  • Use early returns. Instead of nesting if blocks, check for invalid conditions first and return early.
  • Replace conditionals with polymorphism. If you have a long switch or chain of if/elseif, consider using the Strategy or State pattern.
  • Simplify boolean expressions. Complex conditions like if ($a && ($b || $c) && !$d) can often be broken into named boolean variables or separate methods.

Implementation notes

Qualimetrix uses an extended variant of Cyclomatic Complexity, sometimes called CCN2+: the standard decision points (if, elseif, while, for, foreach, case, catch, &&, ||, ?:) plus the PHP operators listed in the deviation below.

Deviation from original spec

McCabe's metric has no counterpart for these PHP operators. Qualimetrix counts each as a decision point:

  • ?? (null coalescing operator) and ??= (null-coalescing assignment) — +1
  • ?-> (nullsafe method call) — +1
  • ?-> (nullsafe property fetch) — +1
  • xor (logical XOR operator) — +1, like the other logical operators, although it does not short-circuit

??, ??= and ?-> hide a branch: $a ?? $b is equivalent to $a !== null ? $a : $b — a decision point that is easy to overlook.

match arms: Each condition in a multi-value match arm is counted separately. For example, 1, 2, 3 => ... counts as 3 decision points, analogous to switch case fall-through.

Closures and arrow functions: Closures and arrow functions are measured as separate units — they do not add to the enclosing method's CCN. This matches how they are used in practice: as self-contained callable objects.

Comparing with other tools

Because of these additional decision points, Qualimetrix will report higher CCN values than phpmd or pdepend for code that uses null coalescing or nullsafe operators. This is not a bug — it reflects a stricter definition of complexity. The difference is most noticeable in code with chained ?? expressions.

Configuration

# qmx.yaml
rules:
  complexity.ccn:
    callable:
      warning: 15
      error: 25
    class:
      max_warning: 40
      enabled: true   # set to false to disable class-level check

For a simple pass/fail threshold (all violations become errors):

rules:
  complexity.ccn:
    callable:
      threshold: 15   # equivalent to warning: 15, error: 15
# CLI overrides
bin/qmx check src/ --rule-opt="complexity.ccn:callable.warning=15"
bin/qmx check src/ --rule-opt="complexity.ccn:callable.error=25"
bin/qmx check src/ --rule-opt="complexity.ccn:class.max_warning=40"
bin/qmx check src/ --rule-opt="complexity.ccn:class.enabled=false"

warning: / error: are level options, not rule options

Written directly under complexity.ccn: -- outside callable: and class: -- they configure nothing, and the run now stops with exit 3. Nest them under the level they apply to. The class: level spells its bounds max_warning / max_error, so the two levels do not take the same keys; the refusal lists what the position you wrote actually takes.


Cognitive Complexity

Rule ID: complexity.cognitive

Judged metrics: complexity.cognitive, complexity.cognitive.max

What it measures

Cognitive Complexity measures how hard the code is to read and understand by a human. Unlike cyclomatic complexity, which counts decision points mechanically, cognitive complexity considers how the code feels to the reader.

Key differences from cyclomatic complexity:

  • Nesting increases the penalty. An if inside another if scores higher than two if blocks at the same level, because nested logic is harder to follow mentally.
  • Shorthand structures score less. A switch with 10 cases adds only 1 point (it is a single mental structure), while 10 separate if statements add 10 points each plus nesting.
  • Breaks in linear flow add points. goto and break/continue with a level number (break 2) cost points because they disrupt the reading flow.

How to read the value:

Cognitive Interpretation
0--5 Simple, easy to understand
6--15 Moderate complexity
16--30 Complex, hard to follow
30+ Very hard to follow -- refactoring required

Thresholds

Callable level (enabled by default):

Level Threshold Severity
Warning >= 15 Warning
Error >= 30 Error

Class level (enabled by default) -- checks the maximum cognitive complexity among all methods:

Level Threshold Severity
Warning >= 30 Warning
Error >= 50 Error

Example

function calculate(array $items): float  // cognitive complexity: 9
{
    $total = 0;
    foreach ($items as $item) {                  // +1 (nesting 0)
        if ($item->isActive()) {                 // +2 (1 + nesting 1)
            if ($item->hasDiscount()) {          // +3 (1 + nesting 2)
                $total += $item->discountedPrice();
            } else {                             // +1
                $total += $item->price();
            }
        }
    }
    return $total;
}

Notice how nesting makes the penalty grow. The deeply nested if ($item->hasDiscount()) costs 3 points, not just 1, because it sits inside two other structures.

How to fix

  • Reduce nesting depth. This is the single most effective fix. Use early returns to "flatten" the code.
  • Extract deeply nested blocks into separate methods with descriptive names.
  • Avoid else after return. If the if branch returns, you do not need else.
  • Replace loops with collection methods (e.g., array_filter, array_map) when appropriate.

Implementation notes

Qualimetrix implements the SonarSource whitepaper (Cognitive Complexity, version 1.7, Appendix B):

  • Increments (+1): if, elseif, else, ternary ?:, switch, for, foreach, while, do-while, catch, goto, break/continue with a level number, each sequence of like logical operators, and a method that calls itself (+1 once per method, however many recursive calls it makes).
  • Sequences of logical operators are read in source order, as the whitepaper writes them: every operator that differs from the one before it starts a new sequence. $a && $b && $c is one, $a && $b || $c two, $a && ($b || $c) && $d three. && and and are one operator, as are || and or. Anything else between two operators — !, a call, an assignment — ends the expression, so $a && !($b && $c) is two sequences.
  • else if written as two words is the whitepaper's else if: +1 without a nesting increment, exactly like elseif. An if inside a braced else { ... } block is a nested if.
  • Nesting increment: if, ternary, switch, loops and catch also add the current nesting level. elseif, else if, else, goto and numbered jumps do not.
  • Nesting level: rises inside the bodies of if, elseif, else, ternary branches, switch cases, loops, catch and lambdas. The whitepaper does not say where a condition sits; Qualimetrix reads a condition or subject as not nested inside the structure it controls, so a ternary inside an if (...) condition adds +1, while one inside the if body adds +2.
  • match is PHP's switch expression and is scored as switch (+1 plus the nesting level, arms nested one level): the whitepaper counts a language's own spelling of a listed keyword.
  • Closures and arrow functions add nothing to the enclosing method and raise the nesting level of their body by one. Like every other callable metric, Qualimetrix measures each closure as its own unit (see the deviation below).
  • Named functions and anonymous-class methods declared inside a method are measured as separate units starting at nesting level 0 (see the deviation below).
  • Null-coalescing operators add nothing: ??, ??= and ?-> score 0, as the whitepaper prescribes ("Ignore shorthand", page 6): they shorten a null check into one expression rather than break the linear flow. Cyclomatic complexity counts each operator as +1; NPath treats ?? and ??= as ternary value selections (+2 each).
public function activeNames(array $users): array  // cognitive complexity: 0
{
    return array_map(                         // +0 (the arrow function itself)
        fn (User $u) => $u->isActive()        // the arrow function: +2 (ternary, nesting=1)
            ? $u->name()
            : 'inactive',
        $users,
    );
}

Deviation from original spec

  • Closures and arrow functions inside a method: the whitepaper adds a lambda's body to the enclosing method. Qualimetrix keeps the lambda a separate unit, as its cyclomatic, NPath and maintainability metrics do, but continues the nesting level from where the lambda is written. The whitepaper's score for a method is therefore the method's value plus the values of the lambdas inside it; in the example above, 0 + 2 = 2. A finding for a complex closure is reported on the closure.
  • Named functions and anonymous-class methods inside a method: the whitepaper nests them one level inside the enclosing structure and adds their body to the method. Qualimetrix measures each as its own unit starting at nesting level 0.
  • Recursion: only a direct self-call is detected: $this->method() or $this?->method(), self::, static:: or a class name resolved to the owning class, including imported aliases, imported namespace prefixes, fully qualified and namespace\-relative names, in any letter case. parent:: is not a self-call. A function can call itself by its short, fully qualified or namespace\-relative name. The whitepaper also counts every method in an indirect cycle (A calls B, B calls A); Qualimetrix does not, and it does not follow a call through a variable or a callable.

Configuration

# qmx.yaml
rules:
  complexity.cognitive:
    callable:
      warning: 20
      error: 40
    class:
      max_warning: 40

For a simple pass/fail threshold:

rules:
  complexity.cognitive:
    callable:
      threshold: 20   # warning=20, error=20 → all violations are errors
bin/qmx check src/ --rule-opt="complexity.cognitive:callable.warning=20"
bin/qmx check src/ --rule-opt="complexity.cognitive:callable.error=40"

warning: / error: are level options, not rule options

Written directly under complexity.cognitive: -- outside callable: and class: -- they configure nothing, and the run now stops with exit 3. Nest them under the level they apply to. The class: level spells its bounds max_warning / max_error, so the two levels do not take the same keys; the refusal lists what the position you wrote actually takes.


NPath Complexity

Rule ID: complexity.npath

Judged metrics: complexity.npath, complexity.npath.max

What it measures

NPath Complexity counts the total number of unique execution paths through a method. While cyclomatic complexity adds 1 for each decision point, NPath multiplies across branches.

Think of it this way: if a method has 3 independent if statements, each can be true or false. That gives 2 x 2 x 2 = 8 possible paths. NPath would be 8, while cyclomatic complexity would be 4.

This makes NPath grow very fast. It reflects the true testing burden: to fully test all paths, you would need one test case per unique path.

How to read the value:

NPath Label Interpretation
1--1,000 moderate Minor refactoring (extract 1--2 methods)
1,001--10,000 high Significant refactoring needed
10,001--1,000,000 very high Major restructuring required
> 1,000,000 extreme Fundamental redesign needed

The category label is included in violation messages (e.g., "NPath complexity is 36120 (very high)") to help prioritize without memorizing the NPath scale.

Thresholds

Callable level (enabled by default):

Level Threshold Severity
Warning >= 200 Warning
Error >= 1000 Error

Class level (disabled by default) -- checks the maximum NPath among methods.

Example

function validate(Request $request): bool
{
    if ($request->hasName()) { /* ... */ }       // 2 paths
    if ($request->hasEmail()) { /* ... */ }      // x 2 = 4 paths
    if ($request->hasPhone()) { /* ... */ }      // x 2 = 8 paths
    if ($request->hasAddress()) { /* ... */ }    // x 2 = 16 paths
    if ($request->hasCity()) { /* ... */ }       // x 2 = 32 paths
    if ($request->hasCountry()) { /* ... */ }    // x 2 = 64 paths
    if ($request->hasZip()) { /* ... */ }        // x 2 = 128 paths
    if ($request->hasState()) { /* ... */ }      // x 2 = 256 paths -> WARNING
    return true;
}

Just 8 independent if statements already produce 256 paths.

How to fix

  • Extract groups of related checks into separate methods. Splitting validation into validateContactInfo() and validateAddress() cuts the path count dramatically.
  • Reduce independent branches. Combine related conditions or use data-driven validation (e.g., loop over a list of required fields).
  • Break up long runs of independent decisions. Sequential decisions multiply NPath, while a decision nested inside a branch only adds its paths to that branch: two if statements in a row give 4, the same two nested give 3.

Implementation notes

Qualimetrix follows Nejmeh (1988) with PHP-specific extensions:

  • Boolean operators in conditions: Each &&/|| in a condition adds 1 to that condition's path count. For example, if ($a && $b || $c) contributes 4 paths (base 2 + 2 operators).
  • Ternary: Contributes 2 base paths plus any complexity in sub-expressions.
  • ?? and ??=: Contribute 2 ordinary expression paths each, like ternary value selection. return $x ?? 5 and $x ??= [] have NPath 2; two chained coalesces have NPath 4.
  • ?->: Each nullsafe hop adds 1 separately from ordinary expression paths. At a callable statement boundary, max(1, ordinary) + nullsafe retains exactly one statement base.
  • PHP-specific extensions: match, foreach, ??, and ?-> are all handled as path-generating constructs. Expression-bearing for, foreach, switch, and echo slots are traversed through transparent AST wrappers.
  • switch without default: Contributes one path for "no case matched", just as an if without else has a skip path. Checkstyle uses the same interpretation; pdepend/PHPMD does not.

Bare { ... } blocks and declare(...) { ... } retain the product of their child statements. Expressions in unset, dynamic global, and static initializers contribute paths too. Arithmetic saturates at 1,000,000,000; a published value at that cap is a lower bound, not an exact count. The text label > 1M (extreme) is a display category, not the saturation limit.

match expressions: Qualimetrix uses an additive approach, consistent with Nejmeh's original formula for switch:

NPath(match) = NPath(subject) + sum(NPath(arm conditions) + max(1, NPath(arm body)))

Every arm contributes at least one path, so a simple match agrees with the equivalent switch when that switch has a default. A match without a default adds no no-match path because an unmatched match throws. Nullsafe method/property access contributes its conditional short-circuit path, and nested ternaries or boolean operators are retained even inside wrapper expressions. Some other tools (notably pdepend) use a multiplicative approach for match, which can produce extreme values (millions) for methods with large match expressions. Qualimetrix's additive approach yields practical, actionable values. Values for these constructs change from earlier Qualimetrix releases and may require threshold/baseline recalibration.

Deviation from original spec

Nejmeh (1988) predates ??, ??= and ?->. Qualimetrix models ?? and ??= as ternary value selections (+2 ordinary paths), while ?-> adds one separate nullsafe path per hop. $x ?? $y ?? 5 therefore reports 4, like the nested ternary $x ? 1 : ($y ? 2 : 3), although each has three distinguishable outcomes. This preserves the existing ternary recurrence; it is not a special count of distinct runtime outcomes.

NPath try/catch handling is a deliberate qmx extension. Qualimetrix computes (NPath(try) + Σ NPath(catch) + 1) × NPath(finally): the +1 is a PMD-style additive term for the no-exception path. A bare try{}catch{} therefore reports NPath 3, whereas the original Nejmeh (1988) scheme — which predates exceptions — would imply 2. This differs from phpmd/pdepend and is intentional.

Configuration

# qmx.yaml
rules:
  complexity.npath:
    callable:
      warning: 300
      error: 2000
    class:
      enabled: false   # disabled by default

For a simple pass/fail threshold:

rules:
  complexity.npath:
    callable:
      threshold: 300   # warning=300, error=300 → all violations are errors
bin/qmx check src/ --rule-opt="complexity.npath:callable.warning=300"
bin/qmx check src/ --rule-opt="complexity.npath:class.enabled=true"

warning: / error: are level options, not rule options

Written directly under complexity.npath: -- outside callable: and class: -- they configure nothing, and the run now stops with exit 3. Nest them under the level they apply to. The class: level spells its bounds max_warning / max_error, so the two levels do not take the same keys; the refusal lists what the position you wrote actually takes.


WMC -- Weighted Methods per Class

Rule ID: complexity.wmc

Judged metric: complexity.wmc

What it measures

WMC (Weighted Methods per Class) is the sum of cyclomatic complexity of all methods in a class. It tells you the overall complexity burden of the entire class.

A class with 20 simple getter/setter methods (each with complexity 1) has WMC = 20. A class with 5 methods where each has complexity 10 also has WMC = 50. Both are "heavy" in different ways: the first has too many methods, the second has too-complex methods.

How to read the value:

WMC Interpretation
1--20 Manageable class
21--50 Large class -- review for single responsibility
51--80 Very large class
80+ Excessive -- strongly consider splitting

Thresholds

Level Threshold Severity
Warning >= 50 Warning
Error >= 80 Error

Example

class OrderProcessor
{
    public function process(): void { /* CCN = 8 */ }
    public function validate(): void { /* CCN = 12 */ }
    public function calculateTax(): void { /* CCN = 6 */ }
    public function applyDiscounts(): void { /* CCN = 10 */ }
    public function generateInvoice(): void { /* CCN = 7 */ }
    public function sendNotification(): void { /* CCN = 5 */ }
    public function logResult(): void { /* CCN = 3 */ }
    // WMC = 8 + 12 + 6 + 10 + 7 + 5 + 3 = 51 -> WARNING
}

How to fix

  • Split the class into smaller, focused classes. If WMC is high because of many methods, the class likely has too many responsibilities.
  • Simplify individual methods. If WMC is high because a few methods are very complex, refactor those methods first.
  • Consider the Single Responsibility Principle. A class should have only one reason to change.

Implementation notes

WMC is calculated as the sum of Cyclomatic Complexity of all methods in a class. The Complexity collector produces it on each exact class declaration, including 0 for a class without methods. Property hooks and anonymous-class methods do not contribute; the Complexity rule evaluates it and can exclude data classes. Since Qualimetrix uses the CCN2+ variant (which counts ?? and ?-> as decision points), WMC values will be correspondingly higher than those reported by other tools.

Configuration

# qmx.yaml
rules:
  complexity.wmc:
    warning: 60
    error: 100
    exclude_data_classes: true

For a simple pass/fail threshold:

rules:
  complexity.wmc:
    threshold: 60     # warning=60, error=60 → all violations are errors
bin/qmx check src/ --rule-opt="complexity.wmc:warning=60"
bin/qmx check src/ --rule-opt="complexity.wmc:error=100"

Per-layer shorthand and class activation

A top shorthand for CCN, cognitive complexity or NPath changes callable and preserves the class band. It conflicts with explicit writes to the same expanded leaves in one layer; independent settings such as callable.enabled are preserved. Separate layers merge their expanded leaves. NPath written class bands activate without a redundant true; class.enabled:false still turns them off. Integer bands refuse fractional values; effective ordering includes default halves. Existing academic algorithms and default numbers are unchanged. See Configuration forms.