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

PHPStan und Codequalität im eigenen Modul sicherstellen

PHPStan und Codequalität im eigenen Modul sicherstellen

~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Ein grüner Testlauf beantwortet nur "verhält sich der Code wie erwartet", nie "ist der Code in sich typkorrekt". Genau diese zweite Frage beantwortet PHPStan - statische Analyse, die jede Zeile ohne Ausführung gegen die deklarierten Typen prüft. Dieses Projekt hat dafür eine feste Regel in CLAUDE.md: Level 5, null Fehler, per bin/analyse-Wrapper.

bin/analyse app/code/Mironsoft/Loyalty --level=5

Zwei echte Stolperfallen speziell in diesem Modul

CLAUDE.md listet bereits mehrere bekannte Magento-Interface-Lücken (PageInterface::getData(), StoreInterface::getBaseUrl(), StoreManagerInterface::getStores() und ähnliche) - dieses Modul bringt zwei weitere, ihm eigene Fälle mit.

1. getcustomattribute-kann-null-liefern

AwardPointsOnOrderPlaced (Kapitel 30) liest und schreibt loyalty_points_balance über CustomerRepositoryInterface::getById()->getCustomAttribute(). Der Rückgabetyp ist ?AttributeValueInterface - PHPStan meldet auf Level 5 zu Recht, dass ->getValue() nicht ohne Weiteres auf einem möglicherweise null-Wert aufgerufen werden darf, etwa für einen brandneuen Kunden, dem noch nie ein Punktestand zugewiesen wurde.

// Vor der Korrektur - PHPStan Level 5: "Cannot call method getValue()
// on Magento\Framework\Api\AttributeValueInterface|null".
$balance = (int) $customer->getCustomAttribute('loyalty_points_balance')->getValue();

// Nach der Korrektur - expliziter Null-Fallback statt @-Silencing oder assert():
$balanceAttribute = $customer->getCustomAttribute('loyalty_points_balance');
$balance = $balanceAttribute !== null ? (int) $balanceAttribute->getValue() : 0;

Tipp: null !== 0 ist hier nicht nur eine PHPStan-Formalität: ein Kunde ohne gesetztes Attribut ist geschäftlich dasselbe wie ein Kunde mit 0 Punkten - der explizite Fallback macht diese Annahme sichtbar, statt sie stillschweigend im Code zu verstecken.

2. eav-collection-iteration-braucht-eine-annotation

Reward\Collection (Kapitel 12/15) erbt von \Magento\Eav\Model\Entity\Collection\AbstractCollection. Deren getItems() ist auf Framework-Ebene generisch typisiert - PHPStan kennt beim Iterieren also nicht automatisch, dass jedes Element tatsächlich ein Reward ist, und meldet einen Fehler, sobald Reward-spezifische Methoden wie getPointsCost() aufgerufen werden.

foreach ($rewardCollection as $reward) {
    /** @var Reward $reward */
    if ($reward->getPointsCost() > $availablePoints) {
        continue;
    }
    // ...
}

Achtung: assert($reward instanceof Reward) statt der @var-Annotation ist laut CLAUDE.md ausdrücklich verboten - assert() kann je nach zend.assertions-PHP-Einstellung in Produktion komplett wegoptimiert werden und ist damit kein verlässlicher Schutz, nur eine PHPStan-Beruhigung.

Wann @phpstan-ignore-next-line die richtige Antwort ist

Die in CLAUDE.md gelisteten Magento-Kern-Lücken - etwa Request::getFullActionName(), das dieses Modul in keinem eigenen Controller direkt braucht, aber in einem hypothetischen Plugin auf einen fremden Controller betreffen könnte - sind der einzige Fall, in dem // @phpstan-ignore-next-line statt einer @var-Annotation der richtige Weg ist: das Interface selbst ist unvollständig, keine Typunsicherheit im eigenen Code.

PHPStan in der Pipeline

Der phpstan-Job aus der .gitlab-ci.yml in Kapitel 95 ruft exakt denselben Befehl auf wie lokal - kein Sonderfall für CI, keine abweichende Konfiguration. Ein Level-5-Fehler, der lokal übersehen wird, scheitert spätestens dort, bevor die deutlich langsameren Test-Jobs überhaupt starten.

Checkliste: Block 11 zusammengefasst

Neue und geänderte Dateien aus Block 11

app/code/Mironsoft/Loyalty/
├── Model/
│   └── Config/
│       ├── LoyaltyFeatureFlagsConfigType.php       (Kapitel 88)
│       └── Source/
│           └── LoyaltyFeatureFlagsFileSource.php   (Kapitel 88)
├── Test/
│   ├── Unit/
│   │   ├── Model/
│   │   │   └── Service/
│   │   │       └── PointsCalculatorTest.php        (Kapitel 91)
│   │   └── Observer/
│   │       └── AwardPointsOnOrderPlacedTest.php     (Kapitel 92)
│   └── Integration/
│       └── Model/
│           └── PointsLedgerRepositoryTest.php       (Kapitel 93, illustrativ)
├── i18n/
│   ├── de_DE.csv                                    (Kapitel 89/90)
│   └── en_US.csv                                    (Kapitel 89/90)
└── etc/
    └── di.xml                                       (Kapitel 88, ergänzt)

app/etc/loyalty_flags.php                             (Kapitel 88)
.gitlab-ci.yml                                        (Kapitel 95)
  1. Eigener Configuration Type (Kapitel 88): für deploy-gesteuerte, DB-unabhängige Werte - nicht als Ersatz für system.xml, sondern als Ergänzung dafür, wofür system.xml der falsche Ort ist.
  2. Mehrsprachigkeit über __() und CSV (Kapitel 89): englischer Quellstring im Code, zeichengenaue Übersetzung in der Locale-CSV.
  3. Zwei getrennte Locale-Quellen (Kapitel 90): Store View für den Storefront, Backend-Benutzerkonto für den Admin - eine gemeinsame CSV pro Locale.
  4. Unit Tests für reine Logik (Kapitel 91-92): PointsCalculator ohne jede Abhängigkeit, ein Observer mit neun gemockten Abhängigkeiten, aber nur zwei bewusst ausgewählten Testfällen.
  5. Integrationstests für Verdrahtung (Kapitel 93): echte Datenbank, echter Object Manager, dort wo ein Mock die eigentliche Frage nicht beantworten kann.
  6. Priorisierte statt vollständige Abdeckung (Kapitel 94): Geld-Arithmetik und Guards zuerst, Getter/Setter zuletzt oder nie.
  7. CI automatisiert alles oben Genannte (Kapitel 95): schnelle Unit Tests bei jedem Push, langsamere Integrationstests nur bei Merge Requests.
  8. PHPStan Level 5, null Fehler (Kapitel 96): @var-Annotationen statt assert(), @phpstan-ignore-next-line ausschließlich für echte Framework-Lücken.

Block 12 fügt als Letztes alle zwölf Blöcke zu einem Gesamtbild zusammen - beginnend mit Kapitel 97, das noch einmal alle 32 Modul-Bereiche im Zusammenspiel zeigt.