Configuration reference

Settings live under [analyzer] in mago.toml.

[analyzer]
ignore = ["mixed-argument"]
baseline = "analyzer-baseline.toml"

General options

OptionTypeDefaultDescription
excludesstring[][]Paths or glob patterns to exclude from analysis. Additive to [source].excludes.
ignore(string | object)[][]Issue codes to ignore, optionally scoped to specific paths. See below.
baselinestringunsetPath to a baseline file. Equivalent to passing --baseline on every run. The CLI flag overrides this.
baseline-variantstring"loose"Format for newly generated baselines. "loose" (count-based) or "strict" (exact line matching). See baseline.
minimum-fail-levelstring"error"Minimum severity that causes a non-zero exit. One of "note", "help", "warning", "error". Overridden by --minimum-fail-level.

excludes here is added to whatever you set in [source].excludes; it never narrows the global list.

[source]
excludes = ["cache/**"]

[analyzer]
excludes = ["tests/**/*.php"]

Path-scoped ignoring

ignore accepts plain strings, single-path objects, and multi-path objects, mixed freely:

[analyzer]
ignore = [
    "mixed-argument",
    { code = "missing-return-type", in = "tests/" },
    { code = "unused-parameter", in = ["tests/", "src/Generated/"] },
]

Each entry in in is either a directory or file prefix, or a glob pattern. Any value containing *, ?, [, or { is treated as a glob and matched against the full relative path; everything else is matched as a prefix. "tests" and "tests/" both match every file under tests.

[analyzer]
ignore = [
    { code = "mixed-assignment", in = [
        "tests/",
        "src/Legacy/**/*.php",
        "modules/*/Generated/*.php",
    ] },
]

Glob matching honours the project-wide settings under [source.glob], so toggles like literal-separator and case-insensitive apply here as well.

excludes and ignore are not the same. excludes removes files from analysis entirely, so they are not parsed for type information. ignore still analyses the file but suppresses the listed codes in the output.

Feature flags

These flags toggle individual analyses. Defaults are tuned for everyday use; flip them on as your codebase tightens up.

OptionDefaultDescription
find-unused-expressionstrueReport expressions whose result is discarded, like $a + $b;.
find-unused-definitionstrueReport private definitions that are never referenced.
find-overly-wide-return-typesfalseWarn when a declared return type contains a branch the body never produces, like : string|false on a function that always returns a string. Available since 1.20.0.
analyze-dead-codefalseAnalyse code that appears unreachable.
memoize-propertiestrueTrack literal property values for sharper inference, at the cost of some memory.
allow-possibly-undefined-array-keystrueAllow accessing keys that may be missing without flagging it.
check-throwsfalseReport exceptions that are not caught and not declared with @throws.
check-missing-overridefalseReport missing #[Override] attributes on overriding methods (PHP 8.3+).
find-unused-parametersfalseReport parameters that are never read.
strict-list-index-checksfalseRequire any integer used as a list index to be provably non-negative.
no-boolean-literal-comparisonfalseDisallow direct comparisons to boolean literals like $a === true.
check-missing-type-hintsfalseReport missing type hints on parameters, properties, and return types.
check-closure-missing-type-hintsfalseExtend the type-hint check to closures (requires check-missing-type-hints).
check-arrow-function-missing-type-hintsfalseExtend the type-hint check to arrow functions (requires check-missing-type-hints).
allow-implicit-pipe-callable-typesfalseSkip the closure / arrow-function type-hint checks when the callable is the right-hand side of |>.
register-super-globalstrueRegister PHP superglobals like $_GET and $_POST automatically.
trust-existence-checkstrueNarrow types based on method_exists(), property_exists(), function_exists(), and defined().
check-property-initializationfalseVerify that typed properties are initialised in a constructor or class initialiser.
check-use-statementsfalseReport use statements that import non-existent classes, functions, or constants.
check-name-casingfalseReport incorrect casing when referencing classes, functions, etc. Helps prevent autoload failures on case-sensitive filesystems.
enforce-class-finalityfalseReport classes that are not final, abstract, or annotated @api and have no children.
require-api-or-internalfalseRequire abstract classes, interfaces, and traits to be annotated @api or @internal.
check-experimentalfalseReport use of @experimental symbols from non-experimental contexts. Available since 1.19.0.
allow-side-effects-in-conditionstrueWhen false, report calls to impure functions inside if, while, for, ternary, or match conditions.

Property initialization

When check-property-initialization is enabled, the analyzer reports two issues:

  • missing-constructor for classes with typed properties and no constructor.
  • uninitialized-property for typed properties not assigned in the constructor.

class-initializers lets you mark additional methods that should count as initialisers, alongside __construct. Properties assigned in those methods are treated as definitely initialised. This is useful for frameworks that use lifecycle methods.

OptionTypeDefaultDescription
class-initializersstring[][]Method names treated as class initialisers.
[analyzer]
check-property-initialization = true
class-initializers = ["setUp", "initialize", "boot"]

With this configuration, the following code does not trigger a false positive:

class MyTest extends TestCase
{
    private string $name;

    protected function setUp(): void
    {
        $this->name = "test";
    }
}

Exception filtering

When check-throws is enabled, two options let you skip specific exceptions.

OptionTypeDefaultDescription
unchecked-exceptionsstring[][]Exceptions to ignore, including all subclasses (hierarchy-aware).
unchecked-exception-classesstring[][]Exceptions to ignore as exact class matches only. Subclasses and parents are still checked.
[analyzer]
check-throws = true

unchecked-exceptions = [
    "LogicException",
    "Psl\\Type\\Exception\\ExceptionInterface",
]

unchecked-exception-classes = [
    "Psl\\File\\Exception\\FileNotFoundException",
]

Use unchecked-exceptions to silence an entire category, like every LogicException subclass. Use unchecked-exception-classes when you want to ignore one specific exception while still tracking siblings and parents.

Experimental API detection

Set check-experimental = true to flag use of @experimental symbols from non-experimental code. Mark the symbol with the PHPDoc tag:

/** @experimental */
class UnstableApi {}

/** @experimental */
function beta_feature(): void {}

The analyzer warns when these are used from stable code:

new UnstableApi();              // warning
beta_feature();                 // warning
class MyService extends UnstableApi {}  // warning

Use from another experimental context is allowed:

/** @experimental */
function also_experimental(): void {
    new UnstableApi();
    beta_feature();
}

class StableService {
    /** @experimental */
    public function experimentalMethod(): void {
        new UnstableApi();
    }
}

Plugins

Plugins ship type providers for libraries and frameworks, so functions return precise types instead of generic ones.

OptionTypeDefaultDescription
disable-default-pluginsboolfalseDisable all default plugins. Only the names you list in plugins are active.
pluginsstring[][]Plugins to enable, by name or alias.

Available plugins

PluginAliasesDefaultDescription
stdlibstandard, std, php-stdlibenabledPHP built-in functions: strlen, array_*, json_*, and friends.
pslphp-standard-library, azjezz-psldisabledphp-standard-library.
flow-phpflow, flow-etldisabledflow-php/etl.
psr-containerpsr-11disabledpsr/container.

For example, the stdlib plugin teaches the analyzer that strlen($s) returns int<0, max>, that json_decode($json, true) returns array<string, mixed>, and that array_filter($array) keeps the input shape but possibly drops elements.

Examples

Use the defaults (just stdlib):

[analyzer]

Enable additional plugins:

[analyzer]
plugins = ["psl", "flow-php", "psr-container"]

Disable everything:

[analyzer]
disable-default-plugins = true

Use only one plugin:

[analyzer]
disable-default-plugins = true
plugins = ["psl"]

Plugin aliases work everywhere, so plugins = ["std"] is the same as plugins = ["stdlib"].

Strict mode

The analyzer runs at a moderate strictness by default. Crank it up by enabling more checks; ease it off for legacy code.

Maximum strictness

[analyzer]
find-unused-expressions = true
find-unused-definitions = true
find-overly-wide-return-types = true
analyze-dead-code = true
check-throws = true
check-missing-override = true
find-unused-parameters = true
check-missing-type-hints = true
check-closure-missing-type-hints = true
check-arrow-function-missing-type-hints = true
enforce-class-finality = true
require-api-or-internal = true
check-experimental = true

strict-list-index-checks = true
no-boolean-literal-comparison = true

allow-possibly-undefined-array-keys = false
trust-existence-checks = false

Lenient mode

[analyzer]
check-missing-type-hints = false
strict-list-index-checks = false
no-boolean-literal-comparison = false
enforce-class-finality = false
require-api-or-internal = false

allow-possibly-undefined-array-keys = true
trust-existence-checks = true

check-throws = false

When introducing Mago to an existing codebase, start lenient with a baseline and tighten the screws as the code improves.

Notes on individual flags

trust-existence-checks decides whether the analyzer narrows on runtime checks. With it on (the default), this is fine:

function process(object $obj): mixed
{
    if (method_exists($obj, 'toArray')) {
        return $obj->toArray();
    }

    return null;
}

Turn it off and the call requires an explicit type guarantee instead.

allow-implicit-pipe-callable-types skips the closure / arrow-function type-hint checks when the callable is the right operand of |>. The pipe's left operand carries enough type information to derive the parameter, so the missing hint is harmless there.

Performance tuning

The analyzer uses internal thresholds to balance depth against speed. Settings live under [analyzer.performance].

OptionTypeDefaultDescription
saturation-complexity-thresholdu168192Maximum clauses during CNF saturation.
disjunction-complexity-thresholdu164096Maximum clauses per side in OR operations.
negation-complexity-thresholdu164096Maximum cumulative complexity when negating formulas.
consensus-limit-thresholdu16256Upper limit for consensus optimisation passes.
formula-size-thresholdu16512Maximum logical formula size before simplification is skipped.
string-combination-thresholdu16128Maximum literal strings tracked before generalising to string.
integer-combination-thresholdu16128Maximum literal integers tracked before generalising to int.
array-combination-thresholdu1632Maximum sealed keyed-array shapes tracked individually before merging.
loop-assignment-depth-thresholdu81Maximum loop fixed-point iteration depth. 0 disables re-iteration.

string-concat-combination-threshold is still accepted as an alias for string-combination-threshold.

When to adjust

Defaults work for most projects. Lower the thresholds if analysis feels too slow at the cost of some inference precision. Raise them if you need deeper inference on highly conditional code.

What each threshold controls

The analyzer turns type constraints into Conjunctive Normal Form (CNF) logical formulas. These can grow exponentially with complex conditions, so the thresholds prevent runaway computation.

  • Saturation complexity caps clauses processed during formula simplification. When exceeded, simplification stops early.
  • Disjunction complexity bounds clause growth when combining OR. Wide unions and many branches can hit this.
  • Negation complexity bounds expansion when negating formulas, for example computing else branches from a complicated if.
  • Consensus limit caps an optimisation pass that detects logical tautologies. Higher values may find more simplifications.
  • Formula size is the overall complexity ceiling before the analyzer falls back to simpler inference.
  • String / integer combination caps how many literal values are tracked before the analyzer widens to string or int. Without these, very large arrays or switch statements would push combine cost to O(n²).
  • Array combination caps how many distinct sealed keyed-array shapes are kept separate during type combination. When procedural code accumulates many slightly different shapes for the same variable across branches, the combiner keeps each one until this threshold is hit and merges them into a generalised shape. Increase it for code that depends on very precise per-key narrowing.
  • Loop assignment depth caps how many fixed-point iterations the loop analyzer runs over each loop body. With a chain of N loop-carried dependencies, up to N extra passes may be needed for types at the end of the chain to fully stabilise; each pass re-analyses the whole body. The default of 1 is enough for almost all real code. Raise it to 2 or 3 for codebases that need very precise narrowing of deep loop-carried chains. 0 disables re-iteration entirely, which is the fastest setting but may leave some self-dependent types wider than necessary.

Examples

Fast analysis, lower precision:

[analyzer.performance]
saturation-complexity-threshold = 2048
disjunction-complexity-threshold = 1024
negation-complexity-threshold = 1024
consensus-limit-threshold = 64
formula-size-threshold = 128
string-combination-threshold = 64
integer-combination-threshold = 64
array-combination-threshold = 16
loop-assignment-depth-threshold = 1

Deep analysis, slower:

[analyzer.performance]
saturation-complexity-threshold = 16384
disjunction-complexity-threshold = 8192
negation-complexity-threshold = 8192
consensus-limit-threshold = 512
formula-size-threshold = 1024
string-combination-threshold = 256
integer-combination-threshold = 256
array-combination-threshold = 256
loop-assignment-depth-threshold = 4

Raising thresholds can swing analysis time noticeably on codebases with heavy conditional logic or files with thousands of array operations. Test on your project before deploying to CI.

Diagnosing slow runs

If Mago feels slow, that usually points to a bug in Mago rather than normal behaviour. For reference, on an Apple M1 Pro the analyzer covers all of WordPress/wordpress-develop in under two seconds and php-standard-library/php-standard-library in under 200 milliseconds. Numbers will vary with hardware and project size, but as a rough threshold: if Mago takes more than 30 seconds to analyse your project, something is off, either in Mago or in a pathological input it is tripping over.

The same applies to a regression you notice between releases. If a previously fast analysis suddenly becomes slow, that is worth reporting.

Re-run with MAGO_LOG=trace to get a full pipeline trace:

MAGO_LOG=trace mago analyze

With tracing on, Mago:

  • Starts a hang watcher that flags any single file analysing for more than a few seconds. Useful for catching the file that sends the analyzer into a long or infinite loop.
  • Prints the slowest files seen during the parallel analyse phase, so you can see which inputs dominated total time.
  • Emits per-phase durations for source discovery, compilation, codebase merge, metadata population, parallel analyse, reduce, and so on, so you can tell which stage is responsible.

When reporting a slow run or a regression, include the full trace output and the file the hang watcher points at. Anonymising names and scrubbing sensitive literals is enough; we just need to reproduce the shape of the input.

↳ Edit this page →