静态分析
静态分析工具在不执行代码的情况下检查代码质量、类型安全和潜在 Bug。它是 PHP 项目质量保障体系中的关键环节,能在开发阶段(而非运行阶段)发现大量问题。PHP 生态中的三大主流静态分析工具为 PHPStan、Psalm 和 Phan,本节将详细介绍 PHPStan 和 Psalm 的使用方法与最佳实践。
基础概念
静态分析 vs 动态测试
| 维度 | 静态分析 | 动态测试 |
|---|---|---|
| 执行代码 | 不需要 | 需要 |
| 发现时机 | 编写代码时 | 运行测试时 |
| 覆盖范围 | 所有代码路径 | 取决于测试覆盖率 |
| 检测类型 | 类型错误、死代码、未使用变量 | 逻辑错误、集成问题 |
| 速度 | 快 | 较慢 |
PHPStan 与 Psalm 对比
| 特性 | PHPStan | Psalm |
|---|---|---|
| 维护者 | Ondrej Mirtes | Vimeo/社区 |
| 分析精度 | 高(9个级别) | 高(可比较 3-5 级) |
| 类型推断 | 强 | 强 |
| 框架集成 | 丰富 | 丰富 |
| 插件生态 | 大量官方和社区插件 | 内置模板、特性 |
| 性能 | 快 | 中等 |
| 配置方式 | NEON + PHP | XML + PHP |
| taint analysis | 插件支持 | 内置支持 |
| Baseline | 支持 | 支持 |
PHPStan
安装与基本使用
bash
# 项目安装(推荐)
composer require --dev phpstan/phpstan
# 安装 Laravel 扩展(如使用 Laravel)
composer require --dev phpstan/phpstan-symfony
# 验证安装
vendor/bin/phpstan --version基本命令
bash
# 使用默认级别(0)分析
vendor/bin/phpstan analyse src/
# 使用指定级别分析
vendor/bin/phpstan analyse src/ --level=8
# 分析指定文件
vendor/bin/phpstan analyse src/Service/UserService.php
# 同时分析多个目录
vendor/bin/phpstan analyse src/ tests/
# 输出结果到文件
vendor/bin/phpstan analyse src/ --error-format=json > phpstan-report.json
# 查看所有可用的错误格式
vendor/bin/phpstan help analyse分析级别详解
PHPStan 提供 0-9 共 10 个级别,每个级别在前一级基础上增加检查严格度:
text
Level 0:基本语法检查(无类型推断)
Level 1:报告隐式混合类型
Level 2:报告联合类型中的隐式 mixed
Level 3:报告 null 返回值类型不匹配
Level 4:报告 void 返回值类型不匹配
Level 5:报告未定义的变量和方法
Level 6:报告类型不兼容的属性赋值
Level 7:报告 foreach 中隐式 mixed
Level 8:报告所有类型的缩小不匹配(推荐)
Level 9:最严格,报告所有可能的类型问题bash
# Level 0:基础检查
vendor/bin/phpstan analyse src/ --level=0
# 只检查语法、调用了不存在的类/方法/函数
# Level 5:中等严格
vendor/bin/phpstan analyse src/ --level=5
# 报告未定义变量、未定义方法、不匹配的返回类型
# Level 8:高严格度(推荐用于新项目)
vendor/bin/phpstan analyse src/ --level=8
# 全面类型检查,是生产项目的推荐级别
# Level 9:最严格
vendor/bin/phpstan analyse src/ --level=9
# 最高级别,可能会产生较多误报,适合追求极致类型安全的场景PHPStan 配置文件
在项目根目录创建 phpstan.neon(或 phpstan.neon.dist):
neon
# phpstan.neon.dist
parameters:
# 分析级别
level: 8
# 分析路径
paths:
- src
# 排除的文件和目录
excludePaths:
- src/Infrastructure/Migrations/*
- src/Kernel.php
# 报告未使用的参数
reportUnmatchedIgnoredErrors: false
# 检查未知的全局函数(PHPStan 扩展)
bootstrapFiles:
- vendor/autoload.php
# 自动发现 Composer autoload
scanFiles:
- config/constants.php
# 扫描目录
scanDirectories:
- src
# 动态返回类型扩展
universalObjectCratesClasses:
- stdClass
- SimpleXMLElement
# 未使用的标签
ignoreErrors:
- '#Call to an undefined method [a-zA-Z]+::#'
# PHP 版本目标
phpVersion: 82000 # PHP 8.2
# Taint analysis(污点分析)
taintAnalysis:
enabled: true
# 内存限制
memoryLimit: 512MPHPStan 配置文件也支持 PHP 格式 phpstan.php(推荐用于复杂配置):
php
<?php
declare(strict_types=1);
use PHPStan\Rules\Arrays\ newArrayForEachWithArrayValueTypehintRule;
use PHPStan\Rules\Arrays\ArrayDimFetchRule;
return [
'parameters' => [
'level' => 8,
'paths' => [
__DIR__ . '/src',
],
'excludePaths' => [
__DIR__ . '/src/Migrations',
],
'phpVersion' => 82000,
'checkMissingIterableValueType' => true,
'checkGenericClassInNonGenericObjectType' => true,
'reportUnmatchedIgnoredErrors' => true,
'treatPhpDocTypesAsCertain' => false,
'dynamicConstantNames' => [
'APP_ENV',
'APP_DEBUG',
],
'ignoreErrors' => [
[
'message' => '#Call to an undefined method#',
'path' => __DIR__ . '/src/ThirdParty/*',
],
],
],
];PHPStan 扩展
bash
# Laravel 框架支持
composer require --dev phpstan/phpstan-symfony
# PHPUnit 支持
composer require --dev phpstan/phpstan-phpunit
# Doctrine ORM 支持
composer require --dev phpstan/phpstan-doctrine
# mockery mock 支持
composer require --dev phpstan/phpstan-mockery
# 安全分析
composer require --dev ekino/phpstan-banned-code
#严格类型检查
composer require --dev thiagocordeiro/phpstan-laravel-prometheus-exporterPHPStan Baseline
对于已有项目,可以使用 baseline 机制忽略已有的类型问题,专注于不引入新问题:
bash
# 生成 baseline
vendor/bin/phpstan analyse src/ --level=8 --generate-baseline phpstan-baseline.neon
# 使用 baseline 分析(忽略 baseline 中已记录的错误)
vendor/bin/phpstan analyse src/ --level=8phpstan-baseline.neon 示例:
neon
parameters:
ignoreErrors:
-
message: "#^Variable \\$unused might not be defined\\.$#"
count: 2
path: src/Service/legacy.php
-
message: "#^Parameter \\#1 \\$data of function process\\(\\) expects array, int given\\.$#"
count: 1
path: src/Processor.php定期清理 baseline
Baseline 不应是永久方案。建议定期清理 baseline 中的条目,逐步提升代码的类型安全级别。
PHPStan 源码示例分析
php
<?php
declare(strict_types=1);
class UserRepository
{
public function __construct(
private readonly PDO $db,
) {}
/**
* Level 0-4:不会报错(缺少返回类型)
* Level 5+:报告返回类型缺失
*/
public function findById(int $id) // ❌ Level 5+: Missing return type
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
// ❌ Level 1+: 可能返回 false(mixed 类型)
}
/**
* 正确的类型声明
*/
public function findOneById(int $id): ?array // ✅
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
// Level 8+ 会检查这里 $result 可能是 false
return $result !== false ? $result : null;
}
/**
* Level 8:联合类型 narrowing 检查
*/
public function getUserName(int $id): string
{
$data = $this->findOneById($id);
// ❌ Level 3+: $data 可能为 null
// return $data['name'];
// ✅ 正确处理
if ($data === null) {
return 'Unknown User';
}
return $data['name'];
}
/**
* Level 6+:属性类型检查
*/
private array $cache = [];
public function cacheUser(int $id, array $data): void
{
// ✅ 类型匹配
$this->cache[$id] = $data;
}
/**
* Level 5+:未定义方法检查
*/
public function findActive(): array
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE is_active = 1');
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Level 9:strict 检查
*/
public function processFilters(array $filters): array
{
$result = [];
foreach ($filters as $key => $value) {
// ❌ Level 9: $key 和 $value 都是 mixed
$result[$key] = strtoupper((string) $value);
}
return $result;
}
}内联忽略错误
php
<?php
declare(strict_types=1);
class LegacyService
{
// 忽略整行错误
/** @phpstan-ignore-next-line */
private $legacyProperty;
// 忽略下一行
public function legacyMethod(): void
{
/** @phpstan-ignore-next-line */
$data = $this->unknownMethod();
}
// 忽略特定类型的错误
/** @phpstan-ignore method.notFound */
public function anotherMethod(): void
{
$this->unknownMethod();
}
// 使用 @var 覆盖推断类型
public function process(): void
{
/** @var array<string, mixed> $config */
$config = $this->loadConfig();
}
// 使用 @phpstan-assert 辅助类型推断
/**
* @phpstan-assert !null $value
*/
public function assertNotNull(mixed $value): void
{
if ($value === null) {
throw new \InvalidArgumentException('Value must not be null');
}
}
}Psalm
安装与基本使用
bash
# 项目安装
composer require --dev vimeo/psalm
# 验证安装
vendor/bin/psalm --version基本命令
bash
# 初始化配置(交互式)
vendor/bin/psalm --init
# 分析代码
vendor/bin/psalm
# 分析指定文件或目录
vendor/bin/psalm src/Service/
# 显示详细信息
vendor/bin/psalm --show-info=true
# 生成 baseline
vendor/bin/psalm --set-baseline=psalm-baseline.xml
# 使用 baseline 分析
vendor/bin/psalm --use-baseline=psalm-baseline.xml
# 报告类型覆盖率
vendor/bin/psalm --report-type=checkstylePsalm 配置文件
xml
<?xml version="1.0"?>
<psalm
errorLevel="8"
resolveFromConfigFile="true"
findUnusedCode="true"
findUnusedVariables="true"
phpVersion="8.2"
cacheDirectory=".psalm-cache"
>
<!-- 项目文件 -->
<projectFiles>
<directory name="src"/>
<directory name="tests"/>
<ignoreFiles>
<directory name="vendor"/>
<file name="src/Kernel.php"/>
</ignoreFiles>
</projectFiles>
<!-- 插件 -->
<plugins>
<pluginClass class="Psalm\PhpUnitPlugin"/>
<!-- Doctrine 插件 -->
<pluginClass class="Psalm\DoctrinePlugin"/>
</plugins>
<!-- 全局忽略 -->
<issueHandlers>
<!-- 忽略特定文件的特定错误 -->
<PossiblyUndefinedArrayOffset>
<errorLevel type="suppress">
<file name="src/Service/LegacyService.php"/>
</errorLevel>
</PossiblyUndefinedArrayOffset>
<!-- 降低某些错误级别 -->
<MixedAssignment>
<errorLevel type="info">
<referencedFunction name="json_decode"/>
</errorLevel>
</MixedAssignment>
<!-- 文档标注 suppress -->
<MixedArgumentTypeCoercion errorLevel="suppress"/>
<MixedArrayAccess errorLevel="suppress"/>
</issueHandlers>
</psalm>Psalm 类型注解
php
<?php
declare(strict_types=1);
use Psalm\Type\Union;
class TypeSafeService
{
// @psalm-param 明确参数类型
/**
* @psalm-param array{page: int, per_page: int, search?: string} $params
*/
public function search(array $params): array
{
$page = $params['page'];
$perPage = $params['per_page'];
$search = $params['search'] ?? '';
return $this->performSearch($page, $perPage, $search);
}
// @psalm-return 明确返回类型
/**
* @psalm-return array<array{id: int, name: string, email: string}>
*/
private function performSearch(int $page, int $perPage, string $search): array
{
$stmt = $this->db->prepare('SELECT id, name, email FROM users LIMIT :limit OFFSET :offset');
$stmt->execute([
'limit' => $perPage,
'offset' => ($page - 1) * $perPage,
]);
/** @var list<array{id: int, name: string, email: string}> */
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// @psalm-template 泛型方法
/**
* @psalm-template T
* @psalm-param class-string<T> $className
* @psalm-return T
*/
public function createInstance(string $className): object
{
return new $className();
}
// @psalm-pure 纯函数
/**
* @psalm-pure
*/
public function calculateTotal(array $items): float
{
return array_reduce(
$items,
fn (float $carry, array $item): float => $carry + $item['price'] * $item['quantity'],
0.0,
);
}
// @psalm-assert 类型断言
/**
* @psalm-assert array<string, mixed> $data
*/
public function assertIsAssociativeArray(mixed $data): void
{
if (!is_array($data) || array_is_list($data)) {
throw new \InvalidArgumentException('Expected associative array');
}
}
// @psalm-suppress 局部抑制
public function processExternalData(string $json): array
{
$data = json_decode($json, true);
if (!is_array($data)) {
return [];
}
/** @psalm-suppress MixedAssignment */
return $data;
}
}Psalm Taint Analysis(污点分析)
php
<?php
declare(strict_types=1);
// 污点分析用于追踪不安全数据从源头到汇点的流向
// 防止 SQL 注入、XSS 等安全漏洞
class UserController
{
public function __construct(
private readonly PDO $db,
) {}
/**
* ✅ 安全:使用参数化查询
*/
public function safeSearch(Request $request): array
{
// request()->get() 是 taint source
$search = $request->get('search', '');
// 参数化查询阻止了 taint 传播
$stmt = $this->db->prepare('SELECT * FROM users WHERE name LIKE :search');
$stmt->execute(['search' => "%{$search}%"]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* ❌ 不安全:字符串拼接 SQL(Psalm 会报告 taint 错误)
*/
public function unsafeSearch(Request $request): array
{
// @psalm-taint-sink sql
$search = $request->get('search', '');
$sql = "SELECT * FROM users WHERE name LIKE '%{$search}%'";
$stmt = $this->db->query($sql); // ❌ Taint 汇点
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* XSS 防护
*/
public function renderUser(Request $request): string
{
$name = $request->get('name', '');
// ✅ 安全:转义输出
return htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
// ❌ 不安全:直接输出
// return "<div>Hello, {$name}</div>"; // Taint sink
}
}配置最佳实践
推荐的项目配置
yaml
# phpstan.neon.dist - 推荐配置
parameters:
level: 8
paths:
- src
excludePaths:
- src/Migrations
- src/Kernel.php
- src/Console/Kernel.php
bootstrapFiles:
- vendor/autoload.php
phpVersion: 82000
memoryLimit: 512M
# 类型覆盖相关
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
reportUnmatchedIgnoredErrors: true
treatPhpDocTypesAsCertain: false
# 自动发现 Composer autoload
scanDirectories:
- src
# 第三方库的类型存根
scanFiles:
- vendor/stubs/globals.php
# 忽略外部库的命名
universalObjectCratesClasses:
- stdClass
- SimpleXMLElement框架集成
Laravel 项目 PHPStan 配置
bash
composer require --dev nunomaduro/larastanneon
# phpstan.neon
includes:
- vendor/nunomaduro/larastan/extension.neon
parameters:
level: 8
paths:
- src
- app
- tests
excludePaths:
- app/Providers
# ... 其他配置Symfony 项目 PHPStan 配置
bash
composer require --dev phpstan/phpstan-symfonyneon
# phpstan.neon
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
parameters:
level: 8
symfony:
container_xml_path: var/cache/dev/App_KernelDevDebugContainer.xml
console_application_loader: bin/console
# ... 其他配置CI 集成
GitHub Actions 配置
yaml
# .github/workflows/static-analysis.yml
name: PHP Static Analysis
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
coverage: none
tools: composer:v2
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-phpstan-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-phpstan-
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run PHPStan
run: vendor/bin/phpstan analyse --no-progress --error-format=github
psalm:
name: Psalm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-psalm-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-psalm-
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run Psalm
run: vendor/bin/psalm --output-format=github --no-cache
- name: Psalm Taint Analysis
run: vendor/bin/psalm --taint-analysisGitLab CI 配置
yaml
# .gitlab-ci.yml
phpstan:
stage: test
image: composer:latest
before_script:
- composer install --prefer-dist --no-progress --no-interaction
script:
- vendor/bin/phpstan analyse --no-progress --error-format=json > phpstan-report.json
- if [ $? -ne 0 ]; then cat phpstan-report.json; exit 1; fi
artifacts:
reports:
codequality: phpstan-report.json
when: on_failure
paths:
- phpstan-report.json
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'Composer 脚本集成
json
{
"scripts": {
"phpstan": "phpstan analyse --no-progress",
"phpstan-baseline": "phpstan analyse --generate-baseline=phpstan-baseline.neon",
"psalm": "psalm --no-cache",
"psalm-baseline": "psalm --set-baseline=psalm-baseline.xml",
"static-analysis": [
"@phpstan",
"@psalm"
],
"stan-baseline": "@phpstan-baseline",
"taint-analysis": "psalm --taint-analysis"
}
}Baseline 使用策略
引入 Baseline 的流程
bash
# 1. 在现有项目上首次运行
vendor/bin/phpstan analyse src/ --level=5 --generate-baseline=phpstan-baseline.neon
# 2. 将 baseline 提交到版本控制
git add phpstan-baseline.neon
git commit -m "Add PHPStan baseline"
# 3. 后续分析自动使用 baseline
vendor/bin/phpstan analyse src/ --level=5
# 4. 逐步清理 baseline
# 修复代码中的类型问题
# 重新生成 baseline
vendor/bin/phpstan analyse src/ --level=5 --generate-baseline=phpstan-baseline.neon
# 5. 当 baseline 足够小或为零时
# 移除 baseline
rm phpstan-baseline.neon
git rm phpstan-baseline.neonBaseline 更新策略
bash
# CI 中检测 baseline 是否过期
# 如果新引入的错误不在 baseline 中,CI 应该失败
vendor/bin/phpstan analyse src/ --level=8 --no-progress
# 定期检查 baseline 条目是否可以清理
vendor/bin/phpstan analyse src/ --level=8 --allow-empty-baseline
# 比较新旧 baseline 差异
diff phpstan-baseline.neon phpstan-baseline.neon.new实战示例
完整的类型安全重构示例
php
<?php
declare(strict_types=1);
// ===== 重构前(类型不安全) =====
class OrderProcessorBefore
{
private $db;
public function __construct($db)
{
$this->db = $db;
}
public function process($orderId, $data)
{
$order = $this->findOrder($orderId);
$order['status'] = $data['status'];
$order['updated_at'] = date('Y-m-d H:i:s');
$this->saveOrder($order);
return $order;
}
private function findOrder($id)
{
return $this->db->query("SELECT * FROM orders WHERE id = " . $id)->fetch();
}
}
// ===== 重构后(类型安全) =====
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
}
final class OrderProcessor
{
public function __construct(
private readonly PDO $db,
) {}
/**
* @param array{status: string, notes?: string} $data
* @return array{id: int, status: string, updated_at: string}
*/
public function process(int $orderId, array $data): array
{
$order = $this->findOrder($orderId);
if ($order === null) {
throw new \RuntimeException("Order #{$orderId} not found");
}
$this->validateStatusTransition($order['status'], $data['status']);
$order['status'] = $data['status'];
$order['notes'] = $data['notes'] ?? $order['notes'];
$order['updated_at'] = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
$this->saveOrder($order);
return $order;
}
/**
* @return array{id: int, status: string, notes: string, created_at: string, updated_at: string}|null
*/
private function findOrder(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM orders WHERE id = :id');
$stmt->execute(['id' => $id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result !== false ? $result : null;
}
/**
* @param array{id: int, status: string, notes: string, created_at: string, updated_at: string} $order
*/
private function saveOrder(array $order): void
{
$stmt = $this->db->prepare(
'UPDATE orders SET status = :status, notes = :notes, updated_at = :updated_at WHERE id = :id',
);
$stmt->execute([
'id' => $order['id'],
'status' => $order['status'],
'notes' => $order['notes'],
'updated_at' => $order['updated_at'],
]);
}
private function validateStatusTransition(string $currentStatus, string $newStatus): void
{
$allowedTransitions = [
'pending' => ['processing', 'cancelled'],
'processing' => ['shipped', 'cancelled'],
'shipped' => ['delivered'],
];
$current = strtolower($currentStatus);
$new = strtolower($newStatus);
if (!isset($allowedTransitions[$current])) {
throw new \InvalidArgumentException("Cannot transition from '{$currentStatus}'");
}
if (!in_array($new, $allowedTransitions[$current], true)) {
throw new \InvalidArgumentException(
"Cannot transition from '{$currentStatus}' to '{$newStatus}'",
);
}
}
}注意事项
常见问题
避免过度使用 @phpstan-ignore
内联忽略应作为最后手段,而非首选。过多的内联忽略会导致类型安全体系形同虚设。正确做法是修复代码类型问题或使用 baseline。
php
<?php
declare(strict_types=1);
// ❌ 避免大面积忽略
/** @phpstan-ignore-all */
class BadExample { }
// ❌ 避免忽略可以修复的问题
/** @phpstan-ignore-next-line */
echo $undefinedVariable;
// ✅ 正确做法:声明类型
private string $variable = '';
echo $this->variable;
// ✅ 正确做法:使用 null 检查
if ($variable !== null) {
echo $variable;
}性能优化
bash
# PHPStan 缓存
# 默认缓存目录:/tmp/phpstan
# 可通过 --tmp-dir 指定
vendor/bin/phpstan analyse --no-progress --tmp-dir=.phpstan-cache
# 使用 PHP 反射缓存
vendor/bin/phpstan analyse --no-progress --memory-limit=1G
# 并行分析(PHPStan 不支持多进程,但可以通过分目录实现)
vendor/bin/phpstan analyse src/Domain --no-progress &
vendor/bin/phpstan analyse src/Application --no-progress &
wait最佳实践
- 新项目使用最高可行级别:新项目从 Level 8 开始,而非 Level 0
- 渐进式升级:已有项目使用 baseline,然后逐步升级级别
- CI 中始终运行:将静态分析集成到 CI 中,确保不引入新的类型问题
- 与测试互补:静态分析不能替代测试,两者结合使用效果最佳
- 定期更新工具:PHPStan 和 Psalm 频繁更新,定期更新可获得更好的分析能力
- 不要忽略所有错误:仔细审查每个需要忽略的错误,确保没有遗漏真实问题
- 团队统一级别:整个团队使用相同的分析级别和配置
下一节
继续学习:项目目录结构