Search by

krazybean / untangled

krazybean

Dependency health your whole team can understand: cycles and coupling in PHP projects, in a report anyone can read.

v1.1.0 2026-11-05 05:55 UTC

This package is auto-updated.

Last update: 2026-11-05 05:57:35 UTC


README

The PHP library and CLI of Untangled. It scans a Composer project into the pairs document defined by the shared spec, analyzes it for cycles and coupling (metrics, clustering), and writes a report anyone on the team can read.

Untangled is a re-imagining of snakefood by Martin Blais; the scan → pairs → cluster → render pipeline is his design. See Credit.

Install

Requires PHP 8.2+. The only runtime dependency is nikic/PHP-Parser.

Install it from Packagist, as a dev dependency of the project you want to look at, or globally:

composer require --dev krazybean/untangled
vendor/bin/untangled --version

composer global require krazybean/untangled    # puts `untangled` on your PATH

utg is a short alias for untangled; every example below works with either. The examples use vendor/bin/untangled; with a global install it is just untangled. Untangled reads the project you point it at and never runs its code, so it does not need to be installed inside that project.

Quick start

# A report to share: one HTML file, open it in any browser
vendor/bin/untangled report /path/to/project --cluster=namespace:2 --output=report.html

# The numbers behind it, as JSON
vendor/bin/untangled analyze /path/to/project --cluster=namespace:2

# The raw dependency list
vendor/bin/untangled scan /path/to/project --output=pairs.json

The path is the folder that holds the project's composer.json. It defaults to the current folder.

Commands

untangled scan    [<path>]               [scan options] [--format=json|snakefood] [--output=<file>]
untangled analyze [<path>|<pairs.json>]  [scan options] [filter options] [--output=<file>]
untangled report  [<path>|<pairs.json>]  [scan options] [filter options]
                                         [--format=html|dot] [--style=classic] [--title=<text>] [--output=<file>]
untangled dependents [<path>|<pairs.json>] <file-or-symbol>... [scan options] [--include/--exclude ...]
                                         [--transitive] [--max-depth=<n>] [--through=<glob>]...
                                         [--format=text|json] [--output=<file>]

Each command runs the steps before it: analyze scans first, and report scans and analyzes first. analyze and report also accept a pairs document saved by scan, so a large project can be scanned once and analyzed many ways. untangled --help prints the same summary.

scan

Writes every file-to-file dependency of the project as a list of (from, to) pairs.

vendor/bin/untangled scan /path/to/project                          # pairs document (JSON) to stdout
vendor/bin/untangled scan /path/to/project --output=pairs.json      # ... to a file
vendor/bin/untangled scan /path/to/project --dev                    # include autoload-dev (tests)
vendor/bin/untangled scan /path/to/project --format=snakefood       # snakefood's format, for sfood-* tools

The default output is the pairs document: one node per file (with its namespace and the classes it declares) and per package, builtin or unresolved name it uses, and one edge per dependency with how it was made (use, extends, new, include and so on) and how often. For example:

{
  "format": "untangled-pairs",
  "language": "php",
  "nodes": [
    { "id": "src/Cart.php", "kind": "internal", "module": "Shop", "symbols": ["Shop\\Cart"] },
    ...
  ],
  "edges": [ ... ]
}

--format=snakefood writes the same dependencies the way snakefood's sfood did, one Python tuple per line, so existing sfood-* scripts can read them:

(('.', 'src/Cart.php'), ('.', 'src/Order.php'))
(('.', 'src/Catalog.php'), (None, None))

analyze

Writes cycles and coupling metrics as JSON (the analysis document).

vendor/bin/untangled analyze /path/to/project                         # per file
vendor/bin/untangled analyze /path/to/project --cluster=namespace:2   # per namespace, e.g. App\Billing
vendor/bin/untangled analyze /path/to/project --cluster=folder:2 --exclude='src/Legacy/**'
vendor/bin/untangled analyze pairs.json --cluster=namespace:2         # from a saved scan

It holds:

  • summary: number of units and dependencies, number of cycles, the largest one, how many units sit in a cycle, mean fan-out, and how many external packages and unresolved references were found;
  • units: per file (or per cluster with --cluster): fan-in, fan-out, instability I = Ce / (Ca + Ce), external and unresolved counts, and ca / ce for clusters;
  • cycles: every dependency cycle, its members and one example loop, such as src/Cart.php → src/Order.php → src/Cart.php.

What each number means, and why it matters, is in metrics.

dependents

Lists the files that depend on one or more files, before you change them or when you choose which tests to run. A target is a node id (a file path) or, where the scanner records them, a declared symbol (App\\Billing\\Tax).

vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php
vendor/bin/untangled dependents /path/to/project App\\Billing\\Tax --transitive
vendor/bin/untangled dependents pairs.json src/Billing/Tax.php --transitive --format=json
vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --exclude='**' --include='tests/**'

By default it lists the direct dependents. --transitive adds the files that depend on those, and so on, each with its depth (the shortest distance from a target). --include and --exclude filter the list, not the walk, so a test that reaches a target only through application code is still found (scan with --dev to have tests in the graph). The text form groups files by folder; --format=json writes the document of the spec. The first argument is the source when it is a folder or a .json file; any other argument is a target, and the source is the current folder. A target that matches nothing exits with code 1.

To choose the tests to run after a change, limit the walk instead of the list. --max-depth=N (with --transitive) stops after N steps from the targets, and --through=<glob> (repeatable, any match counts) only walks into files that match, so nothing is found by way of other files:

# tests that use the file, or reach it only through other tests (helpers, builders, base classes)
vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --through='tests/**'
# application code that reaches it, not through tests
vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --through='src/**'
# tests of the files that use it directly
vendor/bin/untangled dependents /path/to/project src/Billing/Tax.php --transitive --max-depth=2 --exclude='**' --include='tests/**'

A file that --through leaves out is never listed, even when it uses a target directly, and it is not walked through. --include and --exclude still only filter the list afterwards, and --max-depth without --transitive is a usage error (exit code 2). A target can also be an absolute path inside the project, or a path with ./ or \ separators. A class name may start with a backslash (\App\Billing\Tax).

report

Writes one self-contained HTML page meant for people who do not read code. It needs no network access to open and can be emailed or attached to a ticket as is.

vendor/bin/untangled report /path/to/project --output=report.html
vendor/bin/untangled report /path/to/project --cluster=namespace:2 --title="Billing" --output=report.html
vendor/bin/untangled report pairs.json --cluster=folder:2 --output=report.html

The page has a summary in plain words, why coupling and cycles matter, an interactive map of the dependencies (capped at the 300 most connected units; cluster to see the whole project), each cycle written out as a loop, the parts to look at first, and every metric with a short explanation. The map can be downloaded from the page as a Graphviz .dot file.

--format=dot writes that Graphviz file directly, for rendering with dot or pasting into another tool. Units in a cycle are drawn in red. --style=classic draws the black and white look of snakefood's sfood-graph instead (grey ellipses, packages and unresolved names in white, PHP builtins left out):

vendor/bin/untangled report /path/to/project --format=dot | dot -Tsvg > graph.svg
vendor/bin/untangled report /path/to/project --format=dot --style=classic --cluster=namespace:2 | dot -Tpng > graph.png

The file asks for Graphviz's dot layout itself, so other Graphviz programs and viewers lay it out the same way. On a large project, render a clustered graph (--cluster=namespace:2, or folder:2) rather than one node per file: thousands of files take dot minutes and come out too small to read.

Options

Options are written --name=value; a value with spaces needs quotes (--title="Billing team"). Options marked repeatable can be given more than once.

Option Commands What it does
--dev all Also scan the autoload-dev rules of composer.json, usually the tests.
--source=<path> all Also scan the .php files under <path> (relative to the project) that composer.json does not autoload, such as a legacy lib/ or a Laravel app's routes/ and config/. The folder is also used to resolve include / require paths, like PHP's include_path. Repeatable.
--aliases=<preset|file> all Resolve global class aliases a framework registers at runtime (class_alias). laravel is the built-in preset; a JSON file declares your own. Repeatable, a later one wins. See Class aliases.
--format=<format> scan, report scan: json (default, the pairs document) or snakefood. report: html (default) or dot.
--style=<style> report --format=dot default, or classic for snakefood's black and white sfood-graph look.
--title=<text> report The report's title. Default: the Composer package name, else the folder or file name.
--cluster=<strategy> analyze, report Group files before measuring: namespace:N, folder:N or rules:<file>. See Clustering.
--transitive dependents Also list the files that depend on a dependent, with their depth.
--max-depth=<n> dependents With --transitive, stop after n steps from the targets (a positive integer).
--through=<glob> dependents Only walk into files matching <glob>; nothing is found by way of other files. Repeatable, any match counts.
--strict analyze, report With --cluster=rules:<file>, exit 1 instead of warning when a file matches no rule. See Clustering.
--include=<glob> analyze, report Keep files whose path matches <glob>. Repeatable.
--exclude=<glob> analyze, report Drop files whose path matches <glob>. Repeatable. See Include and exclude.
--output=<file> all Write to <file> instead of standard output.
--diagnostics=<format> all How warnings and errors are written to standard error: text (default), or json, one object per line. See Diagnostics for programs.
-h, --help Print the usage summary.
-V, --version Print the version.

An option a command does not take (scan --cluster=…) is an error, not silently ignored.

Clustering

Without --cluster, every file is its own unit. With it, files are grouped and the metrics describe the groups, which is usually what a team wants to look at.

Strategy Groups files by Example
namespace:N The first N segments of the file's namespace. Files with no namespace fall back to folder:N. namespace:2: App\Billing\Tax → App\Billing
folder:N The first N folders of the file's path. folder:2: src/Billing/Tax.php → src/Billing
rules:<file> Your own prefix rules, read from <file>. see below

A rules file has one rule per line, a path prefix and a cluster name separated by spaces; # starts a comment. The first prefix that matches a file wins, and a file no rule matches stays on its own. This is the format of snakefood's sfood-cluster. So that a new folder does not drop out of the grouped view unnoticed, analyze and report print a warning on standard error that names the files no rule matches; add --strict to make it an error (exit code 1, no output), for CI.

# prefix              cluster
src/Billing/          Billing
src/Invoice           Billing
src/                  Core
vendor/bin/untangled report /path/to/project --cluster=rules:clusters.rules --output=report.html

Packages, builtins and unresolved names are never clustered. The details are in clustering.

Include and exclude

--include and --exclude take globs matched against the whole file path, relative to the project (src/Billing/Tax.php). * matches within one folder, ? one character, and ** any number of folders (**/ also matches none, so **/*Test.php matches FooTest.php at the top level too).

The patterns are checked in the order given and the last one that matches wins; a file no pattern matches is kept. So:

# Everything except the legacy code
--exclude='src/Legacy/**'

# Only Billing: drop everything, then add Billing back
--exclude='**' --include='src/Billing/**'

# Billing, but not its tests
--exclude='**' --include='src/Billing/**' --exclude='**/*Test.php'

Quote globs so the shell does not expand them. Removing a file removes its dependencies too, and a package or unresolved name nothing remaining uses disappears from the report.

Class aliases

Frameworks can register short global names for classes at runtime: in Laravel, \DB means Illuminate\Support\Facades\DB. A scanner cannot see that, so by default such names are reported as unresolved. Declare them with --aliases:

vendor/bin/untangled scan /path/to/project --aliases=laravel
vendor/bin/untangled scan /path/to/project --aliases=laravel --aliases=my-aliases.json

The laravel preset holds Laravel's default facade aliases, plus the aliases installed packages declare under extra.laravel.aliases in their composer.json. Your own file looks like this:

{
  "aliases": {
    "DB": "Illuminate\\Support\\Facades\\DB",
    "Money": "App\\Support\\Money"
  }
}

When two declarations name the same alias, the later --aliases wins. Aliases nobody declared stay unresolved.

Recipes

Scan once, analyze many ways. Scanning is the slow step on a large project:

vendor/bin/untangled scan /path/to/project --source=lib --output=pairs.json
vendor/bin/untangled report pairs.json --cluster=namespace:2 --output=by-namespace.html
vendor/bin/untangled report pairs.json --cluster=folder:1 --output=by-folder.html

Scan options (--dev, --source, --aliases) only apply when scanning, so pass them to scan in this case.

A Laravel application. Routes and config are not autoloaded, and facades are aliases:

vendor/bin/untangled report /path/to/app --source=routes --source=config --aliases=laravel \
  --cluster=namespace:2 --output=report.html

A legacy application. Add the folders its own autoloader or include_path uses; the scanner's warnings name folders with .php files it left out:

vendor/bin/untangled report /path/to/app --source=lib --source=includes --cluster=folder:2 --output=report.html

A picture for a slide. Render the classic graph per namespace:

vendor/bin/untangled report /path/to/project --format=dot --style=classic --cluster=namespace:2 \
  | dot -Tsvg > dependencies.svg

Feed snakefood tools.

vendor/bin/untangled scan /path/to/project --format=snakefood | sfood-graph | dot -Tpng > graph.png

What it reads

The full rules are in the PHP profile. In short:

  • The project must have a composer.json. Its autoload rules (psr-4, psr-0, classmap, files) decide which files are scanned; vendor/ never is.
  • Code that Composer does not autoload is added with --source=<folder>.
  • Global aliases a framework registers at runtime are resolved only when declared with --aliases.
  • include / require with a literal path (string literals, __DIR__, dirname()) are followed, and the included file is scanned too. Paths built at runtime are reported as unresolved.
  • Every class reference resolves to a project file, a Composer package (from vendor/composer/installed.json or composer.lock), a PHP builtin, or an unresolved node. Builtins are left out of analyze and report; packages and unresolved names are listed but do not count toward the coupling metrics.

Exit codes and warnings

Code Meaning
0 Success.
1 The input could not be read: no composer.json, an unreadable pairs, rules or aliases file, or an output file that cannot be written.
2 Usage error: unknown command, option, format, style or clustering strategy.

Warnings go to standard error and do not change the exit code, so output piped to a file stays clean. The scanner warns when .php files were left out (naming the folders that hold them, so you can decide what to pass to --source), when there is no vendor/ or composer.lock to trace packages with or the one it finds is not valid JSON, when it finds no files to scan at all, and for each file that does not parse. Such a file stays in the pairs document marked "error": "parse", and the report lists it under "Files we could not read".

Diagnostics for programs

Everything the commands have to say about a project goes to standard error; standard output holds only the result. By default that is text, one warning: ... or error: ... line each. --diagnostics=json writes one JSON object per line instead, with a stable code to branch on, so a CI job or an agent never has to read the wording of a message:

vendor/bin/untangled scan /path/to/project --diagnostics=json --output=pairs.json 2> diagnostics.jsonl
jq -r 'select(.code == "uncovered-files") | .files[]' diagnostics.jsonl
{"level":"warning","code":"file-unparseable","message":"src/Broken.php: parse error: ...","files":["src/Broken.php"]}

level is warning or error (the reason a command stopped, with exit code 1), code names the condition, message is for people, and files lists the project-relative paths it is about when there are any. The exit code and the standard output do not change. The codes are listed in the diagnostics specification, which also says how to read the lines. --diagnostics=github (annotations for GitHub Actions) is not supported yet; a formatter can be built on the JSON lines. A usage error (exit code 2) is always text.

From PHP, Scanner::scan() returns a ScanResult whose diagnostics are Untangled\Diagnostic objects (level, code, message, files, toArray(), toJson()); its older warnings list of strings is kept.

Development

composer install
composer test

composer test runs PHPUnit (a dev dependency in composer.json). The suite has one test file per class under tests/, mirroring src/, plus tests/ConformanceTest.php, which runs every shared case in conformance/cases. Tests that need files on disk write a throwaway project with tests/TempProject.php.

License

MIT, see LICENSE.