PHPStan without an autoloader

A site without Composer autoload loads its classes with require_once and a path built from $_SERVER['DOCUMENT_ROOT']. PHPStan does not run that code, so it never learns where the classes are and reports every one of them as unknown. This manual shows the two ways to tell it: scanDirectories and scanFiles, the trap of hidden folders such as src/.php/, and the three ways a scanFiles list breaks as the tree changes.

Tools used here: PHPStan 2.2.14, PHP 8.2 and Docker with the php:8.2-cli image, Bash.

Demo layout

The docroot is src/. The page classes live in the hidden folder src/.php/, so the web server never serves them as pages.

src/ ├── .php/ │ ├── BasePage.php │ └── ArticlePage.php └── blog/ └── index.php

src/.php/BasePage.php

<?php namespace Site\Php; class BasePage { public function __construct(protected string $title) { } public function createHead(): string { return '<h1>' . $this->title . '</h1>'; } }

src/.php/ArticlePage.php

<?php namespace Site\Php; require_once __DIR__ . '/BasePage.php'; class ArticlePage extends BasePage { public function createFooter(): string { return '<footer></footer>'; } }

src/blog/index.php

<?php use Site\Php\ArticlePage; $root = $_SERVER['DOCUMENT_ROOT']; require_once($root . '/.php/ArticlePage.php'); $page = new ArticlePage(title: 'Blog'); echo $page->createHead(); echo $page->createFooter();

Run PHPStan in Docker

No Composer and no PHP on the host: download the PHPStan phar once and run it in the official PHP image. Create a folder for the phar:

mkdir -p ~/.cache/phpstan

Download the 2.2.14 release, about 29 MB:

curl -sSLo ~/.cache/phpstan/phpstan.phar https://github.com/phpstan/phpstan/releases/download/2.2.14/phpstan.phar

A shell function mounts the current folder as /app and the phar folder read-only as /tools. Run it from the project root, the folder that holds src/:

phpstan() { docker run --rm -v "$PWD:/app" -w /app -v "$HOME/.cache/phpstan:/tools:ro" php:8.2-cli php /tools/phpstan.phar "$@"; }

phpstan --version

PHPStan - PHP Static Analysis Tool 2.2.14

Step 1: class not found

Analyse the page alone, without a config file:

phpstan analyse --no-progress --level 1 src/blog/index.php

------ ---------------------------------------------------------------------- Line index.php ------ ---------------------------------------------------------------------- 8 Instantiated class Site\Php\ArticlePage not found. 🪪 class.notFound 💡 Learn more at https://phpstan.org/user-guide/discovering-symbols ------ ---------------------------------------------------------------------- [ERROR] Found 1 error

PHPStan does not execute the require_once, and its path depends on $_SERVER, which only exists at run time. The class file is never read.

Step 2: a folder run skips hidden folders

The usual advice is to analyse the whole folder, so that the class files are analysed too. --debug prints each file PHPStan reads:

phpstan analyse --level 1 --debug src/

/app/src/blog/index.php ------ ---------------------------------------------------------------------- Line blog/index.php ------ ---------------------------------------------------------------------- 8 Instantiated class Site\Php\ArticlePage not found. 🪪 class.notFound 💡 Learn more at https://phpstan.org/user-guide/discovering-symbols ------ ---------------------------------------------------------------------- [ERROR] Found 1 error

One file. When PHPStan walks a folder it leaves out every folder whose name starts with a dot, so src/.php/ and both classes in it are skipped without a warning.

Step 3: scanDirectories

scanDirectories makes PHPStan read class and function definitions from a folder without reporting errors in it. Pointed at src, it walks the tree the same way as step 2:

phpstan.neon

parameters: level: 1 scanDirectories: - src

phpstan analyse --no-progress src/blog/index.php

Note: Using configuration file /app/phpstan.neon. ------ ---------------------------------------------------------------------- Line index.php ------ ---------------------------------------------------------------------- 8 Instantiated class Site\Php\ArticlePage not found. 🪪 class.notFound 💡 Learn more at https://phpstan.org/user-guide/discovering-symbols ------ ---------------------------------------------------------------------- [ERROR] Found 1 error

Name the hidden folder itself and the walk starts inside it:

phpstan.neon

parameters: level: 1 scanDirectories: - src/.php

phpstan analyse --no-progress src/blog/index.php

Note: Using configuration file /app/phpstan.neon. [OK] No errors

A class added to src/.php/ later is found without any change to the config, and a deleted class file breaks nothing. Classes kept in ordinary folders need src in the list as well; keep both entries, see a large tree.

Step 4: scanFiles and how it breaks

The other way is an explicit list of files. It works too:

phpstan.neon

parameters: level: 1 scanFiles: - "src/.php/ArticlePage.php" - "src/.php/BasePage.php"

phpstan analyse --no-progress src/blog/index.php

Note: Using configuration file /app/phpstan.neon. [OK] No errors

The list is a copy of the tree, and every change to the tree makes the copy wrong in one of three ways.

A listed file was deleted

Add an entry for a file that no longer exists, src/.php/OldPage.php:

parameters: level: 1 scanFiles: - "src/.php/ArticlePage.php" - "src/.php/BasePage.php" - "src/.php/OldPage.php"

phpstan analyse --no-progress src/blog/index.php; echo "exit $?"

Note: Using configuration file /app/phpstan.neon. Scanned file /app/src/.php/OldPage.php does not exist. exit 1

PHPStan stops before analysing anything. Two details make this worse on a big list. The exit code is 1, the same code as "errors found", so a CI job cannot tell a broken config from real findings. And only the first missing file is named: with two stale entries, OldPage.php and Gone.php, the message is the same as above, and Gone.php shows up only after the first one is fixed.

A folder in the list

scanFiles takes files only. A folder entry fails with the same message, although the folder exists:

parameters: level: 1 scanFiles: - "src/.php"

phpstan analyse --no-progress src/blog/index.php; echo "exit $?"

Note: Using configuration file /app/phpstan.neon. Scanned file /app/src/.php does not exist. exit 1

A new file is not in the list

Add a class src/.php/DictPage.php and a page src/blog/words.php that uses it, and keep the two-file list:

phpstan analyse --no-progress src/blog/

Note: Using configuration file /app/phpstan.neon. ------ ---------------------------------------------------------------------- Line words.php ------ ---------------------------------------------------------------------- 8 Instantiated class Site\Php\DictPage not found. 🪪 class.notFound 💡 Learn more at https://phpstan.org/user-guide/discovering-symbols ------ ---------------------------------------------------------------------- [ERROR] Found 1 error

This one does not stop the run, so it is easy to read as a real bug in the page. With scanDirectories: [src/.php] from step 3 the same run reports no errors.

Find every stale entry at once

Because PHPStan names one missing file per run, check the whole list with the shell instead. The loop takes each quoted entry and prints the ones that are not regular files, which catches deleted files and folder entries in one pass:

grep -oE '^ +- "[^"]+"' phpstan.neon | sed -E 's/^ +- "//; s/"$//' | while IFS= read -r f; do [ -f "$f" ] || echo "stale: $f"; done

On a list with OldPage.php, Gone.php, src/.php and ArticlePage.php:

stale: src/.php/OldPage.php stale: src/.php/Gone.php stale: src/.php

Delete the printed lines, or put the loop into a pre-commit hook and fail the commit when it prints anything. It does not find new files that are missing from the list; only regenerating the list does.

Generate the scan config

Keep the generated part out of the hand-written config. A script writes phpstan-scan.neon with one scanDirectories entry per hidden folder; phpstan.neon includes it. Open the script:

vi phpstan-scan.sh

#!/usr/bin/env bash # Write phpstan-scan.neon: one scanDirectories entry per hidden folder under src/. set -euo pipefail cd "$(dirname "$0")" { echo "# Generated by phpstan-scan.sh, do not edit." echo "parameters:" echo " scanDirectories:" find src -name '.*' -type d -prune | sort | sed 's/^/ - "/; s/$/"/' } > phpstan-scan.neon.tmp mv phpstan-scan.neon.tmp phpstan-scan.neon

find ... -prune lists the hidden folders without walking into them. The script writes a temporary file and renames it, so PHPStan never reads a half-written config. Make it executable and run it:

chmod +x phpstan-scan.sh

./phpstan-scan.sh

cat phpstan-scan.neon

# Generated by phpstan-scan.sh, do not edit. parameters: scanDirectories: - "src/.php"

Then the main config only includes it:

phpstan.neon

includes: - phpstan-scan.neon parameters: level: 1

phpstan analyse --no-progress src/blog/

Note: Using configuration file /app/phpstan.neon. [OK] No errors

Run the script again only when a hidden folder is added or removed. A listed folder that is gone stops the run the same way a missing file does: Scanned directory /app/src/ru/.php does not exist. and exit code 1.

On a large tree

The same approach on a docroot with about 5,400 pages and four hidden engine folders, each with 330 to 338 PHP files. find lists ten hidden folders; the ones without PHP files (.css, .js) can stay out of the config:

find src -name '.*' -type d -prune | sort

src/ao/.css src/ao/.js src/ao/.php src/.css src/.js src/.php src/ru/.js src/ru/.php src/ru/qa/.js src/ru/qa/.php

Pages there also use classes from ordinary folders (one RelatedArticles.php per section), so src stays in the list next to the hidden folders:

parameters: level: 1 scanDirectories: - src - src/.php - src/ao/.php - src/ru/.php - src/ru/qa/.php

Two pages failed with class.notFound under a scanFiles list of 4,439 files that had never been given the engine classes. With this config they passed, in 13 seconds:

[OK] No errors

Limits

Article author: Klavdiya Kondakova