Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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=5

Two 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)
  1. 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.
  2. Multi-language support via __() and CSV (chapter 89): English source string in code, character-exact translation in the locale CSV.
  3. Two separate locale sources (chapter 90): store view for the storefront, backend user account for the admin - one shared CSV per locale.
  4. Unit tests for pure logic (chapters 91-92): PointsCalculator with no dependency at all, an observer with nine mocked dependencies but only two deliberately chosen test cases.
  5. Integration tests for wiring (chapter 93): a real database, a real object manager, wherever a mock can't answer the actual question.
  6. Prioritized rather than complete coverage (chapter 94): money arithmetic and guards first, getters/setters last or never.
  7. CI automates everything above (chapter 95): fast unit tests on every push, slower integration tests only on merge requests.
  8. PHPStan level 5, zero errors (chapter 96): @var annotations instead of assert(), @phpstan-ignore-next-line reserved 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.