Im Idealfall sollten wir PHP 8.0 (die neueste Version zum Zeitpunkt der Erstellung dieses Artikels) für all unsere Webseiten verwenden und es aktualisieren, sobald eine neue Version veröffentlicht wird. Allerdings müssen Entwickler oft mit früheren PHP-Versionen arbeiten, z. B. wenn sie ein öffentliches Plugin für WordPress erstellen oder mit veraltetem Code arbeiten, der eine Aktualisierung der Webserver-Umgebung verhindert.

In diesen Situationen könnten wir die Hoffnung aufgeben, den neuesten PHP-Code zu verwenden. Aber es gibt eine bessere Alternative: Wir können unseren Quellcode immer noch mit PHP 8.0 schreiben und ihn in eine frühere PHP-Version transpilieren – sogar in PHP 7.1.

In diesem Leitfaden erfährst du alles, was du über das Transpilieren von PHP-Code wissen musst.

Was ist Transpilieren?

Beim Transpilieren wird der Quellcode einer Programmiersprache in einen gleichwertigen Quellcode der gleichen oder einer anderen Programmiersprache umgewandelt.

Transpilieren ist kein neues Konzept in der Webentwicklung: Client-seitige Entwickler/innen werden wahrscheinlich mit Babel, einem Transpiler für JavaScript-Code, vertraut sein.

Babel wandelt JavaScript-Code von der modernen ECMAScript 2015+ Version in eine Legacy-Version um, die mit älteren Browsern kompatibel ist. Nehmen wir zum Beispiel eine ES2015-Pfeilfunktion:

[2, 4, 6].map((n) => n * 2);

…wandelt Babel es in seine ES5-Version um:

[2, 4, 6].map(function(n) {
  return n * 2;
});

Was ist Transpiling PHP?

Eine potenzielle Neuheit in der Webentwicklung ist die Möglichkeit, serverseitigen Code, insbesondere PHP, zu transpilieren.

Das Transpilieren von PHP funktioniert auf die gleiche Weise wie das Transpilieren von JavaScript: Der Quellcode einer modernen PHP-Version wird in einen entsprechenden Code für eine ältere PHP-Version umgewandelt.

Nach dem gleichen Beispiel wie zuvor, eine Pfeilfunktion aus PHP 7.4:

$nums = array_map(fn($n) => $n * 2, [2, 4, 6]);

…kann in die entsprechende PHP 7.3 Version transpiliert werden:

$nums = array_map(
  function ($n) {
    return $n * 2;
  },
  [2, 4, 6]
);

Pfeilfunktionen können transpiliert werden, weil sie syntaktischer Zucker sind, d.h. eine neue Syntax, um ein bestehendes Verhalten zu erzeugen. Das ist die niedrig hängende Frucht.

Es gibt jedoch auch neue Funktionen, die ein neues Verhalten erzeugen, und als solche gibt es keinen entsprechenden Code für frühere PHP-Versionen. Das ist der Fall bei den Union-Typen, die in PHP 8.0 eingeführt wurden:

function someFunction(float|int $param): string|float|int|null
{
  // ...
}

In diesen Situationen kann die Transpilierung trotzdem durchgeführt werden, solange das neue Feature für die Entwicklung, aber nicht für die Produktion benötigt wird. Dann können wir das Feature einfach ganz aus dem transpilierten Code entfernen, ohne dass es ernsthafte Konsequenzen hat.

Ein solches Beispiel sind Union-Typen. Diese Funktion wird verwendet, um zu prüfen, dass es keine Unstimmigkeiten zwischen dem Eingabetyp und dem bereitgestellten Wert gibt, was hilft, Fehler zu vermeiden. Wenn es einen Konflikt mit den Typen gibt, entsteht bereits in der Entwicklung ein Fehler, und wir sollten ihn abfangen und beheben, bevor der Code in die Produktion gelangt.

Daher können wir es uns leisten, die Funktion aus dem Code für die Produktion zu entfernen:

function someFunction($param)
{
  // ...
}

Wenn der Fehler in der Produktion trotzdem auftritt, wird die Fehlermeldung weniger präzise sein als bei Union-Typen. Dieser potenzielle Nachteil wird jedoch dadurch aufgewogen, dass wir überhaupt Union-Typen verwenden können.

Vorteile der Transpilierung von PHP-Code

Mit Transpiling kann man eine Anwendung mit der neuesten PHP-Version programmieren und eine Version erstellen, die auch in Umgebungen mit älteren PHP-Versionen funktioniert.

Das kann besonders für Entwickler/innen nützlich sein, die Produkte für ältere Content-Management-Systeme (CMS) entwickeln. WordPress zum Beispiel unterstützt offiziell immer noch PHP 5.6 (auch wenn es PHP 7.4+ empfiehlt). Der Prozentsatz der WordPress Seiten, die mit den PHP-Versionen 5.6 bis 7.2 laufen – die alle End-of-Life (EOL) sind, d.h. sie erhalten keine Sicherheitsupdates mehr – liegt bei beachtlichen 34,8 % und der Anteil der Seiten, die mit einer anderen PHP-Version als 8.0 laufen, bei satten 99,5 %:

WordPress-Nutzungsstatistiken nach Version
WordPress-Nutzungsstatistiken nach Version. Bildquelle: WordPress

Daher werden WordPress-Themes und Plugins, die sich an ein globales Publikum richten, höchstwahrscheinlich mit einer alten PHP-Version programmiert, um ihre mögliche Reichweite zu erhöhen. Dank der Transpilierung können sie mit PHP 8.0 programmiert und trotzdem für eine ältere PHP-Version veröffentlicht werden, um so viele Nutzer/innen wie möglich anzusprechen.

Jede Anwendung, die eine andere als die neueste PHP-Version unterstützen muss (selbst innerhalb der aktuell unterstützten PHP-Versionen), kann davon profitieren.

Dies ist bei Drupal der Fall, das PHP 7.3 benötigt. Dank der Transpilierung können Entwickler öffentlich verfügbare Drupal-Module mit PHP 8.0 erstellen und sie mit PHP 7.3 veröffentlichen.

Ein weiteres Beispiel ist die Erstellung von benutzerdefiniertem Code für Kunden, die aus dem einen oder anderen Grund kein PHP 8.0 in ihrer Umgebung einsetzen können. Dank der Transpilierung können die Entwickler/innen ihre Produkte trotzdem mit PHP 8.0 programmieren und sie in diesen Legacy-Umgebungen einsetzen.

transpilieren

PHP-Code kann immer transpiliert werden, es sei denn, er enthält eine PHP-Funktion, für die es in der vorherigen PHP-Version keine Entsprechung gibt.

Das ist zum Beispiel bei den Attributen der Fall, die in PHP 8.0 eingeführt wurden:

#[SomeAttr]
function someFunc() {}

#[AnotherAttr]
class SomeClass {}

Im vorherigen Beispiel mit den Pfeilfunktionen konnte der Code transpiliert werden, weil Pfeilfunktionen syntaktischer Zucker sind. Attribute hingegen schaffen ein völlig neues Verhalten. Dieses Verhalten könnte auch mit PHP 7.4 und niedriger reproduziert werden, aber nur durch manuelle Programmierung, d.h. nicht automatisch mit Hilfe eines Tools oder Prozesses (KI könnte eine Lösung bieten, aber so weit sind wir noch nicht).

Attribute, die für die Entwicklung bestimmt sind, wie z. B. #[Deprecated], können auf dieselbe Weise entfernt werden wie Union-Typen. Aber Attribute, die das Verhalten der Anwendung in der Produktion verändern, können nicht entfernt werden, und sie können auch nicht direkt transpiliert werden.

Bis heute kann kein Transpiler Code mit PHP 8.0-Attributen automatisch in entsprechenden PHP 7.4-Code umwandeln. Wenn dein PHP-Code also Attribute verwenden muss, wird es schwierig oder unmöglich sein, ihn zu transpilieren.

PHP-Funktionen, die transpiliert werden können

Dies sind die Funktionen von PHP 7.1 und höher, die derzeit transpiliert werden können. Wenn dein Code nur diese Funktionen verwendet, kannst du sicher sein, dass deine transpilierte Anwendung funktionieren wird. Andernfalls musst du prüfen, ob der transpilierte Code zu Fehlern führen wird.

PHP Version Features
7.1 Alles
7.2 object-Typ
Parameter-Typ-Erweiterung
PREG_UNMATCHED_AS_NULL Flag in preg_match
7.3 Referenzzuweisungen in list() / Array-Destrukturierung (außer innerhalb von foreach — #4376)
Flexible Heredoc- und Nowdoc-Syntax
Nachgestellte Kommas in Funktionsaufrufen
set(raw)cookie akzeptiert das Argument $option
7.4 Typisierte Eigenschaften
Pfeilfunktionen
Null verschmelzende Zuweisungsoperatoren
Entpacken innerhalb von Arrays
Numerisches Literal-Trennzeichen
strip_tags() mit Array von Tag-Namen
kovariante Rückgabetypen und kontravariante Parametertypen
8.0 Union-Typen
mixed Pseudo-Typ
static Rückgabetyp
::class magische Konstante für Objekte
match Ausdrücke
catch Ausnahmen nur nach Typ
Null-sicherer Operator
Beförderung von Klassenkonstruktoreigenschaften
Anhängende Kommas in Parameterlisten und use Listen für Closures

PHP-Transpilierer

Derzeit gibt es nur ein Tool zum Transpilieren von PHP-Code: Rector.

Rector ist ein Tool zur PHP-Rekonstruktion, das PHP-Code anhand von programmierbaren Regeln umwandelt. Wir geben den Quellcode und die Regeln ein, die ausgeführt werden sollen, und Rector wandelt den Code um.

Rector wird über die Kommandozeile bedient und über Composer in das Projekt installiert. Nach der Ausführung gibt Rector einen „Diff“ (Hinzufügungen in grün, Entfernungen in rot) des Codes vor und nach der Conversion aus:

"diff" Ausgabe von Rector
„diff“ Ausgabe von Rector

Welche Version von PHP soll transpiliert werden?

Um Code zwischen verschiedenen PHP-Versionen zu transpilieren, müssen die entsprechenden Regeln erstellt werden.

Heute enthält die Rector-Bibliothek die meisten Regeln für das Transpilieren von Code im Bereich von PHP 8.0 bis 7.1. So können wir unseren PHP-Code zuverlässig bis zur Version 7.1 transpilieren.

Es gibt auch Regeln für die Transpilierung von PHP 7.1 nach 7.0 und von 7.0 nach 5.6, aber diese sind nicht vollständig. Wir arbeiten daran, sie zu vervollständigen, so dass wir PHP-Code eventuell bis zur Version 5.6 transpilieren können.

Transpilieren vs. Backporting

Das Backporting ist ähnlich wie das Transpilieren, aber einfacher. Bei der Rückportierung von Code werden nicht unbedingt neue Funktionen einer Sprache verwendet. Stattdessen kann dieselbe Funktion in einer älteren Version der Sprache bereitgestellt werden, indem der entsprechende Code aus der neuen Version der Sprache einfach kopiert/eingepasst/angepasst wird.

Zum Beispiel wurde die Funktion str_contains in PHP 8.0 eingeführt. Die gleiche Funktion für PHP 7.4 und darunter kann einfach wie folgt implementiert werden:

if (!defined('PHP_VERSION_ID') || (defined('PHP_VERSION_ID') && PHP_VERSION_ID < 80000)) {
  if (!function_exists('str_contains')) {
    /**
     * Checks if a string contains another
     *
     * @param string $haystack The string to search in
     * @param string $needle The string to search
     * @return boolean Returns TRUE if the needle was found in haystack, FALSE otherwise.
     */
    function str_contains(string $haystack, string $needle): bool
    {
      return strpos($haystack, $needle) !== false;
    }
  }
}

Da die Rückportierung einfacher ist als die Transpilierung, sollten wir uns für diese Lösung entscheiden, wenn die Rückportierung die Aufgabe erfüllt.

Was den Bereich zwischen PHP 8.0 und 7.1 angeht, können wir die Polyfill-Bibliotheken von Symfony verwenden:

Diese Bibliotheken backportieren die folgenden Funktionen, Klassen, Konstanten und Schnittstellen:

PHP Version Features
7.2 Funktionen:

Konstanten:

7.3 Funktionen:

Ausnahmen:

7.4 Funktionen:
8.0 Schnittstellen:
  • Stringable

Klasse:

  • ValueError
  • UnhandledMatchError

Konstanten:

  • FILTER_VALIDATE_BOOL

Funktionen:

Beispiele für transpiliertes PHP

Sehen wir uns einige Beispiele für transpilierten PHP-Code und einige Pakete an, die vollständig transpiliert wurden.

PHP-Code

Der match-Ausdruck wurde in PHP 8.0 eingeführt. Dieser Quellcode:

function getFieldValue(string $fieldName): ?string
{
  return match($fieldName) {
    'foo' => 'foofoo',
    'bar' => 'barbar',
    'baz' => 'bazbaz',
    default => null,
  };
}

…wird mit Hilfe des switch-Operators in die entsprechende PHP 7.4-Version transpiliert:

function getFieldValue(string $fieldName): ?string
{
  switch ($fieldName) {
    case 'foo':
      return 'foofoo';
    case 'bar':
      return 'barbar';
    case 'baz':
      return 'bazbaz';
    default:
      return null;
  }
}

Der nullsafe-Operator wurde ebenfalls in PHP 8.0 eingeführt:

public function getValue(TypeResolverInterface $typeResolver): ?string
{
  return $this->getResolver($typeResolver)?->getValue();
}

Der umgesetzte Code muss den Wert der Operation zunächst einer neuen Variablen zuweisen, um zu vermeiden, dass die Operation zweimal ausgeführt wird:

public function getValue(TypeResolverInterface $typeResolver): ?string
{
  return ($val = $this->getResolver($typeResolver)) ? $val->getValue() : null;
}

Die Funktion zur Förderung von Konstruktoreigenschaften, die ebenfalls in PHP 8.0 eingeführt wurde, ermöglicht es Entwicklern, weniger Code zu schreiben:

class QueryResolver
{
  function __construct(protected QueryFormatter $queryFormatter)
  {
  }
}

Beim Transpilieren für PHP 7.4 wird der gesamte Code erzeugt:

 class QueryResolver
 {
  protected QueryFormatter $queryFormatter;

  function __construct(QueryFormatter $queryFormatter)
  {
    $this->queryFormatter = $queryFormatter;
  }
}

Der oben transpilierte Code enthält typisierte Eigenschaften, die in PHP 7.4 eingeführt wurden. Durch die Übertragung des Codes auf PHP 7.3 werden sie durch docblocks ersetzt:

 class QueryResolver
 {
  /**
   * @var QueryFormatter
   */
  protected $queryFormatter;

  function __construct(QueryFormatter $queryFormatter)
  {
    $this->queryFormatter = $queryFormatter;
  }
}

PHP-Pakete

Die folgenden Bibliotheken werden für die Produktion umgesetzt:

Library/description Code/notes
Rector
PHP-Rekonstruktionswerkzeug, das die Transpilierung ermöglicht
Quellcode
Transpilierter Code
Anmerkungen
Einfache Kodierungsstandards
Werkzeug, um PHP-Code an eine Reihe von Regeln zu binden
Quellcode
Transpilierter Code
Anmerkungen
GraphQL API für WordPress
Plugin zur Bereitstellung eines GraphQL-Servers für WordPress
Quellcode
Transpilierter Code
Anmerkungen

Vor- und Nachteile der Transpilierung von PHP

Der Vorteil des Transpilierens von PHP wurde bereits beschrieben: Es ermöglicht die Verwendung von PHP 8.0 (d. h. der neuesten Version von PHP) im Quellcode, der dann in eine niedrigere Version von PHP für die Produktion umgewandelt wird, um in einer Legacy-Anwendung oder -Umgebung zu laufen.

Auf diese Weise werden wir zu besseren Entwicklern, die Code mit höherer Qualität produzieren. Das liegt daran, dass unser Quellcode die Union-Typen von PHP 8.0, die typisierten Eigenschaften von PHP 7.4 und die verschiedenen Typen und Pseudo-Typen, die jeder neuen PHP-Version hinzugefügt wurden (mixed von PHP 8.0, object von PHP 7.2), neben anderen modernen Funktionen von PHP nutzen kann.

Mithilfe dieser Funktionen können wir Fehler während der Entwicklung besser erkennen und Code schreiben, der leichter zu lesen ist.

Werfen wir nun einen Blick auf die Nachteile.

Es muss kodiert und gewartet werden

Rector kann Code automatisch transpilieren, aber der Prozess wird wahrscheinlich einige manuelle Eingaben erfordern, damit er mit unserem spezifischen Setup funktioniert.

Bibliotheken von Drittanbietern müssen ebenfalls transpiliert werden

Dies wird immer dann zum Problem, wenn beim Transpilieren Fehler auftreten, da wir dann in den Quellcode eindringen müssen, um die mögliche Ursache herauszufinden. Wenn das Problem behoben werden kann und das Projekt quelloffen ist, müssen wir einen Pull-Request einreichen. Wenn die Bibliothek nicht quelloffen ist, könnten wir auf ein Hindernis stoßen.

Rector informiert uns nicht, wenn der Code nicht transpiliert werden kann

Wenn der Quellcode PHP 8.0-Attribute oder ein anderes Feature enthält, das nicht transpiliert werden kann, können wir nicht fortfahren. Rector prüft diese Bedingung jedoch nicht, so dass wir dies manuell tun müssen. Bei unserem eigenen Quellcode ist das vielleicht kein großes Problem, da wir damit bereits vertraut sind, aber bei Abhängigkeiten von Drittanbietern könnte es zu einem Hindernis werden.

Debugging-Informationen verwenden den umgesetzten Code, nicht den Quellcode

Wenn die Anwendung in der Produktion eine Fehlermeldung mit einem Stack-Trace erzeugt, verweist die Zeilennummer auf den transpilierten Code. Um die entsprechende Zeilennummer im Quellcode zu finden, müssen wir vom transpilierten in den Originalcode zurückkonvertieren.

Der transpilierte Code muss auch vorangestellt werden

Unser transpiliertes Projekt und eine andere Bibliothek, die ebenfalls in der Produktionsumgebung installiert ist, könnten dieselbe Drittanbieter-Abhängigkeit verwenden. Diese Drittanbieter-Abhängigkeit wird für unser Projekt transpiliert, während der ursprüngliche Quellcode für die andere Bibliothek beibehalten wird. Daher muss die transpilierte Version mit PHP-Scoper, Strauss oder einem anderen Tool vorangestellt werden, um mögliche Konflikte zu vermeiden.

Die Transpilierung muss während der kontinuierlichen Integration (CI) erfolgen

Da der transpilierte Code natürlich den Quellcode überschreibt, sollten wir den Transpilierungsprozess nicht auf unseren Entwicklungscomputern ausführen, da sonst die Gefahr von Seiteneffekten besteht. Es ist besser, den Prozess während eines CI-Laufs auszuführen (mehr dazu weiter unten).

Wie man PHP transpiliert

Zunächst müssen wir Rector in unserem Entwicklungsprojekt installieren:

composer require rector/rector --dev

Anschließend erstellen wir eine Konfigurationsdatei rector.php im Stammverzeichnis des Projekts, die die erforderlichen Regelsätze enthält. Um den Code von PHP 8.0 auf 7.1 herunterzustufen, verwenden wir diese config:

use Rector\Set\ValueObject\DowngradeSetList;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->import(DowngradeSetList::PHP_80);
    $containerConfigurator->import(DowngradeSetList::PHP_74);
    $containerConfigurator->import(DowngradeSetList::PHP_73);
    $containerConfigurator->import(DowngradeSetList::PHP_72);
};

Um sicherzustellen, dass der Prozess wie erwartet ausgeführt wird, können wir den process Befehl von Rector im Trockenmodus ausführen und die zu verarbeitende(n) Datei(en) übergeben (in diesem Fall alle Dateien im Ordner src/):

vendor/bin/rector process src --dry-run

Um die Transpilierung durchzuführen, führen wir den process Befehl von Rector aus, der die Dateien an ihrem bisherigen Speicherort verändert:

vendor/bin/rector process src

Bitte beachte: Wenn wir den rector process in unseren Entwicklungscomputern laufen lassen, wird der Quellcode an Ort und Stelle konvertiert, unter src/. Wir wollen jedoch den konvertierten Code an einem anderen Ort erzeugen, um den Quellcode beim Downgrade nicht zu überschreiben. Aus diesem Grund ist es am besten, den Prozess während der kontinuierlichen Integration auszuführen.

Optimieren des Transpilierungsprozesses

Um ein transpiliertes Deliverable für die Produktion zu erstellen, muss nur der Code für die Produktion konvertiert werden; Code, der nur für die Entwicklung benötigt wird, kann übersprungen werden. Das bedeutet, dass wir es vermeiden können, alle Tests (sowohl für unser Projekt als auch für seine Abhängigkeiten) und alle Abhängigkeiten für die Entwicklung zu transpilieren.

Was die Tests betrifft, so wissen wir bereits, wo sich die Tests für unser Projekt befinden – zum Beispiel im Ordner tests/. Wir müssen auch herausfinden, wo sich die Tests für die Abhängigkeiten befinden – zum Beispiel in den Unterordnern tests/, test/ und Test/ (für verschiedene Bibliotheken). Dann weisen wir Rector an, die Bearbeitung dieser Ordner zu überspringen:

return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::SKIP, [
    // Skip tests
    '*/tests/*',
    '*/test/*',
    '*/Test/*',
  ]);
};

Was die Abhängigkeiten angeht, weiß Composer, welche für die Entwicklung (unter dem Eintrag require-dev in composer.json) und welche für die Produktion (unter dem Eintrag require) sind.

Um vom Composer die Pfade aller Abhängigkeiten für die Produktion zu erhalten, führen wir aus:

composer info --path --no-dev

Mit diesem Befehl wird eine Liste der Abhängigkeiten mit Namen und Pfad erstellt, etwa so:

brain/cortex                     /Users/leo/GitHub/leoloso/PoP/vendor/brain/cortex
composer/installers              /Users/leo/GitHub/leoloso/PoP/vendor/composer/installers
composer/semver                  /Users/leo/GitHub/leoloso/PoP/vendor/composer/semver
guzzlehttp/guzzle                /Users/leo/GitHub/leoloso/PoP/vendor/guzzlehttp/guzzle
league/pipeline                  /Users/leo/GitHub/leoloso/PoP/vendor/league/pipeline

Wir können alle Pfade extrahieren und in den Rector-Befehl eingeben, der dann den src/-Ordner unseres Projekts sowie die Ordner mit allen Abhängigkeiten für die Produktion verarbeitet:

$ paths="$(composer info --path --no-dev | cut -d' ' -f2- | sed 's/ //g' | tr '\n' ' ')"
$ vendor/bin/rector process src $paths

Eine weitere Verbesserung kann verhindern, dass Rector die Abhängigkeiten verarbeitet, die bereits die Ziel-PHP-Version verwenden. Wenn eine Bibliothek mit PHP 7.1 (oder einer niedrigeren Version) programmiert wurde, muss sie nicht nach PHP 7.1 transpiliert werden.

Um dies zu erreichen, können wir die Liste der Bibliotheken erhalten, die PHP 7.2 und höher benötigen, und nur diese verarbeiten. Die Namen all dieser Bibliotheken erhalten wir über den why-not-Befehl von Composer, etwa so:

composer why-not php "7.1.*" | grep -o "\S*\/\S*"

Da dieser Befehl nicht mit dem --no-dev Flag funktioniert, müssen wir, um nur die Abhängigkeiten für die Produktion einzubeziehen, zuerst die Abhängigkeiten für die Entwicklung entfernen und den Autoloader neu generieren, den Befehl ausführen und sie dann wieder hinzufügen:

$ composer install --no-dev
$ packages=$(composer why-not php "7.1.*" | grep -o "\S*\/\S*")
$ composer install

Der Befehl info --path von Composer gibt den Pfad eines Pakets in diesem Format aus:

# Executing this command
$ composer info psr/cache --path   
# Produces this response:
psr/cache /Users/leo/GitHub/leoloso/PoP/vendor/psr/cache

Wir führen diesen Befehl für alle Einträge in unserer Liste aus, um alle Pfade zum Umsetzen zu erhalten:

for package in $packages
do
  path=$(composer info $package --path | cut -d' ' -f2-)
  paths="$paths $path"
done

Schließlich stellen wir diese Liste dem Rector zur Verfügung (sowie den Ordner src/ des Projekts):

vendor/bin/rector process src $paths

Zu vermeidende Fallstricke beim Transpilieren von Code

Das Transpilieren von Code ist eine Kunst, die oft projektspezifische Anpassungen erfordert. Sehen wir uns ein paar Probleme an, auf die wir stoßen können.

Verkettete Regeln werden nicht immer abgearbeitet

Von einer verketteten Regel spricht man, wenn eine Regel den Code umwandeln muss, der von einer vorherigen Regel erzeugt wurde.

Zum Beispiel enthält die Bibliothek symfony/cache diesen Code:

final class CacheItem implements ItemInterface
{
  public function tag($tags): ItemInterface
  {
    // ...
    return $this;
  }
}

Beim Transpilieren von PHP 7.4 nach 7.3 müssen zwei Änderungen am Funktions-tag vorgenommen werden:

Das Endergebnis sollte dieses hier sein:

final class CacheItem implements ItemInterface
{
  public function tag($tags)
  {
    // ...
    return $this;
  }
}

Der Rector gibt jedoch nur die Zwischenstufe aus:

final class CacheItem implements ItemInterface
{
  public function tag($tags): self
  {
    // ...
    return $this;
  }
}

Das Problem ist, dass Rector nicht immer kontrollieren kann, in welcher Reihenfolge die Regeln angewendet werden.

Die Lösung besteht darin, herauszufinden, welche verketteten Regeln unbearbeitet geblieben sind, und einen neuen Rector-Lauf auszuführen, um sie anzuwenden.

Um die verketteten Regeln zu identifizieren, führen wir Rector zweimal auf dem Quellcode aus, etwa so:

$ vendor/bin/rector process src
$ vendor/bin/rector process src --dry-run

Beim ersten Mal führen wir Rector wie erwartet aus, um die Transpilierung durchzuführen. Beim zweiten Mal verwenden wir das Flag --dry-run, um festzustellen, ob noch Änderungen vorgenommen werden müssen. Wenn ja, wird der Befehl mit einem Fehlercode beendet und die „diff“-Ausgabe zeigt an, welche Regel(n) noch angewendet werden können. Das würde bedeuten, dass der erste Durchlauf nicht vollständig war und einige verkettete Regeln nicht verarbeitet wurden.

Rector mit dem Flag -dry-run ausführen
Rector mit dem Flag -dry-run ausführen

 

Sobald wir die nicht angewendete(n) verkettete(n) Regel(n) identifiziert haben, können wir eine weitere Rector-Konfigurationsdatei erstellen – zum Beispiel rector-chained-rule.php, die die fehlende Regel ausführt. Anstatt einen ganzen Satz von Regeln für alle Dateien unter src/ auszuführen, können wir die fehlende Regel auf die Datei anwenden, auf die sie angewendet werden soll:

// rector-chained-rule.php
use Rector\Core\Configuration\Option;
use Rector\DowngradePhp74\Rector\ClassMethod\DowngradeSelfTypeDeclarationRector;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $containerConfigurator): void {
  $services = $containerConfigurator->services();
  $services->set(DowngradeSelfTypeDeclarationRector::class);

  $parameters = $containerConfigurator->parameters();
  $parameters->set(Option::PATHS, [
    __DIR__ . '/vendor/symfony/cache/CacheItem.php',
  ]);
};

Schließlich weisen wir Rector im zweiten Durchgang an, die neue Konfigurationsdatei über input --config zu verwenden:

# First pass with all modifications
$ vendor/bin/rector process src

# Second pass to fix a specific problem
$ vendor/bin/rector process --config=rector-chained-rule.php

Composer-Abhängigkeiten können inkonsistent sein

Bibliotheken können eine Abhängigkeit als für die Entwicklung bestimmt deklarieren (z. B. unter require-dev in der composer.json), aber dennoch Code von ihnen für die Produktion referenzieren (z. B. in einigen Dateien unter src/, nicht unter tests/).

Normalerweise ist das kein Problem, denn dieser Code wird in der Produktion nicht geladen, so dass es in der Anwendung nie zu einem Fehler kommt. Wenn Rector jedoch den Quellcode und seine Abhängigkeiten verarbeitet, prüft er, ob der gesamte referenzierte Code geladen werden kann. Rector gibt einen Fehler aus, wenn eine Datei auf einen Teil des Codes einer nicht installierten Bibliothek verweist (weil er nur für die Entwicklung benötigt wird).

Zum Beispiel implementiert die Klasse EarlyExpirationHandler aus der Cache-Komponente von Symfony die Schnittstelle MessageHandlerInterface aus der Messenger-Komponente:

class EarlyExpirationHandler implements MessageHandlerInterface
{
    //...
}

Allerdings deklariert symfony/cache symfony/messenger als eine Abhängigkeit für die Entwicklung. Wenn du Rector auf einem Projekt ausführst, das von symfony/cache abhängt, wird ein Fehler ausgegeben:

[ERROR] Could not process "vendor/symfony/cache/Messenger/EarlyExpirationHandler.php" file, due to:             
  "Analyze error: "Class Symfony\Component\Messenger\Handler\MessageHandlerInterface not found.". Include your files in "$parameters->set(Option::AUTOLOAD_PATHS, [...]);" in "rector.php" config.
  See https://github.com/rectorphp/rector#configuration".   

Es gibt drei Lösungen für dieses Problem:

  1. Überspringe in der Rector-Konfiguration die Verarbeitung der Datei, die auf dieses Stück Code verweist:
return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::SKIP, [
    __DIR__ . '/vendor/symfony/cache/Messenger/EarlyExpirationHandler.php',
  ]);
};
  1. Lade die fehlende Bibliothek herunter und füge ihren Pfad hinzu, damit sie von Rector automatisch geladen wird:
return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::AUTOLOAD_PATHS, [
    __DIR__ . '/vendor/symfony/messenger',
  ]);
};
  1. Lass dein Projekt von der fehlenden Bibliothek für die Produktion abhängen:
composer require symfony/messenger

Transpilieren und kontinuierliche Integration

Wie bereits erwähnt, müssen wir auf unseren Entwicklungscomputern das Flag --dry-run verwenden, wenn wir Rector ausführen, da sonst der Quellcode durch den transpilierten Code überschrieben wird. Aus diesem Grund ist es besser, den eigentlichen Transpilierungsprozess während der kontinuierlichen Integration (CI) auszuführen, bei der wir temporäre Runner zur Ausführung des Prozesses starten können.

Ein idealer Zeitpunkt für die Durchführung des Transpilierens ist die Erstellung des Releases für unser Projekt. Der folgende Code ist zum Beispiel ein Workflow für GitHub Actions, der die Veröffentlichung eines WordPress-Plugins erstellt:

name: Generate Installable Plugin and Upload as Release Asset
on:
  release:
    types: [published]
jobs:
  build:
    name: Build, Downgrade and Upload Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Downgrade code for production (to PHP 7.1)
        run: |
          composer install
          vendor/bin/rector process
          sed -i 's/Requires PHP: 7.4/Requires PHP: 7.1/' graphql-api.php
      - name: Build project for production
        run: |
          composer install --no-dev --optimize-autoloader
          mkdir build
      - name: Create artifact
        uses: montudor/[email protected]
        with:
          args: zip -X -r build/graphql-api.zip . -x *.git* node_modules/\* .* "*/\.*" CODE_OF_CONDUCT.md CONTRIBUTING.md ISSUE_TEMPLATE.md PULL_REQUEST_TEMPLATE.md rector.php *.dist composer.* dev-helpers** build**
      - name: Upload artifact
        uses: actions/upload-artifact@v2
        with:
            name: graphql-api
            path: build/graphql-api.zip
      - name: Upload to release
        uses: JasonEtco/upload-to-release@master
        with:
          args: build/graphql-api.zip application/zip
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Dieser Arbeitsablauf enthält eine Standardprozedur, um ein WordPress-Plugin über GitHub Actions zu veröffentlichen. Der neue Zusatz, den Code des Plugins von PHP 7.4 auf 7.1 zu transpilieren, geschieht in diesem Schritt:

      - name: Downgrade code for production (to PHP 7.1)
        run: |
          vendor/bin/rector process
          sed -i 's/Requires PHP: 7.4/Requires PHP: 7.1/' graphql-api.php

Zusammengenommen führt dieser Arbeitsablauf nun die folgenden Schritte durch:

  1. Checkt den Quellcode für ein WordPress-Plugin aus dem Repository aus, das mit PHP 7.4 geschrieben wurde
  2. Installiert die Composer-Abhängigkeiten
  3. Transponiert den Code von PHP 7.4 auf 7.1
  4. Ändert den Eintrag „Requires PHP“ im Header der Hauptdatei des Plugins von "7.4" auf "7.1".
  5. Entfernt die für die Entwicklung benötigten Abhängigkeiten
  6. Erstellt die .zip-Datei des Plugins und schließt dabei alle nicht benötigten Dateien aus
  7. Lädt die .zip-Datei als Release-Asset hoch (und zusätzlich als Artefakt in die GitHub-Aktion)

Testen des transponierten Codes

Wenn der Code nach PHP 7.1 transpiliert wurde, woher wissen wir dann, dass er gut funktioniert? Oder anders gesagt, woher wissen wir, dass er gründlich umgewandelt wurde und keine Reste von höheren PHP-Versionen zurückgeblieben sind?

Ähnlich wie beim Transpilieren des Codes können wir die Lösung innerhalb eines CI-Prozesses implementieren. Die Idee ist, die Umgebung des Runners mit PHP 7.1 einzurichten und einen Linter auf den transpilierten Code anzuwenden. Wenn ein Teil des Codes nicht mit PHP 7.1 kompatibel ist (z. B. eine typisierte Eigenschaft aus PHP 7.4, die nicht umgewandelt wurde), gibt der Linter einen Fehler aus.

Ein Linter für PHP, der gut funktioniert, ist PHP Parallel Lint. Wir können diese Bibliothek als Abhängigkeit für die Entwicklung in unserem Projekt installieren oder sie durch den CI-Prozess als eigenständiges Composer-Projekt installieren lassen:

composer create-project php-parallel-lint/php-parallel-lint

Wenn der Code PHP 7.2 und höher enthält, wird PHP Parallel Lint einen Fehler wie diesen ausgeben:

Run php-parallel-lint/parallel-lint layers/ vendor/ --exclude vendor/symfony/polyfill-ctype/bootstrap80.php --exclude vendor/symfony/polyfill-intl-grapheme/bootstrap80.php --exclude vendor/symfony/polyfill-intl-idn/bootstrap80.php --exclude vendor/symfony/polyfill-intl-normalizer/bootstrap80.php --exclude vendor/symfony/polyfill-mbstring/bootstrap80.php
PHP 7.1.33 | 10 parallel jobs
............................................................   60/2870 (2 %)
............................................................  120/2870 (4 %)
...
............................................................  660/2870 (22 %)
.............X..............................................  720/2870 (25 %)
............................................................  780/2870 (27 %)
...
............................................................ 2820/2870 (98 %)
..................................................           2870/2870 (100 %)


Checked 2870 files in 15.4 seconds
Syntax error found in 1 file

------------------------------------------------------------
Parse error: layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/graphql-api.php:55
    53|     '0.8.0',
    54|     \__('GraphQL API for WordPress', 'graphql-api'),
  > 55| ))) {
    56|     $plugin->setup();
    57| }
Unexpected ')' in layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/graphql-api.php on line 55
Error: Process completed with exit code 1.

Fügen wir den Linter in den Workflow unseres CIs ein. Die Schritte, die wir ausführen müssen, um den Code von PHP 8.0 nach 7.1 zu transpilieren und zu testen, sind:

  1. Schau dir den Quellcode an
  2. Lasse die Umgebung PHP 8.0 laufen, damit Rector den Quellcode interpretieren kann
  3. Transpiliere den Code nach PHP 7.1
  4. Installiere das PHP-Linter-Tool
  5. Ändere die PHP-Version in der Umgebung auf 7.1
  6. Führe den Linter mit dem transpilierten Code aus

Dieser GitHub Action-Workflow erledigt diese Aufgabe:

name: Downgrade PHP tests
jobs:
  main:
    name: Downgrade code to PHP 7.1 via Rector, and execute tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set-up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 8.0
          coverage: none

      - name: Local packages - Downgrade PHP code via Rector
        run: |
          composer install
          vendor/bin/rector process

      # Prepare for testing on PHP 7.1
      - name: Install PHP Parallel Lint
        run: composer create-project php-parallel-lint/php-parallel-lint --ansi

      - name: Switch to PHP 7.1
        uses: shivammathur/setup-php@v2
        with:
          php-version: 7.1
          coverage: none

      # Lint the transpiled code
      - name: Run PHP Parallel Lint on PHP 7.1
        run: php-parallel-lint/parallel-lint src/ vendor/ --exclude vendor/symfony/polyfill-ctype/bootstrap80.php --exclude vendor/symfony/polyfill-intl-grapheme/bootstrap80.php --exclude vendor/symfony/polyfill-intl-idn/bootstrap80.php --exclude vendor/symfony/polyfill-intl-normalizer/bootstrap80.php --exclude vendor/symfony/polyfill-mbstring/bootstrap80.php

Bitte beachte, dass mehrere bootstrap80.php-Dateien aus den Polyfill-Bibliotheken von Symfony (die nicht transpiliert werden müssen) vom Linter ausgeschlossen werden müssen. Diese Dateien enthalten PHP 8.0, so dass der Linter bei der Verarbeitung dieser Dateien Fehler auslösen würde. Der Ausschluss dieser Dateien ist jedoch sicher, da sie in der Produktion nur geladen werden, wenn PHP 8.0 oder höher läuft:

if (\PHP_VERSION_ID >= 80000) {
  return require __DIR__.'/bootstrap80.php';
}

Zusammenfassung

In diesem Artikel haben wir gelernt, wie wir unseren PHP-Code transpilieren können. So können wir PHP 8.0 im Quellcode verwenden und eine Version erstellen, die mit PHP 7.1 funktioniert. Die Transpilierung erfolgt mit Rector, einem Tool zur PHP-Rekonstruktion.

Das Transpilieren unseres Codes macht uns zu besseren Entwicklern, da wir Fehler in der Entwicklung besser erkennen und Code produzieren können, der natürlich leichter zu lesen und zu verstehen ist.

Das Transpilieren ermöglicht es uns auch, unseren Code mit spezifischen PHP-Anforderungen vom CMS zu entkoppeln. Das können wir jetzt tun, wenn wir die neueste Version von PHP verwenden wollen, um ein öffentlich zugängliches WordPress-Plugin oder Drupal-Modul zu erstellen, ohne unsere Nutzerbasis stark einzuschränken.

Hast du noch Fragen zum Transpilieren von PHP? Lass es uns im Kommentarbereich wissen!

Leonardo Losoviz

Leo writes about innovative web development trends, mostly concerning PHP, WordPress and GraphQL. You can find him at leoloso.com and twitter.com/losoviz.