script-development/phpstan-warroom-rules

GitHub: script-development/phpstan-warroom-rules

一套自定义 PHPStan 规则包,为 Laravel 项目强制执行团队的架构准则,涵盖事务包装、审计日志仅追加、控制器与 Action 层职责分离等约束。

Stars: 0 | Forks: 0

# phpstan-warroom-rules [![Packagist Version](https://img.shields.io/packagist/v/script-development/phpstan-warroom-rules.svg)](https://packagist.org/packages/script-development/phpstan-warroom-rules) [![PHP Version](https://img.shields.io/packagist/dependency-v/script-development/phpstan-warroom-rules/php.svg)](https://packagist.org/packages/script-development/phpstan-warroom-rules) [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/ad/ad5834178f7599af9fdda11629d49cae07f2997beec49821b2920eff5bfd50e7.svg)](https://github.com/script-development/phpstan-warroom-rules/actions/workflows/ci.yml) [![License](https://img.shields.io/packagist/l/script-development/phpstan-warroom-rules.svg)](LICENSE) Canonical PHPStan 规则,在 `script-development` 的 Laravel 领地中强制执行作战室(war-room)准则。 通过 Composer 作为 `script-development/phpstan-warroom-rules` 分发。准则来源为 [ADR-0021](https://adrs.script.nl/decisions/phpstan-rules-package)。 ## 为什么 有几项准则主张需要开箱即用的 PHPStan + Larastan 无法提供的静态分析强制检查: - 多重写入的 Action 必须在数据库事务中包装操作。 - 审计日志记录仅允许追加。 - 禁止使用 `abort()` 系列辅助函数,推荐抛出显式的 HTTP 异常。 - Action 构造函数必须注入 `ConnectionInterface`,绝不使用 `DatabaseManager`。 这些规则起源于 `emmie`,现已提升为共享包,以便每个使用的领域在 `composer require` 时都能获得相同的强制检查。 ## 安装 ``` composer require --dev script-development/phpstan-warroom-rules ``` 该包附带 `phpstan/extension-installer` 元数据。如果您安装了该安装器,扩展将自动加载。否则,请将其添加到您的 `phpstan.neon` 中: ``` includes: - vendor/script-development/phpstan-warroom-rules/extension.neon ``` ## 规则 | 规则 | 标识符 | 检测对象 | 禁止 / 要求 | |---|---|---|---| | `EnforceActionTransactionsRule` | `enforceActionTransactions.missingTransaction` | Action 的 `execute()` 方法 | 如果出现 ≥2 次写入操作而没有使用 `->transaction()`,则报错。 | | `ForbidDatabaseManagerInActionsRule` | `forbidDatabaseManager.inAction` | Action 构造函数 | 构造函数参数类型为 `DatabaseManager` 将报错。应改为注入 `ConnectionInterface`。 | | `ForbidAbortHelperRule` | `forbidAbortHelper.abortUsed` | 函数调用 | `abort()`、`abort_if()`、`abort_unless()` 均会报错。应改为抛出显式的 `HttpException` 子类。 | | `ForbidHttpExceptionInActionsRule` | `forbidHttpExceptionInActions.httpExceptionInAction` | `App\Actions\*` 类(命名空间前缀,包含子命名空间)内部的 `throw` 语句 | 从 Action 中抛出 `Symfony\Component\HttpKernel\Exception\HttpException` 系列异常(`HttpException` 及其所有子类 — `NotFoundHttpException`、`AccessDeniedHttpException`、`UnprocessableEntityHttpException` 等)将报错。类型感知:抛出的表达式的类型必须是 `Symfony\Component\HttpKernel\Exception\HttpExceptionInterface` 的子类型(捕获子类、完全限定抛出和类型值抛出,这些是导入检查架构测试会遗漏的)。HTTP 状态关注点应属于 HTTP 层 — 在 FormRequest 中设置唯一性规则,或者抛出一个由渲染器映射到状态的自定义领域异常。`Illuminate\Validation\ValidationException` **不在范围内**(它不是 Symfony 的 `HttpException`;Action 合法地抛出它用于有状态验证)。它是 `ForbidAbortHelperRule` 的类型感知兄弟规则。准则依据:作战室 §架构原则 — 显式优于隐式 (#1) + Form Request → DTO → Action 流水线 (#3)。 | | `LogRule` | `logRule.logModification` | `update()` / `delete()` 调用 | 如果接收者类型的类名包含 `"Log"` 或 `"logs"`(不区分大小写),则报错。 | | `LogBuilderTruncateRule` | `logRule.logModification` | `Builder->truncate()` 调用 | 如果流式链中最近的 `table()` 调用针对以 Log 命名的表(字符串字面量参数匹配 `"log"` / `"logs"`,不区分大小写),则报错。它是 `LogRule` 的兄弟规则;共用 `logRule.logModification` 标识符,因此单个 `ignoreErrors` 条目即可同时覆盖两者。Eloquent 的 `from()` 链和基于 Model-`$table`-属性驱动的表属于可接受的遗漏。准则依据:ADR-0001 §仅追加。 | | `EnforceAuditSnapshotOnRetryRule` | `enforceAuditSnapshotOnRetry.firstStatementMustResetState` | 构造函数注入了实体审计 logger 的 `App\Actions\*` | `$connection->transaction(...)` 内部的第一条语句必须重置模型的内存状态(`$model->refresh()`、重新获取或重新实例化)。准则依据:ADR-0001 §重试快照安全性。 | | `EnforceAuditTransactionScopeRule` | `enforceAuditTransactionScope.nonTransactionalMutationInClosure` | 其 `execute()` 带有字面闭包来调用 `transaction(...)` 的 `App\Actions\*` | 在闭包内修改 `StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` 状态(或其对应的 `Illuminate\Support\Facades\*`)将报错。允许读取(`Auth::user()`、`Session::get()`、`Cache::get()`)。准则依据:ADR-0029(审计行持久性契约)§决策规则 3。 | | `ForbidEloquentMutationInControllersRule` | `forbidEloquentMutationInControllers.eloquentMutationInController` | `App\Http\Controllers\*`(包含子命名空间;可通过 `controllerNamespacePrefixes` 配置) | 在 `Illuminate\Database\Eloquent\Model` 子类或 `Illuminate\Database\Eloquent\Builder` 链上调用 Eloquent 持久化 API(`save`、`update`、`delete`、`create`、`destroy`、`forceDelete`、`forceFill`、`push`、`restore`、`touch` 及其 `*OrFail` / `*Quietly` / `*OrCreate` 变体 — 包含 24 个方法的黑名单)将报错。允许读取(`find`、`where`、`get`、`first`、`paginate`、`pluck`、`count`、`exists`、`query`)。应将修改操作委托给 Action。准则依据:ADR-0011(Action 类架构)+ ADR-0019(显式模型数据填充)。 | | `EnforceResourceDataValidatorOptInRule` | `enforceResourceDataValidatorOptIn.missingValidatorCall` | 继承 `App\Http\Resources\ResourceData` 的类 | 如果类声明了非空的 `EAGER_LOAD_COUNT` / `EAGER_LOAD_SUM` 常量,但在任何方法中均未调用 `validateRelationsLoaded()`,则报错。 | | `EnforceFormRequestToDtoRule` | `enforceFormRequestToDto.missingToDtoMethod` | 继承 `Illuminate\Foundation\Http\FormRequest` 的具体类 | 如果该类既没有声明也没有继承 `toDto()` 方法,则报错。抽象中间类(`BaseFormRequest`)豁免。请交予 Action 类型化的 DTO,而不是 `$request->validated()` 数组。准则依据:ADR-0012(FormRequest → DTO 流)。 | | `EnforceCurrentUserAttributeRule` | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | `App\Http\Controllers\*` 类(命名空间前缀,包含子命名空间;可通过 `controllerNamespacePrefixes` 配置)内部对 `Request::user()` / `Auth::user()` / `auth()->user()` 的调用 | 请在方法参数上使用 `#[\Illuminate\Container\Attributes\CurrentUser] User $user`。其作用域由命名空间决定,而非类祖先 — `App\Http\Controllers` 中无基类的 `final` 控制器也会触发;FormRequests(`App\Http\Requests`)、中间件(`App\Http\Middleware`)、服务、Action(`App\Actions`)、任务和控制台命令不会触发,因为它们的命名空间不以控制器前缀开头(容器属性注入无论如何都不适用于 FormRequest 方法)。 | | `EnforceCurrentUserAttributeRule` | `enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser` | `App\Http\Controllers\*` 类(命名空间前缀,包含子命名空间)内部对 `Request::user()` / `Auth::user()` / `auth()->user()` 的调用 | 请在方法参数上使用 `#[\Illuminate\Container\Attributes\CurrentUser] User $user`。其作用域由命名空间决定,而非类祖先 — `App\Http\Controllers` 中无基类的 `final` 控制器也会触发;FormRequests(`App\Http\Requests`)、中间件(`App\Http\Middleware`)、服务、Action(`App\Actions`)、任务和控制台命令不会触发,因为它们的命名空间不以控制器前缀开头(容器属性注入无论如何都不适用于 FormRequest 方法)。 | | `EnforceAuditModelProtectionsRule` | `enforceAuditModelProtections.hasFactoryForbidden` / `.softDeletesForbidden` / `.updatedAtNotDisabled` | 通过 SHAPE 识别为审计记录的 Eloquent 模型:短名称以配置的后缀结尾(默认为 `AuditLog`)或 FQCN 位于配置的命名空间下(默认为 `App\Models\Audit`) | 三种仅追加的保护,各自独立触发:使用 `HasFactory`(工厂是绕过哈希链编写器的直接插入路径)、使用 `SoftDeletes`(审计行绝不会被移除)或未禁用 `updated_at`(审计行只写入一次且永不修改 — 需声明 `public const UPDATED_AT = null;`)将报错。发现机制基于模式,绝非人工维护的类列表 — 这是一种黑名单反转,因此新添加的审计模型不会因遗漏而逃脱保护。抽象中间类豁免(其具体的叶子类会继承违规)。被 Eloquent `Model` 类型门控排除的非模型类(命名为 `*AuditLog`)不受限制。准则依据:ADR-0001 §仅追加。 | ### `EnforceActionTransactionsRule` — 写入方法列表 该规则将以下方法计为“写入”: `save`, `saveQuietly`, `create`, `update`, `delete`, `forceDelete`, `sync`, `attach`, `detach`, `insert`, `upsert`, `updateOrCreate`, `firstOrCreate`, `push`, `restore`, `toggle`, `syncWithoutDetaching`, `syncWithPivotValues`。 类型化为非数据库服务(`FilesystemManager`、`Filesystem`、`Cache\Repository`、`LogManager`、`LoggerInterface`、`Mailer`)的属性调用会被排除 — `$this->files->delete($path)` 不会触发该规则。 ### `LogRule` — 误报 该规则对类名使用子字符串匹配。它会针对名为 `Catalog`、`Blog`、`Terminology` 或任何包含 `log` 作为子字符串的业务模型触发。可通过 `phpstan.neon` 针对特定领域进行抑制: ``` parameters: ignoreErrors: - identifier: logRule.logModification path: app/Models/Catalog.php ``` 每个忽略项都应附带说明理由的注释。未来版本可能会添加显式的允许列表参数 — 如果您有长期需求,请提交 issue。 `LogBuilderTruncateRule` 与 `LogRule` 共用 `logRule.logModification` 标识符。因此,以 `logRule.logModification` 为键的单个 `ignoreErrors` 条目即可覆盖两者中被抑制的路径。 ### `EnforceResourceDataValidatorOptInRule` — 可配置的基类 该规则默认作用于继承 `App\Http\Resources\ResourceData` 的类。如果某个领域在不同的 FQCN 下提供其抽象资源基类,请在 `phpstan.neon` 中覆盖 `resourceDataBaseClass` 参数: ``` parameters: resourceDataBaseClass: 'App\Resources\BaseResource' ``` 继承通过 PHPStan 反射(FQCN 祖先遍历)匹配,而非短名称匹配 — 无关命名空间中名为 `ResourceData` 的类不会被匹配。合规的调用形式为 `self::validateRelationsLoaded($model)`、`static::validateRelationsLoaded($model)` 和 `$this->validateRelationsLoaded($model)` — 生产环境的基本方法是 `protected static`,但也接受实例形式,以兼容作为事实来源的 Pest 架构测试的宽松匹配器。空数组常量(`EAGER_LOAD_COUNT = []`)不会触发 — 它们是空操作。 ### `EnforceFormRequestToDtoRule` — 可配置的基类 + 豁免 该规则默认作用于继承 `Illuminate\Foundation\Http\FormRequest` 的具体类。要将契约缩小到特定领域的基础 FQCN,请在 `phpstan.neon` 中覆盖 `formRequestBaseClass` 参数: ``` parameters: formRequestBaseClass: 'App\Http\Requests\BaseFormRequest' ``` 继承通过 PHPStan 反射(FQCN 祖先遍历)匹配,而非短名称匹配。抽象类从不触发 — 特定领域的抽象 `BaseFormRequest` 中间类依据形状而非名称豁免。在父类上声明或由 trait 提供的 `toDto()` 即可满足契约(镜像了作为事实来源的 entreezuil Pest 架构测试 `method_exists()` 匹配器)。 合法的无 DTO 请求(例如,其验证流程直接调用 `AuthManager::attempt()` 的 `LoginRequest`,或只读的过滤/查询请求)会以两种由使用者配置驱动的方式针对特定领域被抑制 — 绝不通过规则内部的名称抑制。 **选项 A — 基于文件的 `ignoreErrors`(以路径为键):** ``` parameters: ignoreErrors: - identifier: enforceFormRequestToDto.missingToDtoMethod path: app/Http/Requests/LoginRequest.php ``` 每个忽略项都应附带说明理由的注释。 **选项 B — `formRequestToDtoExemptClasses`(以类为键):** 要跳过的完全限定类名列表,通过 **精确的 FQCN** 匹配。这是 `ignoreErrors` 的类键替代方案 — 在文件移动时可预测,并且它将即将废弃的本地架构测试的豁免类列表 1:1 地移植到包配置中。默认为空 ⇒ 无豁免。 ``` parameters: formRequestToDtoExemptClasses: # login handler: auth flow calls Auth::attempt() directly, no Action DTO - 'App\Http\Requests\Auth\LoginRequest' ``` 使用者提供的 FQCN 列表属于 *配置*,而不是规则主体中的字面量 — 保留了“规则内部绝不通过名称识别”的约定。 #### 废弃本地 FormRequest→DTO 架构测试 如果某个领域已经通过本地 Pest 架构测试(例如 entreezuil 的 `backend/tests/Architecture/FormRequestsTest.php`)强制执行“每个具体的 FormRequest 都必须提供 `toDto()`”,则此规则现在会使该约束重复。要干净地废弃本地测试: 1. 将架构测试的豁免类列表作为 FQCN 移至 `formRequestToDtoExemptClasses`。对于 entreezuil,即: parameters: formRequestToDtoExemptClasses: # 框架 Auth::attempt() 路径,无 Action DTO - 'App\Http\Requests\Auth\LoginRequest' # 中间基类(将其设为 `abstract`,它就会完全脱离检测) - 'App\Http\Requests\BaseFormRequest' 2. 删除本地架构测试 — 该包规则(标识符为 `enforceFormRequestToDto.missingToDtoMethod`)现在成为唯一的执行权威。 (领域架构测试的废弃是单独的后续调度任务,不属于提供此选项的一部分。) ### `EnforceCurrentUserAttributeRule` — 误报 `#[\Illuminate\Container\Attributes\CurrentUser]` 会在 **方法入口依赖注入(DI)时** 解析经过身份验证的用户。如果控制器方法在 `Auth::attempt()` 成功*之后*解析用户 — 即 `guest` / 限流路由上典型的 **登录处理程序** — 则无法使用该属性:在方法入口时还没有用户,因此注入会产生 `null` 并破坏登录。该规则会对 `App\Http\Controllers` 命名空间内的任何 `Auth::user()` / `$request->user()` / `auth()->user()` 触发,并且 **无法识别路由**,因此它会标记这些合法的登录处理程序。通过 `phpstan.neon` 针对特定领域进行抑制 — 绝不通过规则内部的名称抑制: ``` parameters: ignoreErrors: - identifier: enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser # login handler: Auth::user() resolves after Auth::attempt() on a guest route path: app/Http/Controllers/Auth/AuthenticatedSessionController.php ``` 跨领域已确认(n=2, 2026-06-15):entreezuil 的 `AuthenticatedSessionController::store`,ublgenie 的 `AuthController::store`。每个使用者在其 `^0.4` 版本升级时都会添加此项。 ### 可配置的控制器命名空间 (`controllerNamespacePrefixes`) 三个限定在控制器范围内的规则 — `ForbidEloquentMutationInControllersRule`、`EnforceCurrentUserAttributeRule` 和 `ForbidResourceWrappedInJsonResponseRule` — 通过命名空间前缀而不是类祖先来决定“这个类是控制器吗?”(使用者的控制器是没有 `extends Controller` 的无基类 `final` 类,因此祖先遍历什么也捕获不到)。前缀集合是共享的 `controllerNamespacePrefixes` 参数,默认值为 `['App\Http\Controllers']`: ``` parameters: controllerNamespacePrefixes: - 'App\Http\Controllers' ``` 当类的命名空间以列出的**任何**前缀开始 `str_starts_with` 时,该类即属于作用域内,因此典型的子命名空间(kendo 的 `App\Http\Controllers\Central\*`)会自动被默认值覆盖。默认值逐字节重现了之前硬编码的门控 — 保持未设置状态,则不会有任何改变。 #### 覆盖带子命名空间的控制器 如果某个领域在 `App\Http\Controllers` **之外**提供控制器 — 例如 emmie 的 `App\Http\Client\Controllers` 和 `App\Http\Admin\Controllers` — 则通过列出它们的前缀将其纳入这两个规则: ``` parameters: controllerNamespacePrefixes: - 'App\Http\Controllers' - 'App\Http\Client\Controllers' - 'App\Http\Admin\Controllers' ``` 然后,所有这三个规则也会标记这些命名空间中的内联 Eloquent 修改、`Request::user()` / `Auth::user()` / `auth()->user()` 调用以及 `JsonResource` 包装的 JSON 响应。前缀属于 *配置* — 没有任何使用者命名空间会被硬编码在规则主体中,保留了“规则内部绝不通过名称识别”的约定。(每个反斜杠都是单杠 — NEON 仅在双引号内对 `\\` 取消转义;单引号内的 `\\` 会保留为两个字面字符,什么也匹配不到。) ### `EnforceAuditModelProtectionsRule` — 可配置的发现(黑名单反转) 此规则是允许列表架构测试的**反向**。它所取代的 Pest 前身(kendo `tests/Arch/AuditTest.php`、entreezuil `tests/Architecture/AuditTest.php`、ublgenie `tests/Arch/AuditTest.php`)通过手动维护的 FQCN 列表或命名空间目录扫描来枚举审计模型,并断言每个模型都缺少 `HasFactory` / `SoftDeletes` / 可变的 `updated_at`。手动维护的列表会静默豁免将来在其外部添加的每个审计模型。此规则扫描审计模型的 *形状* 并标记任何缺少保护的模型,因此没有任何内容会因为被遗忘而逃脱。 **发现机制** — 如果 Eloquent `Model` 子类的短名称以任何配置的后缀结尾,**或者**其 FQCN 位于任何配置的命名空间前缀下,则它是一条审计记录。这两个信号是一个联合体,涵盖了两种机群策略。默认值: ``` parameters: auditModelNamespacePrefixes: - 'App\Models\Audit' # entreezuil / ublgenie convention (incl. channel logs: AuthEventLog, SmsEventLog) auditModelNameSuffixes: - 'AuditLog' # kendo *AuditLog models, scattered across App\Models + App\Models\Central ``` 如果使用者的审计模型使用了不同的族群,则会扩大任一列表 — 例如,将 kendo 风格的通道日志对(`AiOutboundLog`、`AiMcpLog`)与 `*AuditLog` 实体模型一起纳入范围: ``` parameters: auditModelNameSuffixes: - 'AuditLog' - 'OutboundLog' - 'McpLog' ``` 配置表示 **模式**,绝不是枚举的类名 — 没有任何使用者类名被硬编码在规则主体中,并且名为 `*AuditLog` 的非模型类(如 DTO、服务)会被 Eloquent `Model` 类型门控排除。 **保护措施** — 三项检查各自独立触发(缺少多项的模型会在类行产生多个错误): | 标识符 | 触发条件 | |---|---| | `enforceAuditModelProtections.hasFactoryForbidden` | 模型使用 `HasFactory`(传递性 — 抽象基类上的继承 trait 也会计入)。工厂是绕过哈希链审计编写器的直接插入路径。 | | `enforceAuditModelProtections.softDeletesForbidden` | 模型使用 `SoftDeletes`。审计行仅允许追加且永不移除。 | | `enforceAuditModelProtections.updatedAtNotDisabled` | 模型未声明 `public const UPDATED_AT = null`。框架的 `Model` 基类设置了 `UPDATED_AT = 'updated_at'`,因此从不覆盖它的模型会保留可变的时间戳 — 审计行只写入一次且永不修改。全面禁用时间戳的模型(`public $timestamps = false;`)根本不会写入 `updated_at`,并会被原生识别为合规。 | 抽象中间类(`abstract class BaseAuditLog`)豁免 — 具体的叶子类会继承任何违规。 **从本地架构测试迁移** — 将架构测试的模型发现约定移至上面的参数中,删除本地的 `HasFactory` / `SoftDeletes` / `updated_at` 模型检查,该包规则就会成为唯一的执行权威。(对 `*Log` 类仅追加的 `update()` / `delete()` 禁令是单独的关注点,已由 `LogRule` 覆盖。)`$timestamps = false` 模型不需要抑制 — 规则会原生识别出全面禁用的时间戳。剩余的真正非审计误报可通过以特定标识符为键的 `ignoreErrors` 针对文件进行抑制,并附带理由注释: ``` parameters: ignoreErrors: - identifier: enforceAuditModelProtections.hasFactoryForbidden # seeded read-model projection, not an audit record; factory is test-only path: app/Models/Audit/SomeProjectionLog.php ``` ### Action 命名空间假设 `EnforceActionTransactionsRule` 和 `ForbidDatabaseManagerInActionsRule` 仅对命名空间以 `App\Actions` 开头的类触发。这与每个 `script-development` 领地中使用的 Laravel 约定相匹配。使用不同 Action 命名空间的领地应提交 PR 以使其可配置。 ## 类型扩展 `ConnectionTransactionReturnTypeExtension` 随规则一同注册。它将 `$connection->transaction(fn () => $foo)` 的返回类型解析为闭包的返回类型,而不是 `mixed`,从而实现事务调用点的严格类型化。 ## 生产环境依赖 `illuminate/*` 包(`database`、`contracts`、`cache`、`filesystem`、`log`、`mail`)被置于 `require` 而非 `require-dev` 中是故意的。规则和 `ConnectionTransactionReturnTypeExtension` 会在 *分析时* 针对 Illuminate 契约和类(例如 `Illuminate\Database\ConnectionInterface`,审计作用域规则推理的缓存/邮件/队列 Facade)进行反射 — 当使用者运行 PHPStan 时,此包的代码会解析这些符号,因此它们是真正的分析时(即扩展的运行时)依赖,而不仅仅是测试专用的工具。将它们移至 `require-dev` 会将它们从正常的 `composer require --dev` 安装中省略,并会破坏那些分析非 Laravel 或 Illuminate 符号不存在的部分树结构的使用者。 ## 版本控制 语义化版本控制: - **主版本** — 规则的行为发生改变,导致以前通过的代码出现 *新* 错误(例如,扩大写入方法列表,收紧 `LogRule` 的匹配)。 - **次版本** — 添加了新规则,或者规则获得了不更改默认值的选项。 - **修订号** — 错误修复、误报抑制、性能改进。 今天锁定到 0.x 的次版本(`^0.2`);未来的 1.0 发布将允许锁定 `^1.0`。有关 0.x 插入符号语义的理由,请参见 `CLAUDE.md` § 版本控制。 ## 开源许可证 MIT — 详见 `LICENSE`。
标签:ffuf, Laravel, OpenVAS, PHP, PHPStan, SOC Prime, 代码规范, 开发工具, 错误基检测, 静态代码分析