Start with the report itself
Static analyzers love to tell you that your method is too long, too complex, or too nested. But every once in a while, one of them produces a warning that feels more personal. This one passes judgment not just on your code’s shape, but on its density.
warning[halstead]: Method has a high halstead difficulty
┌─ file.php:479:5
│
479 │ ╭ private function scrollDown(int $n = 1): bool
480 │ │ {
481 │ │ if ($this->mode !== 'entry') {
482 │ │ $max = max(0, count($this->filteredEntries) - 1);
· │
500 │ │ return true;
501 │ │ }
│ ╰─────^ Method exceeds halstead difficulty threshold(s)
│
= Halstead difficulty is 14.06 (threshold 12). Difficulty reflects how hard the code is to write or understand.
= Help: Consider breaking this into smaller, more focused functions.
The warning is a single number, with decimals, accusing a 20-line method of being hard to understand. The natural first reaction is disbelief. The method is called scrollDown. It moves a selection cursor down in a TUI list. How hard can it be?
The interesting part is that the metric is not wrong. It measures something real. It simply measures something different from what you expect.
Four counts and one ratio
Maurice Halstead published his theory in 1977, in the book Elements of Software Science. His goal was ambitious. He wanted to turn “how hard is this program to understand?” into a number that you could compute from the source code, without running it. Thomas McCabe had proposed cyclomatic complexity a year earlier, so the two metrics grew up side by side and measure different things.
His theory starts from a simple observation. Source code is made of two kinds of tokens.
- Operators are the tokens that do something, such as
!==,<=,-,+,if,return, and function calls. - Operands are the tokens that get acted upon, such as variables, literals, and method names.
From those two categories, you count four numbers:
| Symbol | Meaning |
|---|---|
| η₁ | number of distinct operators |
| η₂ | number of distinct operands |
| N₁ | total number of operator occurrences |
| N₂ | total number of operand occurrences |
The first pair describes your vocabulary, and the second describes your length.
Halstead then derived a whole family of metrics from these counts, including volume, effort, estimated bugs, and time to write. The time metric divides effort by 18, the Stroud number, a figure built on the psychologist John Stroud’s estimate of how many mental discriminations a person makes per second. The metric our analyzer complains about is difficulty, and its formula is remarkably simple:
D = (η₁ / 2) × (N₂ / η₂)
?>
[/php]
The formula has two factors. The first, η₁ / 2, counts how many different kinds of operations you juggle. The second, N₂ / η₂, measures how much you reuse the same operands. A value of 1 means that every operand appears exactly once. A value of 5 means that each distinct variable or literal gets touched five times on average.
The second factor is the one people find surprising. Reusing the same variable over and over does not just make a method longer. Under Halstead, it makes the method harder. The intuition is that a reader must keep the state of $this->selected in mind at every point, and every re-reference is another chance for that mental model to go wrong.
Mapping a 1977 model onto 2026 PHP
Halstead’s original work predates PHP by nearly two decades, so any tool must decide how tokens map onto modern language features. In a PHP analyzer, the mapping looks roughly like this:
- Operators include
if,elseif,return,===,!==,<=,-, and+. - Function calls such as
maxandcountcount as one operator each, and their arguments count as operands. - Variables and literals such as
$this->selected,$this->filteredEntries,$n,1,0, and'entry'count as operands.
Now look at what scrollDown actually does. It manipulates a handful of distinct properties, $this->mode, $this->selected, and $this->filteredEntries. It mixes comparisons, arithmetic, function calls, clamping with max, and boundary checks with count.
That is precisely the recipe for a high Halstead difficulty.
- The operator vocabulary grows because the method uses many different operators. A cursor method needs comparisons, arithmetic, and calls. That is already a rich vocabulary for something that “just scrolls down”.
- The ratio N₂ / η₂ grows because the operands are all different.
$this->selectedis read, compared, modified, and clamped.$this->filteredEntriesis counted. Every occurrence pushes N₂ up, while η₂ stays fixed.
Multiply the two factors and the result is 14.06.
The metric does not say that the method is long, since twenty lines is modest. It does not say that the method nests deeply, since a couple of if statements is tame. It says that the method is dense: a lot of varied computation packed onto a small set of shared state. McCabe’s cyclomatic complexity counts how many paths a method has. Halstead difficulty estimates how much a reader must hold in mind to follow it.
Density is inherent to the job of a method that owns a selection cursor. The new value depends on the list length, the current position, and the step size, and it must stay clamped at both ends. The real question is whether that density lives in one place or spreads across helpers.
Where the 12 comes from
Halstead never published recommended limits. The threshold of 12 is a default policy choice of the analyzer, and the metric itself offers no reason for that choice. That could be changed, of course.
A metric without a calibrated threshold is just trivia. A difficulty of 14.06 against a threshold of 12 puts the method roughly 17 percent over the line. That is not a catastrophe, but it is a persistent signal. Unlike a one-off violation caused by odd code, this warning fires on every method written in the same style, with the same properties, operators, and density.
Thresholds cut both ways. A low threshold catches real problems early, but it also produces noise on methods where density is unavoidable or where splitting does more harm than good. In my view, twelve is a defensible middle ground. Below it, most methods read at a glance. Above it, you usually find mixed concerns or heavy state juggling.
The honest question to ask when a threshold is exceeded is not “how do I silence this?” It is “is this density structural?” Does the method do several jobs that deserve separate names, or is it one job whose formula is simply long?
A small method with a big idea
Here is the practical dilemma. The method is short, and it works. Rewriting it adds indirection, new method names, parameter passing, and more total code. Isn’t that worse?
Sometimes it is. But a Halstead warning on a short method usually points at an opportunity rather than a defect. The density often comes from mixed concerns, and extracting them produces functions that are individually easy to read:
filteredEntries) - 1);
}
private function clampSelection(int $value): int
{
return max(0, min($value, $this->entryCount()));
}
?>
With those helpers in place, scrollDown shrinks to its essential decision logic: what changes and by how much. The fiddly parts, such as off-by-one list lengths and clamping, get one obvious home. Each helper has a small operator set and low operand reuse, so each one scores well on its own. The arithmetic of the metric decomposes the same way the code does.
There is a second benefit that metrics do not capture. The helpers become named concepts. clampSelection documents a rule that used to live implicitly across scattered max calls. The next method you write, such as scrollUp or pageDown, can reuse it rather than reimplement the clamping slightly differently. That is how these warnings quietly prevent drift, the slow process by which two methods that should mirror each other stop doing so.
The warning works best as a conversation starter, not a verdict. If the density is irreducible, with one formula, one job, and nothing to name, the sensible move is to suppress the warning with a comment and move on. More often, though, a twenty-line method that trips a Halstead threshold hides a small, well-defined concept waiting to be extracted. The rewrite costs about five minutes, and the metric simply spotted it before you did.
Metrics do not know what your code means. Halstead difficulty measures token counts, not semantics. “Many different operations repeatedly touching a few pieces of state” is still a decent statistical proxy for “a reader will have to work here.” In this case, the proxy pointed at something worth naming. So the next time an analyzer calls one of your methods difficult, which piece of state is it really complaining about, and what would you call it?

