PHPackages                             script-development/phpstan-warroom-rules - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. script-development/phpstan-warroom-rules

ActivePhpstan-extension[Testing &amp; Quality](/categories/testing)

script-development/phpstan-warroom-rules
========================================

Canonical PHPStan rules enforcing war-room doctrine across script-development Laravel territories.

v0.6.0(3w ago)06.9k↑536.4%[2 issues](https://github.com/script-development/phpstan-warroom-rules/issues)[1 PRs](https://github.com/script-development/phpstan-warroom-rules/pulls)MITPHPPHP ^8.4CI passing

Since Apr 29Pushed 3w agoCompare

[ Source](https://github.com/script-development/phpstan-warroom-rules)[ Packagist](https://packagist.org/packages/script-development/phpstan-warroom-rules)[ RSS](/packages/script-development-phpstan-warroom-rules/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (7)Dependencies (25)Versions (26)Used By (0)

phpstan-warroom-rules
=====================

[](#phpstan-warroom-rules)

[![Packagist Version](https://camo.githubusercontent.com/63300574b60af609ce7b032cae6dd521dff9b2f9ad10093ed1a26574d0148fda/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f7363726970742d646576656c6f706d656e742f7068707374616e2d776172726f6f6d2d72756c65732e737667)](https://packagist.org/packages/script-development/phpstan-warroom-rules)[![PHP Version](https://camo.githubusercontent.com/eda1402844fdbf2791c8e590ead1781dfb20a217ad81e423bb77fcc41bb7072f/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f646570656e64656e63792d762f7363726970742d646576656c6f706d656e742f7068707374616e2d776172726f6f6d2d72756c65732f7068702e737667)](https://packagist.org/packages/script-development/phpstan-warroom-rules)[![CI](https://github.com/script-development/phpstan-warroom-rules/actions/workflows/ci.yml/badge.svg)](https://github.com/script-development/phpstan-warroom-rules/actions/workflows/ci.yml)[![License](https://camo.githubusercontent.com/099c39ecbc79171bc97769abe262d7ad20c02199ae2863e5b7686a16147c8bf5/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f7363726970742d646576656c6f706d656e742f7068707374616e2d776172726f6f6d2d72756c65732e737667)](LICENSE)

Canonical PHPStan rules enforcing war-room doctrine across `script-development` Laravel territories.

Distributed via Composer as `script-development/phpstan-warroom-rules`. Doctrine source is [ADR-0021](https://adrs.script.nl/decisions/phpstan-rules-package).

Why
---

[](#why)

Several doctrine claims need static-analysis enforcement that out-of-the-box PHPStan + Larastan cannot provide:

- Multi-write Actions must wrap operations in a database transaction.
- Audit log records are append-only.
- The `abort()` family of helpers is forbidden in favor of explicit HTTP exception throws.
- Action constructors inject `ConnectionInterface`, never `DatabaseManager`.

These rules originated inside `emmie` and have been promoted to a shared package so every consuming territory gets the same enforcement on `composer require`.

Installation
------------

[](#installation)

```
composer require --dev script-development/phpstan-warroom-rules
```

The package ships with `phpstan/extension-installer` metadata. If you have the installer, the extension is auto-loaded. Otherwise, add it to your `phpstan.neon`:

```
includes:
    - vendor/script-development/phpstan-warroom-rules/extension.neon
```

Rules
-----

[](#rules)

RuleIdentifierDetectsForbids / Requires`EnforceActionTransactionsRule``enforceActionTransactions.missingTransaction`Action `execute()` methodsIf ≥2 write operations appear without `->transaction()`, error.`ForbidDatabaseManagerInActionsRule``forbidDatabaseManager.inAction`Action constructorsConstructor parameter typed `DatabaseManager` is an error. Inject `ConnectionInterface` instead.`ForbidAbortHelperRule``forbidAbortHelper.abortUsed`Function calls`abort()`, `abort_if()`, `abort_unless()` are errors. Throw an explicit `HttpException` subclass instead.`ForbidHttpExceptionInActionsRule``forbidHttpExceptionInActions.httpExceptionInAction``throw` statements inside `App\Actions\*` classes (namespace prefix, incl. sub-namespaces)Throwing a `Symfony\Component\HttpKernel\Exception\HttpException`-family exception (`HttpException` + every subclass — `NotFoundHttpException`, `AccessDeniedHttpException`, `UnprocessableEntityHttpException`, …) from an Action is an error. Type-aware: the thrown expression's type must be a subtype of `Symfony\Component\HttpKernel\Exception\HttpExceptionInterface` (catches subclasses, fully-qualified throws, and typed-value throws an import-checking arch test would miss). HTTP status concerns belong to the HTTP layer — put a uniqueness rule in the FormRequest, or throw a custom domain exception the renderer maps to a status. `Illuminate\Validation\ValidationException` is **out of scope** (not a Symfony `HttpException`; Actions legitimately throw it for stateful validation). Type-aware sibling of `ForbidAbortHelperRule`. Doctrine: war-room §Architectural Principles — Explicit over implicit (#1) + Form Request → DTO → Action pipeline (#3).`LogRule``logRule.logModification``update()` / `delete()` callsIf the receiver type's class name contains `"Log"` or `"logs"` (case-insensitive), error.`LogBuilderTruncateRule``logRule.logModification``Builder->truncate()` callsIf the fluent chain's most recent `table()` call targets a Log-named table (string-literal argument matching `"log"` / `"logs"`, case-insensitive), error. Sibling rule to `LogRule`; shares the `logRule.logModification` identifier so a single `ignoreErrors` entry covers both. Eloquent `from()` chains and Model-`$table`-property-driven tables are acceptable misses. Doctrine: ADR-0001 §Append-only.`EnforceAuditSnapshotOnRetryRule``enforceAuditSnapshotOnRetry.firstStatementMustResetState``App\Actions\*` whose constructor injects an entity audit loggerThe first statement inside `$connection->transaction(...)` must reset the model's in-memory state (`$model->refresh()`, fresh fetch, or fresh instantiation). Doctrine: ADR-0001 §Snapshot-on-Retry Safety.`EnforceAuditTransactionScopeRule``enforceAuditTransactionScope.nonTransactionalMutationInClosure``App\Actions\*` whose `execute()` calls `transaction(...)` with a literal closureMutating `StatefulGuard` / `Session` / `Cache` / `Bus` / `Queue` / `Mailer` / `Notification` / `Broadcaster` / `Filesystem` state (or their `Illuminate\Support\Facades\*` counterparts) inside the closure is an error. Reads (`Auth::user()`, `Session::get()`, `Cache::get()`) are permitted. Doctrine: ADR-0029 (Audit Row Durability Contract) §Decision rule 3.`ForbidEloquentMutationInControllersRule``forbidEloquentMutationInControllers.eloquentMutationInController``App\Http\Controllers\*` (including sub-namespaces)Calling Eloquent persistence APIs (`save`, `update`, `delete`, `create`, `destroy`, `forceDelete`, `forceFill`, `push`, `restore`, `touch`, and their `*OrFail` / `*Quietly` / `*OrCreate` variants — 24-method blocklist) on `Illuminate\Database\Eloquent\Model` subclasses or `Illuminate\Database\Eloquent\Builder` chains is an error. Reads (`find`, `where`, `get`, `first`, `paginate`, `pluck`, `count`, `exists`, `query`) are permitted. Delegate mutations to an Action. Doctrine: ADR-0011 (Action Class Architecture) + ADR-0019 (Explicit Model Hydration).`EnforceResourceDataValidatorOptInRule``enforceResourceDataValidatorOptIn.missingValidatorCall`Classes extending `App\Http\Resources\ResourceData`If the class declares a non-empty `EAGER_LOAD_COUNT` / `EAGER_LOAD_SUM` constant but never calls `validateRelationsLoaded()` in any method, error.`EnforceFormRequestToDtoRule``enforceFormRequestToDto.missingToDtoMethod`Concrete classes extending `Illuminate\Foundation\Http\FormRequest`If the class neither declares nor inherits a `toDto()` method, error. Abstract intermediates (`BaseFormRequest`) are exempt. Hand Actions a typed DTO, not `$request->validated()` arrays. Doctrine: ADR-0012 (FormRequest → DTO Flow).`EnforceCurrentUserAttributeRule``enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser``Request::user()` / `Auth::user()` / `auth()->user()` calls inside `App\Http\Controllers\*` classes (namespace prefix, incl. sub-namespaces)Use `#[\Illuminate\Container\Attributes\CurrentUser] User $user` on the method parameter. Scope is decided by namespace, not class ancestry — a base-less `final` controller in `App\Http\Controllers` fires; FormRequests (`App\Http\Requests`), middleware (`App\Http\Middleware`), services, Actions (`App\Actions`), jobs, and console commands are silent because their namespaces do not start with the controller prefix (container-attribute injection does not apply to FormRequest methods regardless).### `EnforceActionTransactionsRule` — write-method list

[](#enforceactiontransactionsrule--write-method-list)

The rule counts the following methods as "writes":

`save`, `saveQuietly`, `create`, `update`, `delete`, `forceDelete`, `sync`, `attach`, `detach`, `insert`, `upsert`, `updateOrCreate`, `firstOrCreate`, `push`, `restore`, `toggle`, `syncWithoutDetaching`, `syncWithPivotValues`.

Calls on properties typed as non-database services (`FilesystemManager`, `Filesystem`, `Cache\Repository`, `LogManager`, `LoggerInterface`, `Mailer`) are excluded — `$this->files->delete($path)` does not trigger the rule.

### `LogRule` — false positives

[](#logrule--false-positives)

The rule uses substring matching on class names. It will fire on classes named `Catalog`, `Blog`, `Terminology`, or any business model containing `log` as a substring. Suppress per-territory via `phpstan.neon`:

```
parameters:
    ignoreErrors:
        -
            identifier: logRule.logModification
            path: app/Models/Catalog.php
```

Each ignore should carry a comment with rationale. Future versions may add an explicit allow-list parameter — file an issue if you have a recurring need.

`LogBuilderTruncateRule` shares the `logRule.logModification` identifier with `LogRule`. A single `ignoreErrors` entry keyed on `logRule.logModification` therefore covers both rules for the suppressed path.

### `EnforceResourceDataValidatorOptInRule` — configurable base class

[](#enforceresourcedatavalidatoroptinrule--configurable-base-class)

The rule scopes to classes extending `App\Http\Resources\ResourceData` by default. If a territory ships its abstract resource base under a different FQCN, override the `resourceDataBaseClass` parameter in `phpstan.neon`:

```
parameters:
    resourceDataBaseClass: 'App\Resources\BaseResource'
```

Inheritance is matched via PHPStan reflection (FQCN ancestor traversal), not short-name matching — a class named `ResourceData` in an unrelated namespace will not be matched. Compliant call shapes are `self::validateRelationsLoaded($model)`, `static::validateRelationsLoaded($model)`, and `$this->validateRelationsLoaded($model)` — the production base method is `protected static`, but the instance form is also accepted for compatibility with the source-of-truth Pest arch test's permissive matcher. Empty-array constants (`EAGER_LOAD_COUNT = []`) do not fire — they are no-ops.

### `EnforceFormRequestToDtoRule` — configurable base class + exemptions

[](#enforceformrequesttodtorule--configurable-base-class--exemptions)

The rule scopes to concrete classes extending `Illuminate\Foundation\Http\FormRequest` by default. To narrow the contract to a territory-local base FQCN, override the `formRequestBaseClass` parameter in `phpstan.neon`:

```
parameters:
    formRequestBaseClass: 'App\Http\Requests\BaseFormRequest'
```

Inheritance is matched via PHPStan reflection (FQCN ancestor traversal), not short-name matching. Abstract classes never fire — a per-territory abstract `BaseFormRequest` intermediate is exempt by shape, not by name. A `toDto()` declared on a parent class or provided by a trait satisfies the contract (mirroring the source-of-truth entreezuil Pest arch test's `method_exists()` matcher).

Legitimately DTO-less requests (e.g. a `LoginRequest` whose auth flow calls `AuthManager::attempt()` directly, or read-only filter/query requests) are suppressed per territory in one of two consumer-config-driven ways — never by name inside the rule.

**Option A — per-file `ignoreErrors` (path-keyed):**

```
parameters:
    ignoreErrors:
        -
            identifier: enforceFormRequestToDto.missingToDtoMethod
            path: app/Http/Requests/LoginRequest.php
```

Each ignore should carry a comment with rationale.

**Option B — `formRequestToDtoExemptClasses` (class-keyed):** a list of fully-qualified class names to skip, matched by **exact FQCN**. This is the class-keyed alternative to `ignoreErrors` — predictable across file moves, and it ports a retiring local arch test's exempt-class list into package config 1:1. Default empty ⇒ no exemptions.

```
parameters:
    formRequestToDtoExemptClasses:
        # login handler: auth flow calls Auth::attempt() directly, no Action DTO
        - 'App\Http\Requests\Auth\LoginRequest'
```

A consumer-supplied FQCN list is *config*, not a rule-body literal — the "never by name inside the rule" convention is preserved.

#### Retiring a local FormRequest→DTO arch test

[](#retiring-a-local-formrequestdto-arch-test)

Where a territory already enforces "every concrete FormRequest exposes `toDto()`" via a local Pest arch test (e.g. entreezuil's `backend/tests/Architecture/FormRequestsTest.php`), this rule now duplicates that invariant. To retire the local test cleanly:

1. Move the arch test's exempt-class list into `formRequestToDtoExemptClasses` as FQCNs. For entreezuil that is: ```
    parameters:
        formRequestToDtoExemptClasses:
            # framework Auth::attempt() path, no Action DTO
            - 'App\Http\Requests\Auth\LoginRequest'
            # intermediate base (make it `abstract` and it drops out entirely)
            - 'App\Http\Requests\BaseFormRequest'
    ```
2. Delete the local arch test — the package rule (identifier `enforceFormRequestToDto.missingToDtoMethod`) is now the single enforcement authority.

(Territory arch-test retirement is a separate follow-up dispatch, not part of shipping this option.)

### `EnforceCurrentUserAttributeRule` — false positives

[](#enforcecurrentuserattributerule--false-positives)

`#[\Illuminate\Container\Attributes\CurrentUser]` resolves the authenticated user at **method-entry DI time**. A controller method that resolves the user *after* `Auth::attempt()` succeeds — the canonical **login handler** on a `guest` / throttle-only route — cannot use the attribute: at method entry no user exists yet, so injection yields `null` and breaks login. The rule fires on any `Auth::user()` / `$request->user()` / `auth()->user()` inside the `App\Http\Controllers` namespace and **cannot see routes**, so it will flag these legitimate login handlers. Suppress per territory via `phpstan.neon` — never by name inside the rule:

```
parameters:
    ignoreErrors:
        -
            identifier: enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser
            # login handler: Auth::user() resolves after Auth::attempt() on a guest route
            path: app/Http/Controllers/Auth/AuthenticatedSessionController.php
```

Confirmed cross-territory (n=2, 2026-06-15): entreezuil `AuthenticatedSessionController::store`, ublgenie `AuthController::store`. Each consumer adds this on its `^0.4` bump.

### Action namespace assumption

[](#action-namespace-assumption)

`EnforceActionTransactionsRule` and `ForbidDatabaseManagerInActionsRule` only fire on classes whose namespace starts with `App\Actions`. This matches the Laravel convention used in every `script-development` territory. Territories using a different actions namespace should open a PR to make this configurable.

Type extension
--------------

[](#type-extension)

`ConnectionTransactionReturnTypeExtension` is registered alongside the rules. It resolves the return type of `$connection->transaction(fn () => $foo)` to the closure's return type instead of `mixed`, enabling strict typing of transaction call sites.

Production dependencies
-----------------------

[](#production-dependencies)

The `illuminate/*` packages (`database`, `contracts`, `cache`, `filesystem`, `log`, `mail`) sit in `require`, not `require-dev`, on purpose. The rules and `ConnectionTransactionReturnTypeExtension` reflect against Illuminate contracts and classes (e.g. `Illuminate\Database\ConnectionInterface`, the cache/mail/queue facades the audit-scope rule reasons about) *at analysis time* — when a consumer runs PHPStan, this package's code resolves those symbols, so they are genuine analysis-time (runtime-for-the-extension) dependencies, not test-only tooling. Moving them to `require-dev` would omit them from a normal `composer require --dev` install and break consumers that analyse non-Laravel or partial trees where the Illuminate symbols are not otherwise present.

Versioning
----------

[](#versioning)

Semantic versioning:

- **Major** — a rule's behavior changes in a way that surfaces *new* errors in code that previously passed (e.g. expanding the write-method list, tightening `LogRule`'s match).
- **Minor** — a new rule is added, or a rule gains an option that doesn't change defaults.
- **Patch** — bug fixes, false-positive suppression, performance improvements.

Pin to a 0.x minor version today (`^0.2`); future 1.0 release will allow `^1.0` pinning. See `CLAUDE.md` § Versioning for the 0.x caret-semantics rationale.

License
-------

[](#license)

MIT — see `LICENSE`.

###  Health Score

48

—

FairBetter than 94% of packages

Maintenance95

Actively maintained with recent releases

Popularity26

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity51

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 79.5% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~10 days

Total

7

Last Release

23d ago

PHP version history (2 changes)v0.1.0PHP ^8.3

v0.2.0PHP ^8.4

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/24678549?v=4)[Gerard Oosterhof](/maintainers/Goosterhof)[@Goosterhof](https://github.com/Goosterhof)

---

Top Contributors

[![Goosterhof](https://avatars.githubusercontent.com/u/24678549?v=4)](https://github.com/Goosterhof "Goosterhof (66 commits)")[![jasperboerhof](https://avatars.githubusercontent.com/u/68101885?v=4)](https://github.com/jasperboerhof "jasperboerhof (12 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (5 commits)")

---

Tags

laravelphpphpstanphpstan-extensionstatic-analysisPHPStanlaravelstatic analysisphpstan-extension

###  Code Quality

TestsPHPUnit

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/script-development-phpstan-warroom-rules/health.svg)

```
[![Health](https://phpackages.com/badges/script-development-phpstan-warroom-rules/health.svg)](https://phpackages.com/packages/script-development-phpstan-warroom-rules)
```

###  Alternatives

[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.5k55.4M9.2k](/packages/larastan-larastan)[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9762.4M133](/packages/roots-acorn)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

255.2k](/packages/aedart-athenaeum)[laravel/ai

The official AI SDK for Laravel.

1.0k3.2M246](/packages/laravel-ai)[flarum/core

Delightfully simple forum software.

211.4M2.4k](/packages/flarum-core)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
