Přechod na novější PHP

DressCode kód nejen formátuje, ale i přepisuje na to, co přináší novější PHP. Zvednete verzi v composer.json, pustíte dresscode fix a v diffu uvidíte kód, jaký byste pro novou verzi napsali sami, rovnou zformátovaný podle vašeho standardu. Umí to pro každou verzi od PHP 8.0 až po PHP 8.6, které teprve vyjde.

Jak to vypadá

Tahle třída je psaná pro PHP 8.0. Projekt teď přechází na PHP 8.6 a používá Nette Coding Standard:

final class ArticleFacade
{
	private ArticleRepository $repository;


	public function __construct(ArticleRepository $repository)
	{
		$this->repository = $repository;
	}


	public function hasPublished(array $articles): bool
	{
		foreach ($articles as $article) {
			if ($article->isPublished()) {
				return true;
			}
		}
		return false;
	}


	public function getAuthorName(?Article $article): ?string
	{
		return $article === null ? null : $article->getAuthor();
	}


	public function isExternal(string $url): bool
	{
		return strpos($url, '://') !== false;
	}


	public function createSlug(string $title): string
	{
		return trim(strtolower(strip_tags($title)));
	}


	public function getRating(int $votes): int
	{
		return max(0, min(5, $votes));
	}
}

Po jednom dresscode fix:

final class ArticleFacade
{
	public function __construct(
		private ArticleRepository $repository,
	) {
	}


	public function hasPublished(array $articles): bool
	{
		return array_any($articles, fn($article) => $article->isPublished());
	}


	public function getAuthorName(?Article $article): ?string
	{
		return $article?->getAuthor();
	}


	public function isExternal(string $url): bool
	{
		return str_contains($url, '://');
	}


	public function createSlug(string $title): string
	{
		return $title |> strip_tags(...) |> strtolower(...) |> trim(...);
	}


	public function getRating(int $votes): int
	{
		return clamp($votes, 0, 5);
	}
}

Deset nálezů, jeden běh, jeden diff. Šest z nich je nová syntaxe nebo nová funkce PHP: vlastnost deklarovaná přímo v konstruktoru (promoted property), operátor ?-> a funkce str_contains() z PHP 8.0, array_any() z PHP 8.4, pipe operátor |> z PHP 8.5 a clamp() z PHP 8.6. Zbylé čtyři jsou formátování, které si nový konstruktor vyžádal: zmizely prázdné řádky po odstraněné vlastnosti a parametr dostal vlastní řádek, čárku za sebou a složenou závorku hned za kulatou, jak to Nette Coding Standard chce.

Přepis a formátování v jednom

Pravidlo, které z vnořených volání dělá pipe, neřeší odsazení, mezery ani to, kam patří závorka. To obstarají pravidla vašeho standardu, a to v tomtéž běhu: DressCode pouští pravidla opakovaně, dokud se kód nepřestane měnit, takže nový kód dostane přesně tu podobu, jakou by mu standard dal, kdybyste ho napsali ručně. A protože pracuje nad bezztrátovým stromem, řádky, na které žádné pravidlo nesáhlo, zůstanou bajt po bajtu stejné. V projektu, který DressCode už používá, tak diff obsahuje přechod na novou verzi PHP a nic jiného.

Pro vás to znamená jeden nástroj, jednu konfiguraci a jeden krok v průběžné integraci. Tatáž konfigurace, která při každém commitu hlídá styl, převede kód na novou verzi PHP v den, kdy ji zvednete.

Verze z composer.json rozhoduje

Pro jakou verzi PHP je kód psaný, čte DressCode z require.php v composer.json: bere nejnižší verzi, kterou omezení dovoluje. Jinou verzi určí jen klíč php v konfiguraci. Každé pravidlo, které zapisuje syntaxi nebo funkci novějšího PHP, ví, od které verze ji jazyk má, a pod ní se samo vynechá. Nemusíte tedy nic hlídat: dokud projekt dovoluje PHP 8.3, array_any() se do kódu nedostane, a v den, kdy omezení zvednete na ^8.4, začne platit. Proč které pravidlo neběží, řekne dresscode config:

Not running
  dresscode/array-first-for-edge-element  it needs PHP 8.5 and the target is 8.3
  dresscode/array-function-for-foreach    it needs PHP 8.4 and the target is 8.3

Pravidla pro přechod na novější PHP tvoří skupinu pravidel modernization. Standard dresscode/nette ji nese, k ostatním standardům ji přidáte:

presets:
	- dresscode/per

groups:
	- modernization

Co všechno se přepíše

Přehled podle verze, ve které PHP danou věc přineslo:

PHP co se přepíše
8.0 strpos() !== false na str_contains(), porovnání se substr() na str_starts_with() a str_ends_with(), jednoduchý switch na match, vlastnost přiřazená v konstruktoru na vlastnost deklarovanou v konstruktoru, ternární operátor s null na ?->, rozhraní Stringable u třídy s metodou __toString(), trojice is_object(), get_class() a gettype() na get_debug_type()
8.1 callable zapsané jako $this->save(...), ruční test, jestli je pole seznamem, na array_is_list(), osmičkové číslo jako 0o755
8.3 json_validate() místo json_decode() volaného jen kvůli testu, Foo::{$name} místo funkce constant()
8.4 smyčka, která jen hledá nebo testuje položky, na array_any(), array_all(), array_find() a array_find_key(), výčet RoundingMode ve funkci round()
8.5 vnořená volání na pipe operátor, čtení prvního a posledního prvku pole na array_first()array_last()
8.6 dvojice max() a min() na clamp()

Tohle všechno zapne skupina modernization. Zkrácený zápis new Foo()->bar() z PHP 8.4, tedy bez závorek kolem new, přidá skupina cleanup. Atribut #[\Override] z PHP 8.3 a návratový typ never z PHP 8.1 přidá skupina types; #[\Override] potřebuje vědět, co třída dědí, a proto typy z PHPStanu.

Několik pravidel rozhoduje o návrhu vašeho kódu, a proto je zapínáte jménem, až se pro ně rozhodnete: readonly u vlastnosti, kterou zapisuje jen konstruktor (PHP 8.1), klíčové slovo readonly místo anotace @readonly (8.1), readonly u celé třídy (8.2) a atribut #[\Deprecated] místo anotace @deprecated (8.4). Poslední z nich mění chování programu ze své podstaty: zastaralá metoda začne za běhu hlásit, že je zastaralá.

rules:
	dresscode/readonly-for-unwritten-property: true
	dresscode/readonly-class-for-readonly-members: true

Rizikové opravy

Některé přepisy můžou změnit, co kód dělá, a DressCode je proto sám od sebe neudělá. Typickým příkladem je switch na match: switch porovnává volně a match přísně, takže case 1 dnes zachytí i řetězec '1', kdežto větev 1 => v match už ne. Taková riziková oprava (risky fix) se jen ohlásí a udělá se, až pravidlo uvedete v klíči fixRisky, nebo na jeden běh s přepínačem --fix-risky:

fixRisky:
	- dresscode/match-for-simple-switch

Rizikový bývá i obyčejný přepis na str_contains(), když kód leží ve jmenném prostoru. Nekvalifikované strpos() v prostoru App může být za běhu funkce App\strpos() a z jednoho souboru se to poznat nedá. Když konfigurace řekne nameResolution: certain, tahle nejistota zmizí a s ní i riziko, viz Funkce a konstanty ve jmenných prostorech.

Jedno DressCode neudělá nikdy, ani s --fix-risky: nenapíše kód, který nepůjde přeložit. Riziková oprava smí změnit chování, ale o tom, jestli se kód přeloží, nesmí rozhodovat soubor, který DressCode zrovna nevidí. Proto readonly dostane jen privátní vlastnost, nebo vlastnost finální třídy: potomek v jiném souboru by jinak mohl vlastnost předeklarovat a to je v PHP fatální chyba, ne změna chování.

PHP 8.6 dřív, než vyjde

PHP 8.6 má vyjít 19. listopadu 2026 a DressCode už dnes přepisuje na jeho funkci clamp(). Syntaxi nové verze přitom rozumí, i když sám běží na starší: parser PhpSyntax čte zdrojový kód tokenizérem PHP, tokeny, které běžící interpret ještě nezná, si doplní sám a stavbu kódu skládá vlastní gramatikou. DressCode spuštěný na PHP 8.4 tak přečte soubor s pipe operátorem z PHP 8.5, který samo PHP 8.4 odmítne jako syntaktickou chybu.

U nástrojů, jejichž pravidla pracují přímo s plochým polem tokenů, to jde těžší cestou, a nejde o chybu jejich autorů. Každé z desítek pravidel si novou syntaxi musí ošetřit samo, a dokud to neudělá, může nový kód poškodit. PHP CS Fixer se proto na PHP 8.4 odmítal spustit až do července 2025, 227 dní po jeho vydání; pustit šel jen s proměnnou prostředí PHP_CS_FIXER_IGNORE_ENV a s vlastním varováním, že kód může změnit špatně. Z té doby jsou v jeho issue trackeru hlášení, kdy jednotlivé fixery přepsaly property hooks na kód, který nešel přeložit. PHP_CodeSniffer property hooks nepodporuje ani dnes, bezmála dva roky po vydání PHP 8.4.

Léta jsem při každé nové verzi PHP čekal, až ji nástroje na styl doženou, a novou syntaxi do kódu nepsal, dokud to nešlo. S DressCode je to obráceně: nástroj je na novou verzi připravený dřív než projekt.

Jak na přechod v praxi

  1. Začněte s čistým pracovním stromem, ať v diffu vidíte jen to, co udělal DressCode.
  2. Zvedněte require.php v composer.json na verzi, na kterou přecházíte.
  3. Pusťte dresscode fix. Co se opravit nedalo nebo čeká na svolení, zůstane ve výpisu a shrnutí jmenuje pravidla, jejichž rizikové opravy čekají.
  4. Rizikové nálezy projděte a rozhodněte, která pravidla uvedete v fixRisky. Nebo je pusťte jednou s --fix-risky a diff zkontrolujte sami.
  5. Výsledek commitněte samostatně, bez jiných změn.

Nemusíte přitom skákat rovnou na nejnovější verzi. Když projekt přechází po jedné verzi, udělejte pro každou vlastní commit; pravidla se řídí tím, co zrovna povoluje composer.json.

Kam dál

  • Typy z PHPStanu, aby DressCode doplnil #[\Override], přepsal zastaralé API a poznal, že řetězec je jméno třídy.
  • Aktualizace knihoven, když s novou verzí PHP přechází i Nette nebo jiná knihovna.
  • Přehled pravidel se všemi pravidly skupiny modernization.