Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5b87320
feat(twig): add Craft-tolerant Twig AST parser + phpunit harness
Jensderond Jun 30, 2026
70aeaa4
test(twig): cover parseFile success and missing-file branches
Jensderond Jun 30, 2026
65656ce
feat(twig): add RelationFieldRegistry for relational handle lookup
Jensderond Jun 30, 2026
3ea2418
feat(twig): detect N+1 relational access in loops
Jensderond Jun 30, 2026
d28fa93
feat(twig): add nested-relation, query-in-loop, length-on-query and u…
Jensderond Jun 30, 2026
d3c0c5e
fix(twig): don't flag a loop's own source query as query-in-loop
Jensderond Jun 30, 2026
6007824
feat(twig): wire TwigPerformanceRule into the PHPStan extension
Jensderond Jun 30, 2026
381c5d4
docs: document Twig performance checks and limitations
Jensderond Jun 30, 2026
f4f0451
fix(twig): detect query-in-loop when a property is read off the fetch
Jensderond Jun 30, 2026
d47010e
feat(twig): declare twig dependency and document result-cache limitation
claude Jul 1, 2026
8646471
feat(twig): unify template scanning, make result cache template-aware…
claude Jul 1, 2026
8834d3a
chore: bump version to 1.4.0
claude Jul 1, 2026
9cbbbda
feat(twig): reduce false positives for static variables
Jensderond Jul 1, 2026
589631a
fix(twig): stop flagging eager-loaded relations passed across includes
Jensderond Jul 1, 2026
c462180
fix(twig): recognise nested dot-path eager-loading for N+1 check
Jensderond Jul 1, 2026
620b1e1
fix: regenerate composer.lock content-hash
claude Jul 1, 2026
903bd03
fix(twig): correct loop-frame handling for nested loops and for-else
claude Jul 1, 2026
bb342a7
docs: describe automatic result-cache invalidation, twig floor, scann…
claude Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
composer.phar
/vendor/
/.phpunit.cache

# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Extension for PHPStan for better support for Craft CMS. The following features a
- Dependency injection container support for `Craft::$container->get()`
- Recognises custom field handles as properties on `Element` and `ElementQuery`, including handle overrides from entry-type field layouts (read from your project config)
- Validates the `action` value of Twig `actionInput()` calls against discovered controller routes (including shorthand routes for controller default actions)
- Detects Twig N+1 query patterns and related performance smells (relational access in loops, queries built in loops, `|length` on queries, unbounded `.all()`), with eager-load (`.with([...])`) and Craft 5 `.eagerly()` suppression

## Install

Expand All @@ -18,6 +19,10 @@ Via Composer
$ composer require --dev jensderond/phpstan-craftcms
```

The Twig checks require `twig/twig` ^3.27. Craft pins Twig to a specific minor
(e.g. Craft 5.10 uses `~3.27.0`), so projects on an older Craft 5.x release that
locks Twig below 3.27 need to update Craft before installing this version.

## Usage

Add `phpstan-craftcms` to the project `phpstan.neon` / `phpstan.neon.dist`:
Expand All @@ -42,12 +47,61 @@ parameters:
- %currentWorkingDirectory%/modules
- %currentWorkingDirectory%/plugins
handleMap: []
craftTemplateScan:
excludeDirectories:
- vendor
- node_modules
craftTwigPerformance:
enabled: true
templatePaths: %craftActionInput.templatePaths%
checks:
nPlusOne: true
nestedRelationAll: true
queryInLoop: true
lengthOnQuery: true
unboundedAll: false
```

- `yii2.config_path` — path to your Yii/Craft application config used to build the service and route maps.
- `craftcms.projectConfigPath` — path to the Craft project config directory; used to collect custom field handles and entry-type handle overrides.
- `craftActionInput.templatePaths` — directories scanned for Twig `actionInput()` calls.
- `craftActionInput.handleMap` — optional map of additional handle aliases used when resolving `actionInput()` values to controllers.
- `craftTemplateScan.excludeDirectories` — directory **names** pruned from the template walk (shared by the action-input and performance scans). Defaults to `vendor` and `node_modules`, so third-party templates a package bundles (Craft's own control-panel templates, other plugins' example/asset templates) are not analysed when a scanned path contains them. Add more names (e.g. `tests`) to skip further trees.

### Twig performance checks

`craftTwigPerformance.checks` toggles each check; `unboundedAll` is off by default
(it is often intentional). Suppress a check project-wide via `ignoreErrors` using
its identifier:

- `craftcms.twigNPlusOne`
- `craftcms.twigNestedRelationAll`
- `craftcms.twigQueryInLoop`
- `craftcms.twigLengthOnQuery`
- `craftcms.twigUnboundedAll`

Limitations: analysis is per-template — it does not follow loop variables across
`{% include %}`, macros, or block boundaries. Loop-source eager-loading is detected
inline or one `{% set %}` back. `.with(...)` is honored only for literal string
arrays; dynamic arguments suppress the finding. `|length`-on-query detection is a
best-effort static heuristic. Eager-load suppression recognizes both
`.with([...])` and Craft 5's `.eagerly()`. Relation checks
(`twigNPlusOne`/`twigNestedRelationAll`) fire only for loops that iterate element
query results: a loop over a static array/hash literal — directly, via `{% set %}`,
or through nested loops — yields plain values, so an attribute that happens to
share a relation field's handle (e.g. `font.file` over a hash literal) is not
flagged.

#### Result cache and template changes

The Twig checks scan the template tree directly, outside PHPStan's per-file
analysis. PHPStan's result cache is normally keyed on the analysed **PHP**
files (and the config) only, so this extension registers a result-cache meta
extension that folds a hash of every discovered template's path and contents
into the cache metadata. Editing, adding, or removing a `.twig` file changes
that hash, which invalidates the result cache and triggers a full re-analysis —
no manual `clear-result-cache` needed. Template changes are picked up by a
plain `phpstan analyse`, locally and in CI, just like PHP changes.

## Credits

Expand Down
18 changes: 13 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
{
"name": "jensderond/phpstan-craftcms",
"description": "CraftCMS extension for PHPStan",
"version": "1.3.0",
"version": "1.4.0",
"require": {
"phpstan/phpstan": "^2.1"
"phpstan/phpstan": "^2.1",
"twig/twig": "^3.27"
},
"require-dev": {
"craftcms/cms": "~5",
"laravel/pint": "^1.25"
"craftcms/cms": "^5.10.8",
"laravel/pint": "^1.25",
"phpunit/phpunit": "^11.5"
},
"license": "MIT",
"autoload": {
"psr-4": {
"Jensderond\\PhpstanCraftcms\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Jensderond\\PhpstanCraftcms\\Tests\\": "tests/"
}
},
"authors": [
{
"name": "Jens de Rond",
"email": "jensderond@gmail.com"
}
],
"scripts": {
"lint": "pint"
"lint": "pint",
"test": "phpunit"
},
"config": {
"allow-plugins": {
Expand Down
Loading