Ensuring PHPStan and Code Quality in a Custom Module
Ensuring PHPStan and Code Quality in a Custom Module
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
A green test run only answers "does the code behave as expected", never "is the code internally type-correct". PHPStan answers that second question - static analysis that checks every line against its declared types without executing anything. This project has a fixed rule for it in CLAUDE.md: level 5, zero errors, through the bin/analyse wrapper.
bin/analyse app/code/Mironsoft/Loyalty --level=5Two real traps specific to this module
CLAUDE.md already lists several known Magento interface gaps (PageInterface::getData(), StoreInterface::getBaseUrl(), StoreManagerInterface::getStores(), and similar) - this module adds two more cases of its own.
1. getCustomAttribute can return null
AwardPointsOnOrderPlaced (chapter 30) reads and writes loyalty_points_balance through CustomerRepositoryInterface::getById()->getCustomAttribute(). The return type is ?AttributeValueInterface - PHPStan correctly flags at level 5 that ->getValue() can't be called on a possibly-null value without a check, for instance for a brand-new customer who has never been assigned a points balance.
// Before the fix - PHPStan level 5: "Cannot call method getValue()
// on Magento\Framework\Api\AttributeValueInterface|null".
$balance = (int) $customer->getCustomAttribute('loyalty_points_balance')->getValue();
// After the fix - explicit null fallback instead of @-silencing or assert():
$balanceAttribute = $customer->getCustomAttribute('loyalty_points_balance');
$balance = $balanceAttribute !== null ? (int) $balanceAttribute->getValue() : 0;Tipp: null !== 0 isn't just a PHPStan formality here: a customer with no attribute set is, in business terms, the same as a customer with 0 points - the explicit fallback makes that assumption visible instead of hiding it silently in the code.
2. EAV collection iteration needs an annotation
Reward\Collection (chapters 12/15) inherits from \Magento\Eav\Model\Entity\Collection\AbstractCollection. Its getItems() is generically typed at the framework level - PHPStan therefore doesn't automatically know, while iterating, that each element is actually a Reward, and flags an error the moment a reward-specific method like getPointsCost() gets called.
foreach ($rewardCollection as $reward) {
/** @var Reward $reward */
if ($reward->getPointsCost() > $availablePoints) {
continue;
}
// ...
}Achtung: assert($reward instanceof Reward) instead of the @var annotation is explicitly forbidden by CLAUDE.md - depending on the zend.assertions PHP setting, assert() can be entirely optimized away in production, making it no reliable safeguard, only a PHPStan placebo.
When @phpstan-ignore-next-line is the right answer
The Magento core gaps listed in CLAUDE.md - such as Request::getFullActionName(), which this module doesn't need directly in any controller of its own, but could touch through a hypothetical plugin on a foreign controller - are the one case where // @phpstan-ignore-next-line is the right tool instead of a @var annotation: the interface itself is incomplete, there's no type uncertainty in the module's own code.
PHPStan in the pipeline
The phpstan job in the .gitlab-ci.yml from chapter 95 runs the exact same command as locally - no special case for CI, no diverging configuration. A level-5 error missed locally fails there at the latest, before the far slower test jobs even start.
Checklist: block 11 summarized
New and changed files from block 11
app/code/Mironsoft/Loyalty/
├── Model/
│ └── Config/
│ ├── LoyaltyFeatureFlagsConfigType.php (chapter 88)
│ └── Source/
│ └── LoyaltyFeatureFlagsFileSource.php (chapter 88)
├── Test/
│ ├── Unit/
│ │ ├── Model/
│ │ │ └── Service/
│ │ │ └── PointsCalculatorTest.php (chapter 91)
│ │ └── Observer/
│ │ └── AwardPointsOnOrderPlacedTest.php (chapter 92)
│ └── Integration/
│ └── Model/
│ └── PointsLedgerRepositoryTest.php (chapter 93, illustrative)
├── i18n/
│ ├── de_DE.csv (chapters 89/90)
│ └── en_US.csv (chapters 89/90)
└── etc/
└── di.xml (chapter 88, extended)
app/etc/loyalty_flags.php (chapter 88)
.gitlab-ci.yml (chapter 95)- Custom configuration type (chapter 88): for deploy-controlled, database-independent values - not a replacement for system.xml, but a complement for what system.xml is the wrong place for.
- Multi-language support via __() and CSV (chapter 89): English source string in code, character-exact translation in the locale CSV.
- Two separate locale sources (chapter 90): store view for the storefront, backend user account for the admin - one shared CSV per locale.
- Unit tests for pure logic (chapters 91-92):
PointsCalculatorwith no dependency at all, an observer with nine mocked dependencies but only two deliberately chosen test cases. - Integration tests for wiring (chapter 93): a real database, a real object manager, wherever a mock can't answer the actual question.
- Prioritized rather than complete coverage (chapter 94): money arithmetic and guards first, getters/setters last or never.
- CI automates everything above (chapter 95): fast unit tests on every push, slower integration tests only on merge requests.
- PHPStan level 5, zero errors (chapter 96):
@varannotations instead ofassert(),@phpstan-ignore-next-linereserved for genuine framework gaps.
Block 12 finally brings all twelve blocks together into one whole picture - starting with chapter 97, which once more shows all 32 module areas working together.