V tomto průvodci
Výchozí stav
Aplikace je modulární CMS s front endem, administrací a interní vrstvou API, psané mnoho let. PHPStan běžel na level 9, ale většina chyb ležela v baseline souborech po jednotlivých etapách: na začátku práce to bylo 16 611 ignorovaných chyb. Baseline je užitečné příměří, ne oprava: každá ignorovaná chyba je místo, kde nás analyzátor přestal chránit.
Nulová baseline dosažená postupně, zatímco se na stejné větvi normálně vyvíjí.
Žádné změny chování skryté v práci s typy. Každá oprava je buď neutrální, nebo záměrná oprava chyby pojmenovaná v commitu.
V době psaní je jich 780 a práce po cestě odhalila víc než tucet chyb viditelných uživatelům.
1. Pravidla hry
Doporučení samotného PHPStan je stručné a převzali jsme je jako závazná pravidla. Každé existuje proto, že zkratka skrývá vadu, na kterou chyba ukazuje.
- Žádné komentáře
@phpstan-ignorea žádné nové záznamy v baseline pro nový kód. - Žádná přetypování přidaná jen kvůli umlčení chyby.
(int) $row['id']bez protestu změní pole na1. - Žádná přepsání přes inline
@vara žádnéassert()pro vynucení typu. - Žádné rozšiřování typů parametrů nebo návratových hodnot jen proto, aby chyba zmizela.
- Opravte příčinu: chybějící rozhraní, nullovatelnou hodnotu, kterou nikdo neošetřil, větev, která se nikdy nespustí.
2. Postup s baseline: západka (ratchet), pročištění a soubor pro plný průchod
Tři malé nástroje dělají postupný pokrok bezpečným.
Západka (ratchet)
Pre-commit hook spočítá každý záznam v baseline vážený jeho count, porovná součet s HEAD a commit odmítne, pokud vzrostl. Pokrok může jít jen jedním směrem.
php tests/Manual/System/phpstan_baseline_ratchet.php
# PHPStan baseline ratchet: HEAD=12449 now=11989 -460
Pročištění
Po opravě zastarají odpovídající záznamy v baseline. Pročištění přečte výstup PHPStan v JSONu a ve všech souborech baseline odstraní nebo sníží záznamy, které už neodpovídají.
vendor/bin/phpstan analyse --error-format=json > result.json
php tests/Manual/System/phpstan_baseline_prune.php --write --json=result.json
Soubor pro plný průchod
Starší baseline po etapách zůstávají, jak jsou. Všechno ostatní se sbírá v jednom znovu generovaném souboru. Na pořadí generování záleží: dočasně odeberte include souboru pro plný průchod, pročistěte ostatní soubory, soubor pro plný průchod vygenerujte znovu, vraťte include a pročistěte ještě jednou.
[OK] No errors.reportUnmatched: false a záznamy připněte pomocí paths: místo jediné path:.3. Hodnoty mixed z databáze a požadavků
Příznak. Cannot cast mixed to int, Parameter #1 expects int, mixed given u každého fetchAssociative(), fetchOne() i parametru požadavku.
Oprava. Jeden drobný pomocník, který se u skalárů a null chová přesně jako přetypování, a pro cokoli, co tam patřit nemůže, vyhodí výjimku:
final class DbValue
{
public static function int(mixed $value): int
{
if (\is_scalar($value) || $value === null) {
return (int) $value;
}
throw new \UnexpectedValueException('Expected a number, got ' . \get_debug_type($value) . '.');
}
public static function nullableInt(mixed $value): ?int { return $value === null ? null : self::int($value); }
public static function string(mixed $value): string { /* same idea, also accepts Stringable */ }
}
Stávající chování zůstává u platných dat beze změny, takže se to dá aplikovat mechanicky. Rozdíl je v tom, že vnořené pole se už potichu nezmění na 1.
4. getId() na objektech, které PHPStan nevidí
Příznak. Cannot call method getId() on object nebo na mixed. Byl to největší shluk. Kolekce, generické manažery a proxy vracejí obyčejný object a kód volá getId() na čemkoli, co přijde.
Oprava. Jedno rozhraní, které implementuje každá entita s identifikátorem, a pomocník pro místa volání, kde je statický typ pořád volný:
interface IdentifiableInterface
{
public function getId(): ?int;
}
final class EntityId
{
public static function of(mixed $entity): ?int
{
return $entity instanceof IdentifiableInterface ? $entity->getId() : null;
}
}
Důležité je, co následuje. Návratový typ je teď poctivě ?int a PHPStan sleduje to null na každé místo, které předpokládalo celé číslo. Každé z nich vyžaduje rozhodnutí:
| Situace | Rozhodnutí |
|---|---|
| Sestavení seznamu nebo voleb výběru | Přeskočit položky bez id (continue). |
| Mazání podle id | Vrátit neúspěšný výsledek: neuloženou entitu nelze smazat. |
| Autentizace | Vyhodit výjimku: uživatel bez id se nikdy nesmí přihlásit. |
| Logování | Vypsat jako prázdné. Nikdy nevkládejte statické volání do řetězce ve dvojitých uvozovkách (viz oddíl 12). |
getId(): int? Nové entity opravdu nemají id, dokud se neprovede flush. Deklarovat jinak jen přesouvá problém na TypeError za běhu.5. Netypovaná pole odpovědí
Příznak. Cannot access offset 'message' on mixed, opakovaně v každém repozitáři, který čte obálku odpovědi interního API.
Oprava. Malá třída pro čtení odpovědi s jednou metodou pro každé pole, každá s bezpečnou výchozí hodnotou:
ApiEnvelope::isSuccess($response); // true only for success === true
ApiEnvelope::message($response, 'Save failed.');
ApiEnvelope::code($response); // always a ResultCode
ApiEnvelope::arrayData($response); // array or null
@phpstan-assert-if-true na isSuccess(). Vypadá to lákavě, ale u vstupů, které už mají tvrzený tvar, PHPStan hlásí volání jako „always true“, což vytváří nové chyby přesně v kódu, který jste čistili.6. Nullovatelné kontextové objekty
Příznak. Kontext vázaný na požadavek, u nás aktuální jazyk, ukládal hodnotu jako object. Každý spotřebitel volal getLanguage()->getId() a výsledek předával metodám, které čekaly int.
Oprava. Uloženou hodnotu otypujte skutečnou třídou entity a spotřebitelům dejte dva výslovné přístupové body. Každý volající pak uvede svůj záměr:
public function getLanguageId(): ?int
{
return $this->language?->getId();
}
public function requireLanguageId(): int
{
return $this->getLanguageId() ?? throw new \LogicException('Language is not set in the context.');
}
Presentery front endu, které vždy běží po vyřešení jazyka, používají requireLanguageId(). Služby na pozadí, například odesílání e-mailů, přijímají null a výslovně se vrátí k výchozímu jazyku.
'cs', kde se čekalo číselné id jazyka, takže se aktivační e-mail mohl odeslat ve špatném jazyce. Otypování kontextu to zviditelnilo.7. Duck typing a mrtvé větve, které skrývá
Příznak. Call to an undefined method na generickém manažeru nebo mapperu, obvykle hlídané pomocí method_exists().
Oprava. Zaveďte rozhraní, které kód už předpokládal (u nás rozhraní mapperu, rozhraní stromového mapperu a rozhraní stromového manažeru), a deklarujte ho na třídách. Hlídání pomocí method_exists() zmizí.
Jakmile jsou typy poctivé, PHPStan začne hlásit instanceof ... will always evaluate to true a Result of && is always false. Berte to jako nálezy, ne šum: označují kód, který se nikdy nespustí. Typický případ je rozbalení líné proxy pro hodnotu, o které je už zaručeno, že je skutečná entita. Takové větve smažte místo toho, abyste je dávali do baseline.
Výsledky nejsou přepravka na typovaná data
Pomocník, který vracel tabulku, sloupce a rozsah uvnitř Result::success(data: [...]), ztratil cestou všechny typy, protože getData() je mixed. Sedm operací nad stromem pak předávalo mixed do typovaných metod repozitáře. Malý readonly hodnotový objekt vracený jako TreeStructure|Result (kde Result znamená selhání) odstranil přetypování i chyby najednou.
Traity, které skončily ve špatné třídě
Chyby jako „call to an undefined method CategoryPresenter::initUser()“ často znamenají, že trait byl přidán třídě, která ho nikdy nepotřebovala. U nás presenter přehledu kategorií používal trait pro detail inzerátu a spolu s ním zpřístupnil potvrzovací signál a dvě komponenty formuláře na stránce, která žádný inzerát nemá. Šablona nic z toho nepoužívala, takže opravou bylo trait odstranit.
8. Mapování asociací v Doctrine ORM 3
Příznak. Přístup přes offset k mapování asociací, například $mapping['joinColumns'][0]['name'], neprojde analýzou, protože ORM 3 vrací objekty mapování, ne pole.
Oprava. Jeden pomocník, který zúží na správnou třídu mapování a jinak hlasitě selže:
public static function joinColumnName(AssociationMapping $mapping): string
{
if ($mapping instanceof ToOneOwningSideMapping) {
return $mapping->joinColumns[0]->name;
}
throw new \LogicException('Association ' . $mapping->fieldName . ' has no join column.');
}
public static function mappedBy(AssociationMapping $mapping): ?string
{
return $mapping instanceof InverseSideMapping ? $mapping->mappedBy : null;
}
8b. Názvy tříd: class-string, nebo kontrola
Příznak. expects class-string<object>, string given u getClassMetadata(), getRepository() a getByType() v DI kontejneru.
Oprava. Závisí na tom, odkud se název bere:
- Předaný volajícím: anotujte parametr
@param class-string $entityClassa nechte PHPStan procházet řetěz směrem nahoru. Malý skript krok opakoval, dokud se neobjevila žádná nová místa. - Sestavený za běhu (názvy modulů a komponent, třída továrny uložená v databázi, platební callback): zkontrolujte ho pomocí
class_exists(). Pomocník jakohasServiceByType()může nést@phpstan-assert-if-true class-string $className, takže kontrola zároveň zúží typ. - Ne vždy třída: „typ vlastnosti“ hydrátoru může být také
int. Tady je správnou opravou kontrolaisEntity()s tvrzením, ne anotaceclass-string.
Související past: array_map('intval', $ids) na poli typu mixed potichu změní prvek pole na 1. Pomocník DbValue::ints() přetypovává skaláry přesně jako (int), zahazuje prvky, které nemohou být čísla, a seznam nechává seznamem.
9. Generika a hranice šablon
Základní manažery, služby a mappery sdílejí desítky modulů. Zgenerizovat je jedním krokem by se dotklo všech modulů najednou. Tomu se vyhnou dvě funkce PHPStan.
Výchozí hodnoty šablon nechají nepřevedené moduly fungovat jako dřív. Když má každá šablona výchozí hodnotu rovnou své hranici, neparametrizovaný Advert v signatuře prostě znamená „hranici“ a missingType.generics se nehlásí. Přidání = bound k 539 šablonám entit a do vrstev služeb a manažerů odstranilo asi 2 400 záznamů z baseline, aniž by se sáhlo na kód za běhu:
/**
* @template TEntity of BaseEntity = BaseEntity
*/
abstract class Manager { /* ... */ }
Variance v místě volání nechá sdílený kód přijmout jakoukoli specializaci bez chyb invariance:
/** @param Manager<covariant ServiceInterface<covariant BaseEntity>, covariant BaseEntity> $manager */
Moduly se pak jeden po druhém převádějí přidáním @extends Manager<Product> a každý krok odstraní kus baseline.
Omezte šablony na to, co kód skutečně volá
Entity s překlady měly @template TTranslation of object, takže každé $translation->getLanguageId() ve sdíleném traitu bylo chybou „undefined method“, opakovanou u každé z 54 entit. Oprava je rozhraní pro to, co kód volá, a hranice jako průnik, protože nativní typy traitu pořád říkají BaseEntity:
interface TranslationInterface
{
public function getLanguageId(): ?int;
}
/** @template TTranslation of BaseEntity&TranslationInterface */
trait TranslationCollectionTrait { /* ... */ }
Globální alias typu udržuje docblocky čitelné a v typech parametrů by měl být covariant, aby se přijalo Status<StatusText>:
# phpstan.neon
parameters:
typeAliases:
TranslationEntity: 'App\Core\Base\BaseEntity&App\...\TranslationInterface'
/** @param OrderStatus<covariant TranslationEntity> $status */
Zpřísnění hranice promění každé X<object> v docblocku v chybu, což je užitečné. Těch 131 míst se přepsalo skriptem, který čte pořadí @template v každé třídě a nahrazuje jen argument na pozici překladu. Stejné zpřísnění odhalilo entitu deklarovanou jako @extends BasePage<Route, PageText> s vyměněnými argumenty. S hranicemi object to potichu procházelo a jeden volající nesl inline @var, aby to obešel. Tento jediný krok odstranil 528 záznamů z baseline.
10. Pole: typy hodnot a tvary
Příznak. should return array<int, string> but returns array<int|string, string>. Generický pomocník pro seznamy nemůže vědět, že každý klíč je celé číslo.
Typy hodnot pro parametry s obyčejným array
Parametr typovaný jen jako array vyvolá missingType.iterableValue. Asi tisíc jsme jich doplnili podle názvu parametru, podle konvence, kterou už používalo rozhraní mapperu: *Ids se stane array<int>, řádky, parametry a hodnoty array<string, mixed> a cokoli jiného array<array-key, mixed>.
array jako implicitní mixed a nekontroluje, co se s hodnotami dělá. Jakmile typ hodnoty vypíšete, mixed se stane explicitním a hlásí se každé nekontrolované použití. Objevilo se asi 480 takových míst. Mezi nimi query builder, jehož include('*') porušoval vlastní rozhraní, a array_merge(), které by na řetězci selhalo.Nechte PHPStan říct návratový typ
U metody, která vrací array bez typu hodnot nebo nemá návratový typ vůbec, nemusíte hádat. Dočasně přidejte záměrně špatnou značku: @return array<never, never> pro pole, @return void pro netypované metody. PHPStan pak u každého return ohlásí „should return … but returns X“ a X je typ, který odvodil. Hodnoty X sloučte po metodách a zapište zpět. Ponechte jen konkrétní výsledky: přeskočte cokoli s literálními hodnotami, cokoli velmi dlouhého a výrazy šablon jako T of object = object. Názvům tříd přidejte předponu \, protože PHPStan je vypisuje bez ní. Nezapisujte vágní výsledky jako array<array-key, mixed>: odhalují navazující chyby mixed, aniž by přidaly přesnost.
Tvary polí z generických pomocníků. Pokud smyčka už existuje, sestavte mapu přímo v ní s klíči, které jste už zkontrolovali. Je to kratší než volání pomocníka a plně typované:
$options = [];
foreach ($items as $item) {
$id = EntityId::of($item);
if ($id === null) {
continue;
}
$options[$id] = $item->getTranslation($langId)?->getName() ?? '';
}
Stejný vzor opraví array_map(fn ($x): int => EntityId::of($x), ...): mapujte na ?int a pak použijte array_filter a array_values.
11. Testy, které se zpřísňují s vámi
Unit testy často nahrazují entity anonymními třídami, které mají jen metodu getId(). Když se parametr stane ?Language nebo EntityId::of() kontroluje rozhraní, tyto náhražky buď vyvolají TypeError, nebo se potichu změní na null, což je horší.
- Dávejte přednost skutečné instanci entity:
(new Language())->setId(1). - Pokud se anonymní třídě nelze vyhnout, nechte ji implementovat rozhraní.
- Z nahrazeného presenteru vracejte skutečný kontextový objekt místo vnořené anonymní třídy.
12. Hromadné úpravy, které kousnou
Většina těchto oprav se aplikovala skripty napříč stovkami souborů. Tyto pasti se objevily víckrát:
| Past | Výsledek | Pojistka |
|---|---|---|
Regulární výraz se zastaví u ( nebo ?-> |
DbValue::int($conn->executeQuery) bez argumentů |
Po každé dávce spusťte PHPStan. Zachytil oba případy. |
| Volání pomocníka uvnitř řetězce ve dvojitých uvozovkách | "user={EntityId::of($u)}" za běhu selže, protože PHP statická volání neinterpoluje |
Prohledání tokenizerem na názvy pomocníků uvnitř tokenů řetězců |
| Přenesená úvodní zpětná lomítka | \ApiEnvelope:: se přeložilo do globálního jmenného prostoru |
Lint a PHPStan |
| Smíšené odsazení | Vzor napsaný pro tabulátory neodpovídá souboru odsazenému mezerami | Ověřte počet shod a skript zastavte |
Vkládání return; jako pojistky |
Fatální chyby v nevoid metodách | php -l na každém změněném souboru |
Každý skript ověřuje, že každý vzor odpovídal přesně tolikrát, kolikrát se čekalo. Skript, který hlasitě selže v půlce, je mnohem lepší než ten, který tiše přeskočí soubor.
13. Kde se typy ztratily
V polovině práce už většina zbývajících chyb neukazovala na špatný kód. Ukazovala na hodnotu, jejíž skutečný typ vývojář znal, ale cestou k PHPStan se ztratil. Každé místo níže změnilo konkrétní entitu zpět na BaseEntity nebo object. Oprava zdroje odstranila najednou desítky chyb v navazujícím kódu.
| Kde | Příznak | Oprava |
|---|---|---|
Koncová entita bez @extends Base<…> |
Každá šablona sdílené báze spadne na výchozí hodnotu | Odvoďte argumenty: pro každou šablonu najděte vlastnost báze typovanou jí a přečtěte targetEntity z mapování Doctrine koncové třídy |
Sdílená třída používající trait kolekce bez @use Trait<T> |
Metody traitu vracejí Collection<int, BaseEntity> |
Přidejte @use těsně nad řádek use |
Fluentní settery deklarované jako : self |
(new Message())->setA()->setB() se změní na sdílenou základní třídu |
@return $this u každého setteru, jehož tělo jen vrací $this |
Fluentní objekt dotazu, jehož include() vrací obyčejné self |
findAll()->include([…])->execute() vrací BaseEntity[] |
@return self<T> u každé fluent metody |
getEntityClass(): class-string |
Generický hydrátor nemůže vrátit entitu manažeru | class-string<TEntity> podél celého řetězce služba → manažer |
Komentář /** @var … */ mezi dvěma atributy |
Vlastnost si nechává holý nativní typ (Collection bez typů prvků); PHP komentář čte, PHPStan ne |
Přesuňte komentář nad první atribut. Parser komentář připojí k prvnímu tokenu deklarace, což je atribut. Našli jsme jich 43 prohledáním tokenizerem |
Pomocník odpovědi deklarovaný jako : mixed |
Každý volající ho „pro jistotu“ obalí is_array() |
Deklarujte skutečný tvar pole; pojistky se změní na „always true“ a zmizí |
S tím přišly dvě provozní lekce. Za prvé: po změně generik u základní třídy může cache výsledků PHPStan dál hlásit starý stav; než nečekanému výsledku uvěříte, vyčistěte ji. Za druhé: PHPStan 2.2 vypisuje vlastní radu na stderr: nepřepisujte odvozený typ inline @var ani assert(). U mixed z Doctrine (části dotazů, skalární výsledky) ho zužte za běhu pomocí is_array() nebo instanceof.
Ještě jedna past hromadných úprav: skript, který přidá /** @return $this */ nad každý setter, najde i settery uvnitř zakomentovaného bloku /* … */. Vložené */ komentář předčasně uzavře a rozbije soubor. Přeskakujte řádky, které tokenizer hlásí jako komentáře.
Dvě pasti frameworku nás stály po jedné regresi. V Nette čte typ parametru action*() také generátor odkazů: netypovaný nebo nullovatelný parametr je volitelný, kdežto int $order bez výchozí hodnoty způsobí, že každý odkaz na tu akci selže s „Missing parameter“. Parametry akcí typujte jako nullovatelné. A $form['x'] vrací obecné rozhraní komponenty, takže metody jako setRequired() nebo addConditionOn() jsou „undefined“. Inline @var nad tím nic nezúží; pomůže malý typovaný přístupový bod na vaší třídě formuláře ($form->typedComponent('x', BaseControl::class), který při neshodě vyhodí výjimku).
Ještě jedna disciplína: po hromadném odstranění „nepoužívaných“ vlastností znovu spusťte analýzu bez baseline a hledejte property.notFound. Náš vlastní úklid odstranil závislosti konstruktoru, které továrna pořád četla, a crawler si toho nevšiml, protože dotčená stránka byla za formulářem s heslem.
Ne každá chyba ukazuje na kód. Nette volá obsluhu formuláře s hodnotami v typu, který obsluha deklaruje: array, ArrayHash nebo třída DTO. Stub phpstan-nette pořád říká callable(static, mixed), takže se odmítla každá správně otypovaná obsluha. Druhý stub pro stejnou třídu není povolen, protože validace stubů ohlásí, že je deklarován dvakrát. Vlastnost jsme znovu deklarovali na vlastní podtřídě Form aplikace jako callable(static, never). Zbyl tak jeden poctivý nesoulad s vendorským stubem místo asi padesáti falešných chyb.
Pozdní lekce z posledního tisíce
- Některé typy je lepší nechat implicitní, dokud neexistuje přístupový bod. Zápis
@return array<int, array<string, mixed>>u getterů nastavení a konfigurace změnil asi 140 nekontrolovaných použití jakoall()[0]['uploadDir']['abs']na explicitní chybymixed. Poctivá oprava je typovaný přístupový bod, ne docblock:Config::path(1, 'uploadDir', 'abs')vrací řetězec (prázdný, když chybí) aConfig::value(1, 'mail')vrací surovou hodnotu ke kontrole na straně volajícího. Po přepsání jedenácti řetězců offsetů šlo gettery otypovat bez jediné nové chyby; hodnoty čtené přes getter nastavení procházejíDbValue::int()místo(int). - Parametry předávané odkazem přepisují typ volajícího. Pomocník
prepareCategoryFilter(array &$filters)dokumentovaný jakoarray<array-key, mixed>po volání změnil pečlivě otypovanéarray<int, Filter>volajícího naarray<mixed>. Parametr předávaný odkazem otypujte stejně přesně jako volající, nebo použijte@param-out. - Hranice šablony nemůže ukazovat na šablonu deklarovanou až za ní.
@template TService of ServiceInterface<TEntity>nad@template TEntityvyrobilo 309 chyb „not subtype“, protože hranice pozdější šablonu nevyhodnotí. Skutečná oprava znamená přeřazení šablon a každého@extends; nechali jsme ji na samostatný krok. - Stuby potřebují rodiče, které jdou autoloadem. Stub, který opravoval jeden chybný
@return objectv platebním SDK, selhal při validaci stubů („extends unknown class“), protože SDK se načítá jen přes classmap. Jednodušší opravou byla kontrolainstanceofv našem místě volání. - Typovaný přístupový bod funguje i pro komponenty. Stejné
typedComponent()použité pro prvky formuláře, přidané do sdíleného traitu komponent, nahradilo každé inline@varnad$this['x']v presenterech a komponentách. Jeden z těch komentářů uváděl třídu, která neexistovala. - Testy nad zdrojovým textem odolávají „neškodným“ úpravám. Test ověřoval přesný text volání, takže přidání
?? ''v místě volání ho rozbilo. Stejnou chybu opravilo přijetí?stringve volaném pomocníkovi, aniž se sáhlo na volání.
Skutečné chyby nalezené cestou
Práce s typy není kosmetická. Mezi vadami opravenými při odstraňování víc než 10 000 záznamů z baseline:
- Výběr sazby DPH, který se vždy vykreslil prázdný.
- Chybějící šablona, která způsobila HTTP 503 místo náhradního řešení.
- Obálka
findAllBy(), která vracela nanejvýš jednu entitu. - Aritmetika stránkování rozbitá prioritou operátorů.
- Mazání stromu, které mohlo smazat uzly dřív, než ověřilo data podstromu.
- Číselné URL kategorií vracející HTTP 500 místo vyřešení nebo odpovědi 404.
- Stránky soft 404 odpovídající stavem 200.
- Aktivační e-mail, který se mohl odeslat ve špatném jazyce.
- Chybová zpráva při přihlášení sestavená z kódu výsledku
null. - Stránka detailu inzerátu, která volala metodu, jež na jejím objektu metadat neexistovala. Nikdy nespadla jen proto, že obrázek, na kterém závisela, byl vždy prázdný, takže se obrázky Open Graph tiše nikdy negenerovaly. Přepnutí nullovatelné vlastnosti na nenullovatelnou odhalilo oba problémy.
- Stránky detailu článku, které nikdy nenačetly hlavní obrázek, takže neměly ani obrázek Open Graph. Druhá entita měla vyměněné generické argumenty (uživatel a obrázek), stejně jako entita stránky výše.
- Přehled kategorií, který zdědil trait pro detail inzerátu, a s ním signál a dva formuláře, které nikdy nepotřeboval.
- Editační formulář inzerátu, který vždy načítal výchozí sadu parametrů. Kód procházel relace kategorií inzerátu a ponechával položky, které byly
instanceof Category, což relace nikdy není, takže seznam kategorií byl vždy prázdný. - Stránka detailu firmy, která deduplikovala objekty kategorií pomocí
array_unique(), jež převádí hodnoty na řetězce a u objektů vyhodí chybu. Přežívala jen proto, že kategorie ještě nebyly načtené. - Počítadlo příspěvků v diskuzi, které volalo metodu na vláknu komentářů, které může být
null. - Služba, která hlásila špatnou třídu entity (kopie ze sourozenecké služby), takže zneplatňování cache a kontroly oprávnění mířily na jinou tabulku.
- Dvě metody kolekce
clear…(), které volalyclear()na neexistující vlastnosti. - Kontrola v query builderu, která porovnávala řetězec aliasu s objekty
Select, nikdy nesouhlasila a přidala kořenový alias doSELECTdvakrát. - Cesta sestavená jako
$root . $row['path'] ?? '': spojení řetězců váže jako první, takže chybějící klíč vyvolal varování místo náhradní hodnoty. - Pole z query stringu (
?curr[]=x,?date[]=x) se dostalo do vlastnosti typu řetězec nebo donew DateTime()a každou stránku proměnilo v HTTP 500. Hodnoty čtené z URL jsou v PHPStanmixedz dobrého důvodu. Result::fromArray([])pro odpověď API, která není JSON, spadlo naResultCode::fromValue(null).- Celý mechanismus dekorátorů za běhu, který při každém požadavku procházel vlastnosti presenteru a nic neměnil: každý setter, který hledal, byl dávno zakomentovaný. Jeden dekorátor dokonce kontroloval
setCurrencya pak volalsetCurr. - Požadavek na API s neznámým jazykem spadl na
TypeError: načítač byl deklarován, že vrací lokalizaci, zatímco vyhledávání mohlo vrátitnull, což volající už ošetřoval. - Komponenta seznamu komentářů deklarovala návratový typ s jmenným prostorem, který neexistoval, takže vytvoření jejího formuláře mohlo skončit jen
TypeError. - Dvě entity používaly trait „identifiable“, aniž by měly vlastnost
$id; jakékoli volánígetId()by četlo nedefinovanou vlastnost. - Starší serializovaný formát variant obrázků obcházel normalizér, kterým procházel formát JSON, takže staré varianty vracely netypované hodnoty.
- Aktivační e-mail newsletteru bez adresy a oznámení o čekající publikaci pro komponentu bez překladové domény skončily oba na
TypeErrormísto ošetřeného selhání.
Kontrolní seznam
- Západka (ratchet) v pre-commit hooku: počet v baseline nesmí růst.
- Po každém pročištění nebo novém generování plný běh PHPStan vypíše
[OK] No errors. mixedz úložiště prochází přesDbValue, ne přes přetypování.- Identifikátory pocházejí z rozhraní;
?intse ošetřuje tam, kde se objeví, s výslovně uvedeným rozhodnutím. - Kontextové objekty ukládají skutečné typy a nabízejí výslovnou dvojici
get…Id()arequire…Id(). - Hlášení „always true“ a „always false“ se berou jako mrtvý kód ke smazání.
- Sdílené základní třídy se stávají generickými s výchozími hodnotami šablon a variancí v místě volání, jeden modul po druhém; hranice šablon pojmenovávají rozhraní, které kód skutečně volá.
- Skripty pro hromadné úpravy ověřují počty shod; každá dávka končí
php -l, PHPStan a unit testy. - Testovací stuby používají skutečné entity nebo implementují rozhraní.
- Každá koncová entita má
@extends, každá sdílená třída má@usepro své generické traity a fluent metody vracejí$thisneboself<T>. - Po změně generik u základní třídy vyčistěte cache výsledků PHPStan, než začnete věřit číslům.