Skip to content

Coupling Rules

Coupling rules measure how tightly your classes depend on each other. When classes are tightly coupled, changing one class can break many others. Loosely coupled code is easier to test (you can isolate a class), easier to change (fewer side effects), and easier to reuse.

The same Coupling analysis also classifies configured framework dependencies for the coupling.cbo-app and coupling.ce-framework metrics. A later configuration source replaces the earlier framework namespace list; an explicit empty list disables that classification for the run.

Think of coupling like wires connecting boxes. The more wires between two boxes, the harder it is to move one without disturbing the other.


CBO -- Coupling Between Objects

Rule ID: coupling.cbo

Judged metrics: coupling.cbo, coupling.cbo-app, coupling.cbo-own

What it measures

CBO counts the total number of other classes that this class is connected to. A "connection" means either:

  • This class uses another class (outgoing dependency, called "efferent coupling" or Ce), or
  • Another class uses this class (incoming dependency, called "afferent coupling" or Ca)

CBO = |Ca ∪ Ce| (the number of unique classes in the union of both sets).

For example, if UserService uses UserRepository, Logger, Validator, and Mailer (Ce = 4), and is used by UserController and AdminController (Ca = 2), its CBO = 6 (no overlap).

How to read the value:

CBO Interpretation
0--7 Normal coupling
8--14 Moderate -- typical for complex classes
15--20 High coupling -- consider reducing dependencies
20+ Very high coupling

At namespace level CBO counts namespaces, not classes. A namespace's CBO is the number of distinct namespaces holding a class on the other side of a dependency that crosses the namespace's boundary, in either direction. The boundary is the namespace's whole subtree -- the same region its coupling.ca and coupling.ce are counted over -- so a parent namespace is coupled to what lies outside it, never to its own sub-namespaces. coupling.ca and coupling.ce on a namespace still count classes, which is why a namespace's CBO is usually smaller than either, and why the finding message spells it CBO: N namespaces.

The other side is named by the namespace that declares the class, so the number depends on how finely the code around the namespace is split: dividing a neighbouring directory into sub-namespaces raises this namespace's CBO while its Ca and Ce stay the same. A parent namespace near the root of a project is coupled to many such namespaces and usually has the highest CBO of all. The namespace thresholds below reuse the class-level numbers; they were not calibrated for this quantity separately.

A namespace is judged on its own classes. The rule reads coupling.cbo-own: the same count taken over the classes declared directly in the namespace, where a sub-namespace is a namespace like any other. A namespace without sub-namespaces has one scope, so the two keys are equal there. On a parent they differ, and the subtree value is not judged: it grows with the subtree, and its region depends on which sub-namespaces the run holds, so a narrowed run and a whole one would disagree about which code is judged. The own scope's boundary does not move with that, so which namespaces are judged does not depend on the run's paths. The value does, as it does for every coupling measure (coupling.ca, coupling.instability, coupling.class-rank): a dependency is read from the class it starts at, so a class the run did not analyse — a sub-namespace's included — is not counted, and a narrowed run (projectScope.state: narrowed in the JSON report) can judge a namespace on a lower value than a whole one. The finding's inbound and outbound counts are the own scope's too (coupling.ca-own, coupling.ce-own). A namespace that declares no type of its own publishes no coupling.cbo-own and is not judged; the subtree coupling.cbo is published on every namespace (--format=metrics).

Thresholds

Class level (enabled by default):

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

Namespace level (enabled by default, judged on coupling.cbo-own, requires at least 3 classes declared in the namespace itself):

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

Example

class ReportGenerator
{
    public function __construct(
        private UserRepository $userRepo,      // +1
        private OrderRepository $orderRepo,    // +1
        private ProductRepository $productRepo, // +1
        private Logger $logger,                // +1
        private CacheInterface $cache,         // +1
        private EventDispatcher $dispatcher,   // +1
        private TemplateEngine $templates,     // +1
        private PdfGenerator $pdf,             // +1
        private CsvExporter $csv,              // +1
        private EmailService $email,           // +1
        private TranslatorInterface $translator, // +1
        private ConfigProvider $config,        // +1
        private MetricsCollector $metrics,     // +1
        private SecurityChecker $security,     // +1
        private AuditLogger $audit,            // +1
    ) {}
    // Ce = 15, and if nothing depends on this class, CBO = 15 -> WARNING
}

How to fix

  • Split the class. A class with 15+ dependencies is doing too much. Extract groups of related dependencies into focused services (e.g., ReportDataProvider, ReportExporter).
  • Use interfaces instead of concrete classes. This does not reduce CBO directly, but makes the coupling looser and more flexible.
  • Apply dependency injection. Avoid creating dependencies inside the class with new. Inject them through the constructor so they can be replaced.
  • Consider the Facade pattern. Wrap groups of related services behind a single interface.

Implementation notes

Qualimetrix implements bidirectional coupling consistent with Chidamber & Kemerer (1994): CBO = |Ca ∪ Ce|, counting the number of unique classes that appear in either incoming or outgoing dependencies. If class A both uses and is used by class B, B is counted once (union semantics), not twice.

  • Extended coupling types: Qualimetrix detects 14 types of coupling, going beyond C&K's original "methods or instance variables" definition. These include: class instantiation, static method calls, type hints (parameters, return types, properties), catch clauses, instanceof checks, class constants, attributes (the attribute name and, as a separate kind, the references inside its arguments), extends/implements, and trait use.
  • Union and intersection types: Each type in a union (A|B) or intersection (A&B) type hint is counted as a separate coupling.
  • Self-references excluded: References to self, static, and parent within the same class are not counted as coupling.
  • PHP built-in classes excluded: Dependencies on classes from the PHP distribution (php-src) are excluded from CBO, Ca, and Ce — this includes core classes (Exception, DateTime, Closure), SPL (ArrayIterator, SplFileInfo), and bundled extensions (PDO, DOMDocument, Random\Randomizer, CurlHandle, etc.). Coupling to stable, PHP-maintained types does not increase architectural risk. Classes from PECL extensions (e.g., Redis, Memcached, MongoDB\Driver\Manager) are not excluded and count toward CBO as regular dependencies. The exclusion does not depend on how the class is named: extends \RuntimeException counts toward CBO, Ca and Ce no more than implements \Countable or a \DateTimeImmutable type hint does. The extends edge itself is kept, because DIT and NOC read inheritance from it.

Deviation from original spec

The graph universe includes every named project class, interface, trait, and enum, even when it has no edges. CBO, Ca, and Ce deduplicate logical endpoints and retain undeclared external targets; degree-zero declarations therefore receive known zero values instead of missing metrics. A logical class score is projected to each exact owned declaration so declaration controls, baseline identities, and fingerprints remain independent.

Configuration

# qmx.yaml
rules:
  coupling.cbo:
    suppress_namespaces:
      - subtree: App\Core\ValueObject
    scope: application  # 'all' (default) or 'application' (uses CBO_APP)
    class:
      warning: 18
      error: 25
    namespace:
      enabled: true
      min_class_count: 5

For a simple pass/fail threshold on a single level:

rules:
  coupling.cbo:
    class:
      threshold: 18   # warning=18, error=18 → all violations are errors
bin/qmx check src/ --rule-opt="coupling.cbo:class.warning=18"
bin/qmx check src/ --rule-opt="coupling.cbo:class.error=25"
bin/qmx check src/ --rule-opt="coupling.cbo:namespace.min_class_count=5"
bin/qmx check src/ --rule-opt="coupling.cbo:namespace.enabled=false"
bin/qmx check src/ --rule-opt="coupling.cbo:scope=application"

Flat threshold shorthand (both levels at once): a bare threshold at the rule's own top level -- not nested under class:/namespace: -- sets warning/error to the same value on both the class and namespace dimensions in one go. This differs from complexity.ccn's top-level threshold, which applies to the callable level only: CBO's class/namespace defaults already match (14/20), so there is no single "primary" level to prefer.

rules:
  coupling.cbo:
    threshold: 15   # class AND namespace: warning=15, error=15
bin/qmx check src/ --rule-opt="coupling.cbo:threshold=15"

Shorthand expands before configuration layers merge

A top-level threshold sets both the class and namespace warning/error leaves in its own configuration layer. Writing it beside an overlapping nested threshold or band in that same layer refuses with exit 3; independent settings such as class.enabled remain valid. Across layers, the later layer replaces only the leaves it writes: threshold: 50 in qmx.yaml followed by --rule-opt="coupling.cbo:class.warning=1" and --rule-opt="coupling.cbo:class.error=1" sets the class band to 1/1 while leaving the namespace band at 50/50.

Top-level warning: / error: are options of this rule too. Unlike the complexity rules, coupling.cbo reads them at its own top level, where they open the same flat form as threshold and set both dimensions at once. They have always worked here; they are documented now because an option key that is not declared is refused, and refusing a key that works would be wrong.

rules:
  coupling.cbo:
    warning: 12     # class AND namespace
    error: 18

Framework CBO distinction

By default, CBO counts all dependencies equally: importing 50 PhpParser\Node\* types counts the same as depending on 50 application services. But framework coupling is structural (can't be eliminated without changing framework), while application coupling is architectural (should be minimized).

Qualimetrix provides two supplementary metrics to distinguish them:

Metric Formula Purpose
coupling.cbo-app Ca_app + Ce_app (framework deps excluded) Application-only coupling
coupling.ce-framework Count of efferent framework dependencies Informational

Configuration:

# qmx.yaml
coupling:
  framework-namespaces:
    - subtree: Symfony
    - subtree: PhpParser
    - subtree: Psr
    - subtree: Amp

These are explicit namespace selectors. subtree: Psr matches Psr\Log\LoggerInterface but not PsrExtended\Custom; exact and full-subject regex are also available.

Using with the CBO rule:

Set scope: application to make the CBO rule check coupling.cbo-app instead of coupling.cbo:

rules:
  coupling.cbo:
    scope: application
    class:
      warning: 10   # tighter thresholds make sense for app-only coupling
      error: 15

When no framework-namespaces are configured, coupling.cbo-app equals coupling.cbo (no effect).


Unmatched framework namespace

Rule ID: coupling.unmatched-framework-namespace

Severity: warning (fixed)

What it measures

A framework-namespaces selector that no name in the run matches.

The selector was written to move classes out of coupling.cbo-app and into coupling.ce-framework. One that matches nothing moves neither, so every application-scope coupling verdict is drawn from a wider set than you asked for. Nothing else in the run says so: a missed prefix produces exactly the report a run with no selector at all produces.

The framework namespace "Symfony\Bundle" matched no class the run analysed or
depends on. Nothing was moved out of the application scope for it, so
"coupling.cbo-app" still counts every class it was written to exclude and
"coupling.ce-framework" counts none of them.

The usual causes are a renamed vendor namespace, a leading backslash (\Symfony never matches — write the namespace as it appears in an import), and a dependency dropped in a refactor while its prefix stayed in qmx.yaml.

One finding per unbound selector, reported on the project. A selector is checked against both ends of every dependency edge — the targets, where external framework classes live, and the sources — because that is the set the metrics themselves classify.

When it stays silent

  • Declaration evidence is incomplete. Missing PHP, authored PHP removal, generated exclusions or an uncertain universe withhold namespace-absence claims; a covered report state alone does not authorize them.

  • No framework-namespaces configured. Nothing was claimed, so nothing failed.

  • The selector matched. Including a selector that only matches your own analysed classes: that still moves them out of the application scope, which is what the option does.
  • The run's dependency graph is empty. Check one file that depends on nothing and there was nothing to classify at all, so a correct project-wide qmx.yaml is not reported for that invocation.
  • No dependency graph was built. There is then no set of names to ask about, and the run says nothing rather than accusing every prefix at once.

Configuration

rules:
  coupling.unmatched-framework-namespace:
    enabled: true   # default

It is an ordinary rule finding rather than a configuration error: it answers to fail_on, --disable-rule=coupling.unmatched-framework-namespace, @qmx-ignore coupling.unmatched-framework-namespace and the baseline. A shared qmx.yaml that legitimately names a framework one repository does not use is the case that keeps it out of the refusal path.

The message ends by naming the configuration that wrote the selector list (Written by configuration file "…" (imported by configuration file "qmx.yaml").). A list from a shared base, installed or not, is judged: writing coupling.framework_namespaces in your own file replaces the base's list whole.


RFC -- Response for a Class

Metrics: coupling.rfc, coupling.rfc-own, coupling.rfc-external

RFC measures the size of a class's response set, using Chidamber and Kemerer's union of its methods and the targets directly invoked by those methods:

RFC = |M ∪ R₁ ∪ R₂ ∪ ...|

M contains every declared method, including abstract methods, plus the methods effectively imported from traits. A trait alias adds its exposed name; a visibility-only adaptation does not add another method. Class methods override trait methods, and trait conflicts follow insteadof. This applies to named classes, traits, interfaces and enums. Every enum also contributes its implicit cases() method; a backed enum contributes from() and tryFrom() as well. Methods inherited from a parent do not enter M; a direct call to an inherited or parent method enters the response set. Calls made inside a method's closure are included. Nested anonymous-class methods and expressions outside methods are excluded. Unread traits contribute no guessed methods to M; unresolved invoked targets use the fallback described below.

  • coupling.rfc-own is the size of M.
  • coupling.rfc-external counts distinct directly invoked responses outside M, including directly called inherited methods. “External” here means outside the measured declaration's effective method set, not outside the analysed project.
  • coupling.rfc is their sum. Repeated calls and calls to an already owned method do not increase it.

A statically known class response uses the resolved class name and method name, compared without regard to letter case. Aliases, fully qualified names and namespace-relative spellings of the same target count once. An imported trait alias refers to its original method body when deciding whether a called response is already owned. Responses in effective imported trait bodies use the consuming class's this/self/static/parent context. A named target outside the collected class roster retains its resolved class and method identity. A known receiver whose readable hierarchy has no matching method retains that receiver and method identity. An inherited target reached through unread ancestry cannot identify its declaring body and uses the method-name fallback.

Which calls contribute

Form Response identity
Own method via $this, self or static Already in M
Inherited or parent method Resolved declaring class and method, where readable
Imported trait method or alias Effective exposed method in M; invoked body resolved to its source
Typed non-variadic parameter, property or promoted-property receiver Resolved class and method, when the declared type identifies one class
Assignment of a known receiver to a local variable; direct positive instanceof branch The retained class and method, within that supported local scope
Named static call; new ClassName() Resolved class and method; construction uses __construct
Nullsafe call The same target as the ordinary method call
Chained return, array element, variadic parameter or untyped receiver Method-name fallback, shared by repeated calls to that name
Variable-class static call ($class::method()) Resolved class and method for a known object receiver; otherwise method-name fallback
Global function A separate function response, independent of method responses
First-class callable or dynamic method name Excluded

The fallback also applies when ancestry or a trait is unreadable, or method lookup is ambiguous. Declared types provide static evidence; RFC does not follow runtime dispatch, infer return types or perform general flow or PHPDoc type analysis. Distinct unknown receivers calling the same method name can therefore share one response. These are metric values, with no separate RFC rule or configurable finding threshold.

Deviation from original spec

  • Global functions contribute responses, and PHP 8.4 property hooks contribute to M; the original class metric has no corresponding PHP constructs. Hook property names remain case-sensitive. Imported hooks with conflicting property sources are omitted; PHP property compatibility is not validated.
  • Trait methods and adaptations use PHP composition rules, including nested traits, aliases, visibility changes and insteadof.
  • First-class callables are not invocations; dynamic method names are not resolved and do not contribute a response.
  • An unresolved receiver, unread trait or ancestor outside the analysed paths falls back to the method name. The result does not claim complete runtime dispatch resolution.

Example

class InvoiceService
{
    public function issue(Gateway $gateway): void
    {
        $gateway->send();
        $gateway->send();
    }
}
// rfc-own: 1 (issue); rfc-external: 1 (Gateway::send); rfc: 2

A larger response set means more behaviour to understand and test. Split responsibilities when one class reaches too many unrelated operations.


Instability

Rule ID: coupling.instability

Judged metric: coupling.instability on a class; coupling.instability-own on a namespace

What it measures

Instability measures the direction of dependencies for a class or namespace. It answers the question: "Does this class mostly depend on others, or do others mostly depend on it?"

The formula is:

I = Ce / (Ca + Ce)

Where:

  • Ca (Afferent coupling) = how many other classes depend on THIS class (incoming)
  • Ce (Efferent coupling) = how many classes THIS class depends on (outgoing)

The result is a number between 0.0 and 1.0:

  • I = 0.0 (maximally stable) -- many classes depend on this one, but it depends on nothing. Like a foundation: you cannot move it without breaking everything above. Stable classes should be abstract (interfaces, abstract classes) so they are safe to depend on.
  • I = 1.0 (maximally unstable) -- this class depends on many others, but nobody depends on it. Like a leaf on a tree: easy to change without affecting anything. Concrete implementations should typically be unstable.

Why is high instability bad?

An instability close to 1.0 means the class has many outgoing dependencies and nobody depends on it. While this sounds "free to change," it also means the class is very sensitive to changes in its dependencies. If any of those dependencies change, this class might break.

How to read the value:

I (Instability) Interpretation
0.0 Maximally stable (only depended upon) -- or not connected at all: with Ca = Ce = 0 the ratio is undefined and reported as 0 by convention
0.0--0.3 Stable -- hard to change, many dependents
0.3--0.7 Balanced
0.7--1.0 Unstable -- easy to change
1.0 Maximally unstable (only depends on others)

Thresholds

Class level (enabled by default):

Level Threshold Severity
Warning >= 0.8 Warning
Error >= 0.95 Error

Namespace level (enabled by default, requires at least min_type_count (default 3) types declared in the namespace itself):

Level Threshold Severity
Warning >= 0.8 Warning
Error >= 0.95 Error

Example

// Highly unstable class (I ≈ 1.0): depends on 5 things, nobody depends on it
class DailyReportJob
{
    public function __construct(
        private UserRepository $users,
        private ReportGenerator $generator,
        private EmailService $email,
        private Logger $logger,
        private Clock $clock,
    ) {}
    // Ca = 0 (nothing depends on this)
    // Ce = 5 (depends on 5 classes)
    // I = 5 / (0 + 5) = 1.0 -> ERROR
}

How to fix

  • Reduce outgoing dependencies. Fewer use statements and constructor parameters mean lower Ce.
  • Introduce abstractions. If the class is a service that others could benefit from, extract an interface. This increases Ca (other classes will depend on the interface) and reduces I.
  • Accept instability when it makes sense. Entry points like console commands, controllers, and cron jobs are naturally unstable (high Ce, low Ca). Consider disabling the rule for these or raising the threshold.

Deviation from original spec

Robert C. Martin (1994) originally defined Instability only at the package (namespace) level. Qualimetrix extends it to the class level for finer-grained analysis; the class-level metric is a Qualimetrix extension.

A namespace is not a package in Martin's sense once it has sub-namespaces, so Qualimetrix publishes two values and judges one. The rule judges coupling.instability-own, taken over the types declared directly in the namespace (Ca-own and Ce-own), the scope CBO and distance judge too. The subtree value coupling.instability folds in every outward dependency of the sub-namespaces while few dependents reach a parent that declares nothing itself, so the root of a bounded context reads close to 1 by construction. It stays published as aggregate evidence and feeds subtree distance, health.coupling and the HTML report's Martin diagram, but is not judged.

A namespace declaring no analysed type of its own publishes no coupling.instability-own and is never judged. One declaring only bare enums publishes the key but is not judged either: the judged population counts classes, traits, interfaces and implementing enums. A namespace that declares a type but has no own edge (Ca-own = Ce-own = 0) carries 0 by the convention above.

Configuration

# qmx.yaml
rules:
  coupling.instability:
    suppress_namespaces:
      - subtree: App\Core\ValueObject
    class:
      max_warning: 0.9
      max_error: 1.0
      min_afferent: 1    # default: 1
    namespace:
      min_type_count: 5
      min_afferent: 1    # default: 1

min_afferent -- minimum afferent coupling (Ca) required for a class or namespace to be checked; for a namespace it is the own afferent coupling, coupling.ca-own. Default: 1 (symbols with Ca = 0 are skipped). Set to 0 to check all symbols, or to 2 to also skip symbols with only one dependent. Symbols with very few dependents have high instability by definition, which is architecturally expected for concrete implementation classes.

namespace.min_type_count -- minimum number of types a namespace declares directly: classes, traits, interfaces and enums that implement an interface, as for Distance. Default: 3 (equality is admitted); 0 disables the floor, but a namespace is judged only if it declares at least one counted type, whatever the floor -- the same condition under which coupling.distance-own exists. Instability and distance are both package metrics of Martin's (D = |A + I - 1|) and judge one population. The earlier min_class_count counted classes of the whole subtree and is refused by name -- in YAML, as min-class-count and minClassCount, and in --rule-opt -- with exit code 3; rename the key and mind the new count.

For a simple pass/fail threshold on a single level:

rules:
  coupling.instability:
    class:
      threshold: 0.9   # warning=0.9, error=0.9
bin/qmx check src/ --rule-opt="coupling.instability:class.max_warning=0.9"
bin/qmx check src/ --rule-opt="coupling.instability:class.max_error=1.0"
bin/qmx check src/ --rule-opt="coupling.instability:class.min_afferent=0"
bin/qmx check src/ --rule-opt="coupling.instability:namespace.min_type_count=5"
bin/qmx check src/ --rule-opt="coupling.instability:namespace.min_afferent=2"

Flat threshold shorthand (both levels at once): a bare threshold at the rule's own top level -- not nested under class:/namespace: -- sets max_warning/max_error to the same value on both the class and namespace dimensions in one go, since their defaults already match (0.8/0.95).

rules:
  coupling.instability:
    threshold: 0.9   # class AND namespace: warning=0.9, error=0.9
bin/qmx check src/ --rule-opt="coupling.instability:threshold=0.9"

Shorthand expands before configuration layers merge

As with coupling.cbo, a top-level threshold expands to both class and namespace bands in its own layer. Overlapping shorthand and explicit band writes in one layer refuse with exit 3. In a later layer, a nested write replaces only the addressed leaves; for example, --rule-opt="coupling.instability:class.threshold=0.8" overrides a file's flat threshold: 0.9 for the class band while retaining namespace 0.9/0.9.

Top-level max_warning: / max_error: are options of this rule too. Like coupling.cbo's warning/error, they open the flat form at the rule's own top level and set both dimensions at once. They have always worked here and are documented now for the same reason.

rules:
  coupling.instability:
    max_warning: 0.85   # class AND namespace
    max_error: 0.95

Distance from Main Sequence

Rule ID: coupling.distance

Judged metric: coupling.distance-own

What it measures

This rule checks the balance between abstractness and stability of a namespace (group of classes). It is based on the idea that:

  • Stable packages should be abstract. If many classes depend on your package, it should consist of interfaces and abstract classes. That way, when you need to change behavior, you add a new implementation rather than modifying what others depend on.
  • Unstable packages should be concrete. If your package depends on many others but nothing depends on it, it should contain concrete implementations. There is no point making it abstract if nobody is going to implement the abstractions.

The formula is:

D = |A + I - 1|

Where:

  • A (Abstractness) = (abstract classes + interfaces) / (classes + traits + interfaces + enums implementing an interface) in the namespace (0.0 = all concrete, 1.0 = all abstract)
  • I (Instability) = the instability metric described above

The result is a number between 0.0 and 1.0:

  • D = 0.0 -- the namespace sits on the "main sequence," meaning it has a balanced mix of abstractness and stability.
  • D = 1.0 -- the namespace is far from the ideal balance.

There are two bad zones:

  • Zone of Pain (bottom-left): stable + concrete. Many classes depend on this package, but it is all concrete code. Any change will ripple outward. Solution: add interfaces.
  • Zone of Uselessness (top-right): unstable + abstract. The package is full of abstractions that nobody implements. Dead weight.

How to read the value:

Distance Interpretation
0.0--0.1 On the main sequence -- well balanced
0.1--0.3 Acceptable balance
0.3+ Off balance -- zone of pain or uselessness

Deviation from original spec

Robert C. Martin (1994) defined Abstractness over abstract classes and concrete classes only, so every PHP construct invented later has to be mapped onto that model. Qualimetrix treats a bare enum as neutral and leaves it out of the denominator entirely, rather than counting it as concrete: an enumeration of literals offers no substitution point, since it cannot be extended, subtyped or implemented. An enum X implements Y counts as concrete and stays in the denominator -- it is a concrete implementation of a declared contract, and without this exception a namespace holding one interface and N enums implementing it would report A = 1.0 while its implementations sit right beside it. Only explicit implements clauses count; the UnitEnum / BackedEnum interfaces that every enum satisfies implicitly do not. The shape of the formula is unchanged -- this is a scope adaptation for PHP, not a different metric. A namespace whose only declarations are bare enums has an empty denominator and publishes no abstractness or distance. An absent value is not a measured concrete A = 0.0.

Two scopes on a namespace, and the project number

A namespace that both declares classes of its own and contains sub-namespaces has two coupling scopes, and both are published:

Key Scope
coupling.ca / coupling.ce / coupling.instability / coupling.abstractness / coupling.distance The whole subtree rooted at this namespace; retained as aggregate evidence
coupling.ca-own / coupling.ce-own / coupling.instability-own / coupling.abstractness-own / coupling.distance-own Only the types declared directly in this namespace, leaving the ones its sub-namespaces hold to those namespaces

coupling.cbo and coupling.cbo-own split the same way, and there the rule also judges the own scope: see CBO. Instability is judged on the own scope too (coupling.instability-own): see Instability.

When the measured populations and required inputs coincide, both scopes have the same value. The absence of sub-namespaces alone does not establish that their inputs are applicable. A namespace that declares no type of its own carries no own-scope abstractness at all and gets no -own key for abstractness, instability or distance (coupling.cbo-own is absent there too): how far a package sits from the main sequence is undefined where there is no package, and silence says that where 0.0 would not.

Publication and judgement follow different populations. coupling.instability-own and coupling.cbo-own are published on a namespace declaring any analysed type; distance and the instability judgement count only classes, traits, interfaces and enums that implement an interface. A namespace declaring only bare enums therefore carries both -own keys but is no package for distance or instability, even with min_type_count: 0.

The project number is folded over own scopes. coupling.distance-own.avg is the eligible-type-weighted mean of measured coupling.distance-own across own namespaces. Each weight counts its own classes, traits, interfaces and explicitly implementing enums; bare enums are excluded. A namespace without measured own distance contributes no sample, and .count records the number of measured namespaces — the project-level key is coupling.distance-own.avg, and coupling.distance.avg does not exist. Folding the subtree rollups instead would count a parent's declarations once for the parent and again for each child. The older fold avoided that double count by taking leaf namespaces only, which instead dropped every parent namespace that declares classes of its own: in a tree where intermediate namespaces hold real code, a large share of the classes were represented in the project average by no value at all.

The population the fold runs over is also what the health coverage line is a share of; see What a Score Covers.

Thresholds

Level Threshold Severity
Warning >= 0.3 Warning
Error >= 0.5 Error

The rule judges coupling.distance-own; its message uses own abstractness, instability and coupling.

Only namespaces with at least 3 own types are analyzed (configurable via minTypeCount): classes, traits, interfaces and enums with an explicit implements clause. Bare enums do not count.

A namespace with no dependency in either direction (Ca-own = Ce-own = 0) is not judged either. Its instability is 0 by convention, not by measurement, so a concrete namespace that nothing uses and that uses nothing would read D = 1.0 and be reported as a zone of pain -- which needs many dependents it does not have. The distance is absent and does not enter project or health aggregation.

A namespace that lacks an input publishes no distance at all rather than one computed from 0. A namespace that declares only functions is not in the class dependency graph, so it has no instability and gets no coupling.distance.

Example

Consider a namespace App\Payment with 10 classes:

  • 0 interfaces or abstract classes (A = 0.0)
  • Many incoming dependencies from other modules (I = 0.2, quite stable)
  • D = |0.0 + 0.2 - 1| = 0.8 -- far from the main sequence (Error)

This namespace is in the Zone of Pain: it is stable (hard to change without breaking others) but has no abstractions (every change requires modifying concrete code).

How to fix

  • For packages in the Zone of Pain: Add interfaces. Extract contracts that other modules can depend on, while keeping implementations as details.
  • For packages in the Zone of Uselessness: Remove unused abstractions or make them concrete. Abstract classes that nobody extends are overhead.
  • Aim for the main sequence: Stable packages should be abstract; unstable packages should be concrete.

Configuration

# qmx.yaml
rules:
  coupling.distance:
    max_distance_warning: 0.4
    max_distance_error: 0.6
    min_type_count: 5
    include_namespaces:
      - subtree: App\Domain
      - subtree: App\Infrastructure
    suppress_namespaces:
      - subtree: App\Tests

For a simple pass/fail threshold:

rules:
  coupling.distance:
    threshold: 0.4   # warning=0.4, error=0.4
bin/qmx check src/ --rule-opt="coupling.distance:max_distance_warning=0.4"
bin/qmx check src/ --rule-opt="coupling.distance:max_distance_error=0.6"
bin/qmx check src/ --rule-opt="coupling.distance:min_type_count=5"

min_type_count counts classes, traits, interfaces and explicitly implementing enums declared directly in the namespace. Default 3 admits equality; 0 disables this floor. Missing count inputs contribute zero. The rule still requires published own distance; scopes without coupling publish none. Selected population accounting distinguishes these exclusions from healthy judgements. min_class_count, min-class-count and minClassCount are refused for Distance and for namespace Instability, which counts the same population under the same min_type_count; CBO retains its class-count option over own classes.

By default, project namespaces are auto-detected from composer.json (autoload.psr-4).

include_namespaces is a list of explicit exact, subtree, or regex namespace selectors. A bare scalar is refused. The generic --rule-opt door uses the same kind:value scalar form, so --rule-opt="coupling.distance:include_namespaces=subtree:App\Domain" selects that subtree; several selectors are written as a list in qmx.yaml.


ClassRank

Rule ID: coupling.class-rank

Judged metric: coupling.class-rank-share

What it measures

The raw coupling.class-rank remains PageRank probability mass r. The rule uses the separate share r / (1 / N) = r * N: how many times the vertex's rank exceeds the uniform rank in this measured graph. A share of 1 is uniform. N counts distinct measured logical class-like vertices, including isolated ones with logical subjects in the metric repository. Unmeasured external vertices stay outside this analysed PageRank population; duplicate declarations do not inflate it. Both metrics project to each exact declaration and provide namespace/project max, average and p95 values. The rule judges exact PHP classes, including abstract classes, with positive coupling.ca; interfaces, traits and enums retain evidence but are not judged. Every remedy this rule offers — extract an interface, split the class, depend on an abstraction — acts on a concrete class. A central interface is what those remedies produce, so judging it would flag the cure. A central enum is a shared closed vocabulary with no collaborators to split and nothing to hide behind an interface. A trait is copied into its users rather than called, so its rank does not describe a runtime hub. Their rank and share stay in --format=metrics, and the JSON report counts them under abstentions with the gate php-class.

Thresholds

Level Share Severity
Warning >= 5 times uniform Warning
Error >= 10 times uniform Error

Equality reaches the boundary. There is no square-root scaling. Overrides and configuration use share units. Messages expand from two to six decimal places to distinguish unequal values; remaining display collisions say they are rounded.

Example

// This class is used (directly or indirectly) by most of the project
class DatabaseConnection
{
    public function query(string $sql, array $params = []): Result { /* ... */ }
    public function beginTransaction(): void { /* ... */ }
    public function commit(): void { /* ... */ }
    public function rollback(): void { /* ... */ }
}

// UserRepository depends on DatabaseConnection -> votes for it
// OrderRepository depends on DatabaseConnection -> votes for it
// PaymentService depends on DatabaseConnection -> votes for it
// ReportGenerator depends on DatabaseConnection -> votes for it
// AuditLogger depends on DatabaseConnection -> votes for it
//
// If these dependents are themselves important (high rank),
// DatabaseConnection's ClassRank grows even further.
// ClassRank share = 12 -> ERROR (12 times the uniform rank)

How to fix

  • Extract an interface. Create DatabaseConnectionInterface and depend on that. This applies the Dependency Inversion Principle: high-level modules depend on abstractions, not concrete classes.
  • Split god-class responsibilities. If the class does too many things, break it apart. For example, split DatabaseConnection into QueryExecutor, TransactionManager, etc.
  • Reduce transitive importance. If the dependents of this class are themselves hubs, refactoring them to depend on abstractions will lower the ClassRank of this class too.

Implementation notes

PageRank keeps damping 0.85, at most 100 iterations and convergence epsilon 1e-6. Dangling vertices redistribute their rank evenly. Raw ranks sum to 1; shares sum to N before projection to duplicate declarations. An empty graph publishes neither metric, a singleton publishes raw 1 and share 1. Share values are relative to the current graph population; narrowed and whole runs can differ. Prioritization consumes share, uses its measured median for an unranked finding, and publishes coupling.class-rank-share in JSON top issues. It never substitutes raw probability for missing share.

Deviation from original spec

Namespace and project ClassRank aggregates sample physical class declarations. Each declaration repeats its logical node's rank and share, so sums, counts and averages are declaration-weighted. Raw PageRank sums to 1 over logical nodes before this projection, not over declarations.

The recommendation counts direct dependents (Ca). PageRank also reflects transitive dependency paths, so one direct dependent that is itself a hub can produce a high share. The report does not count transitive dependents. Distance and Instability use the same expanding precision and explicit display rounded marker as ClassRank when a value and threshold would otherwise look equal.

Configuration

rules:
  coupling.class-rank:
    warning: 6
    error: 12
bin/qmx check src/ --rule-opt="coupling.class-rank:warning=6"
bin/qmx check src/ --rule-opt="coupling.class-rank:error=12"

Migrate old raw/scaled thresholds and baseline entries to the share metric; there is no raw-key fallback or option alias. See default thresholds.

Independent layer bands

CBO and instability spread a top band to class and namespace in its writing layer. Explicit writes to the same expanded leaves conflict in that layer; independent level settings such as enabled are preserved. Configure distinct level bands explicitly or put overlapping overrides in another layer. Effective-band validation includes written and default halves. These forms do not change metric direction, formulas or numeric defaults. A written enabled:true intentionally overrides a lower disable. See Configuration.

Dependency position and type shape

An edge type names the reference position: type_hint for parameter/return, property_type for a property or promoted parameter, and constant_type for a typed constant. Union/intersection/DNF are not separate relation kinds. Graph JSON always publishes shape as an object mapping each type position to its sorted observed shape names (single, nullable, union, intersection, dnf), for example {"property_type": ["nullable"], "type_hint": ["union"]}. An edge with no type-shape facts has shape: {}. Declaration facts (kind, parents/interfaces/traits, attribute sites and __toString) travel separately from edges. Implicit Stringable does not invent dependency edges or increase coupling. Logical PHP class identity is ASCII-folded; exact source declarations remain separate.