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
ifblocks, check for invalid conditions first and return early. - Replace conditionals with polymorphism. If you have a long
switchor chain ofif/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) — +1xor(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):
# 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
ifinside anotherifscores higher than twoifblocks at the same level, because nested logic is harder to follow mentally. - Shorthand structures score less. A
switchwith 10 cases adds only 1 point (it is a single mental structure), while 10 separateifstatements add 10 points each plus nesting. - Breaks in linear flow add points.
gotoandbreak/continuewith 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
elseafterreturn. If theifbranch returns, you do not needelse. - 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/continuewith 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 && $cis one,$a && $b || $ctwo,$a && ($b || $c) && $dthree.&&andandare one operator, as are||andor. Anything else between two operators —!, a call, an assignment — ends the expression, so$a && !($b && $c)is two sequences. else ifwritten as two words is the whitepaper'selse if: +1 without a nesting increment, exactly likeelseif. Anifinside a bracedelse { ... }block is a nestedif.- Nesting increment:
if, ternary,switch, loops andcatchalso add the current nesting level.elseif,else if,else,gotoand numbered jumps do not. - Nesting level: rises inside the bodies of
if,elseif,else, ternary branches,switchcases, loops,catchand 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 anif (...)condition adds +1, while one inside theifbody adds +2. matchis PHP's switch expression and is scored asswitch(+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 andnamespace\-relative names, in any letter case.parent::is not a self-call. A function can call itself by its short, fully qualified ornamespace\-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¶
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()andvalidateAddress()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
ifstatements 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 ?? 5and$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) + nullsaferetains exactly one statement base.- PHP-specific extensions:
match,foreach,??, and?->are all handled as path-generating constructs. Expression-bearingfor,foreach,switch, andechoslots are traversed through transparent AST wrappers. switchwithoutdefault: Contributes one path for "no case matched", just as anifwithoutelsehas 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:
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¶
For a simple pass/fail threshold:
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.