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
- scanDirectories reads every PHP file in the listed folders on each run. On thousands of files that costs seconds: the run in the previous chapter took 13.
- Any missing entry, file or folder, stops the run with exit code 1. Treat a run that prints does not exist as a config error, not as findings.
- Hidden folders are skipped by the analysed paths too, not only by the scan settings: to report errors inside src/.php/, pass that folder to phpstan analyse by name.
Article author: Klavdiya Kondakova