In ideale omstandigheden zouden we voor al onze sites PHP 8.0 (de laatste versie op het moment van schrijven) gebruiken en deze updaten zodra er een nieuwe versie uitkomt. In de praktijk werken developers echter vaak uit noodzaak met oudere PHP versies, zoals wanneer je een openbare plugin voor WordPress maakt of werkt met verouderde code die het upgraden van de webserveromgeving belemmert.

In deze situaties kunnen we al snel de hoop opgeven om de meest recente PHP versie te gebruiken. Maar er is een beter alternatief: we kunnen onze broncode schrijven met PHP 8.0 en deze transpilen naar een eerdere PHP versie – zelfs naar PHP 7.1.

In deze gids leren we je alles wat je moet weten over het transpilen van PHP code.

Wat is transpilen?

Door te transpilen converteer je de broncode van een programmeertaal naar equivalente broncode van dezelfde of een andere programmeertaal.

Transpilen is zeker geen nieuw concept binnen webontwikkeling: client-side developers zijn waarschijnlijk wel bekend met Babel, een transpiler voor JavaScript code.

Babel converteert JavaScript code van de moderne ECMAScript 2015+ versie naar een oudere versie die compatibel is met oudere browsers. Als de tool een ES2015 arrow functie krijgt:

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

… dan converteert Babel het naar een ES5 versie:

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

Wat is het transpilen van PHP?

Wat potentieel nieuw is binnen webontwikkeling: de mogelijkheid om server-side code te transpilen, en dan in het bijzonder PHP.

Het transpilen van PHP werkt op dezelfde manier als het transpilen van JavaScript: broncode van een moderne PHP versie wordt geconverteerd naar een equivalente code voor een oudere PHP versie.

In de lijn van hetzelfde voorbeeld als hierboven, een arrow functie van PHP 7.4:

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

… kan worden getranspileerd naar de equivalente PHP 7.3 versie:

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

Arrow functies kunnen worden getranspileerd omdat ze syntactic sugar zijn, dat wil zeggen een nieuwe syntax om een bestaand gedrag te produceren. Dit is het laaghangende fruit.

Maar er zijn natuurlijk ook nieuwe features die nieuw gedrag creëren, waarvoor dus geen equivalente code bestaat in eerdere versies van PHP. Dat is het geval met union types, die werden geïntroduceerd in PHP 8.0:

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

In deze situaties is transpilen nog steeds mogelijk, zolang de nieuwe feature nodig is voor ontwikkeling, maar niet voor productie. We kunnen in dat geval de feature helemaal uit de getranspileerde code verwijderen zonder ernstige gevolgen.

Een voorbeeld hiervan zijn union types. Deze feature wordt gebruikt om te controleren of er geen mismatch is tussen het input type en de opgegeven waarde, wat bugs helpt voorkomen. Als er een conflict is met types, dan zie je fout al in de ontwikkelingsfase. Dit betekent dat we, voordat de code de productie-omgeving bereikt, de fout kunnen vinden en oplossen.

Dat is dan ook de reden dat we het ons kunnen veroorloven om de feature uit de “productiecode” kunnen verwijderen:

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

Als de fout nog steeds optreedt in productie, dan zal de gegenereerde foutmelding minder nauwkeurig zijn dan wanneer we union types hadden. Dit potentiële nadeel wordt echter gecompenseerd door het feit dat we überhaupt union types kunnen gebruiken.

Voordelen van het transpilen van PHP code

Transpiling stelt iemand in staat om een applicatie te coderen met de nieuwste versie van PHP en een release te produceren die ook werkt in omgevingen met oudere versies van PHP.

Dit kan met name handig zijn voor ontwikkelaars die producten maken voor oudere Content Management Systems (CMS). WordPress ondersteunt officieel nog steeds PHP 5.6 (hoewel het PHP 7.4+ aanbeveelt). Het percentage van WordPress sites met daarop PHP versies 5.6 tot 7.2 – die allemaal End-Of-Life (EOL) zijn, wat betekent dat ze geen beveiligingsupdates meer ontvangen – staat op een aanzienlijke 34,8%. Sterker nog, maar liefst 99,5% van de sites draait op een versie van eerder dan PHP 8.0:

WordPress gebruiksstatistieken per versie
WordPress gebruiksstatistieken per versie. Bron: WordPress

Als gevolg daarvan zijn WordPress thema’s en plugins die openbaar te verkrijgen zijn en een wereldwijd publiek bedienen, waarschijnlijk gecodeerd met een oudere versie van PHP om hun bereik te vergroten. Dankzij transpiling kunnen deze worden gecodeerd met PHP 8.0, en toch worden uitgebracht voor een oudere PHP versie, waardoor zoveel mogelijk gebruikers worden getarget.

Elke toepassing die een andere PHP versie dan de meest recente moet ondersteunen (zelfs binnen het bereik van de momenteel ondersteunde PHP versies) kan dus profiteren.

Dit is het geval met Drupal, waarvoor PHP versie 7.3 vereist is. Dankzij transpiling kunnen developers openbaar beschikbare Drupal modules maken met PHP 8.0 en deze releasen met PHP 7.3.

Een ander voorbeeld is het maken van aangepaste code voor klanten die om de een of andere reden geen PHP 8.0 in hun omgeving kunnen draaien. Desalniettemin kunnen ontwikkelaars dankzij transpiling hun deliverables nog steeds coderen met PHP 8.0 en ze in die oudere omgevingen uitvoeren.

Wanneer PHP transpilen?

PHP code kan altijd worden getranspileerd, tenzij het een PHP feature bevat die geen equivalent heeft in de vorige PHP versie.

Dat is mogelijk het geval met attributes die werden geïntroduceerd in PHP 8.0:

#[SomeAttr]
function someFunc() {}

#[AnotherAttr]
class SomeClass {}

In het eerdere voorbeeld met daarin arrow functies, kon de code worden getranspileerd omdat arrow functies syntactic sugar zijn. Attributes daarentegen creëren geheel nieuw gedrag. Dit gedrag zou kunnen worden gereproduceerd binnen PHP 7.4 en lager, maar alleen door het handmatig te coderen, dus niet op basis van een tool of proces (AI zou een oplossing kunnen bieden, maar we zijn nog niet zover).

Attributes die bedoeld zijn voor gebruik tijdens de ontwikkelingsfase, zoals #[Deprecated], kunnen op dezelfde manier worden verwijderd als union types. Maar attributes die het gedrag van de toepassing in productie wijzigen, kunnen niet worden verwijderd, en kunnen ook niet rechtstreeks worden getranspileerd.

Vandaag de dag is er nog geen transpiler die code met PHP 8.0 attributes kan pakken om automatisch de equivalente PHP 7.4 code te produceren. Als je PHP code dus attributes moet gebruiken, wordt het transpilen erg lastig, zo niet onmogelijk.

PHP features die kunnen worden getranspileerd

Dit zijn de features van PHP 7.1 en hoger die kunnen worden getranspileerd. Als je code alleen deze features gebruikt, dan kan je er zeker van zijn dat de getranspileerde applicatie zal werken. In alle andere gevallen moet je zelf kijken of de getranspileerde code geen foutmeldingen geeft.

PHP Versie Features
7.1 Everything
7.2 object type
parameter type widening
PREG_UNMATCHED_AS_NULL flag in preg_match
7.3 Reference assignments in list() / array destructuring (behalve binnen foreach#4376)
Flexible Heredoc en Nowdoc syntax
Trailing commas in functions calls
set(raw)cookie accepteert $option argument
7.4 Typed properties
Arrow functies
Null coalescing assignment operator
Unpacking binnen arrays
Numeric literal separator
strip_tags() met array van tag names
covariant return types en contravariant param types
8.0 Union types
mixed pseudo type
static return type
::class magic constant bijj objects
match expressions
catch exceptions allen per type
Null-safe operator
Class constructor property promotion
Trailing commas in parameter lists en closure use lists

PHP transpilers

Momenteel is er één tool voor het transpilen van PHP code: Rector.

Rector is een PHP reconstructortool, die PHP code converteert op basis van programmeerbare regels. We voeren de broncode in en de reeks regels die moeten worden uitgevoerd, en Rector zal de code transformeren.

Rector wordt bediend via de opdrachtregel en wordt via Composer in het project geïnstalleerd. Bij uitvoering zal Rector een “diff” (toevoegingen in groen, verwijderingen in rood) van de code geven (van voor en na de conversie):

"diff" output van Rector
“diff” output van Rector

Naar welke PHP versie je moet transpilen

Om code naar andere PHP versies te transpilen, moeten de bijbehorende regels worden gemaakt.

Tegenwoordig bevat de Rector bibliotheek de meeste regels die je nodig hebt voor het transpilen van code binnen het bereik van PHP 8.0 tot 7.1. We kunnen dus met redelijk vertrouwen PHP code transpilen tot versie  7.1.

Ook zijn er regels voor het transpilen van PHP 7.1 tot 7.1 en van 7.0 tot 5.6, maar deze regels zijn niet uitputtend. Er wordt aan gewerkt om ze te voltooien, zodat we PHP code uiteindelijk naar versie 5.6 kunnen transpilen.

Transpilen vs backporten

Backporting is vergelijkbaar met transpilen, maar simpeler. Het backporten van code is niet noodzakelijk afhankelijk van nieuwe features van een taal. In plaats daarvan kan dezelfde functionaliteit worden geleverd aan een oudere versie van de taal door simpelweg de corresponderende code uit de nieuwe versie van de taal te kopiëren/plakken/aan te passen.

De functie str_contains werd bijvoorbeeld geïntroduceerd in PHP 8.0. Dezelfde functie kan eenvoudig als volgt in PHP 7.4 en lager worden geïmplementeerd:

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;
    }
  }
}

Omdat backporten eenvoudiger is dan transpilen, is het slim om voor backporten te kiezen als dit de klus kan klaren.

Wat betreft alles wat tussen PHP 8.0 en 7.1 komt, kunnen we de polyfill libraries van Symfony gebruiken:

Met deze libraries kan je de volgende functies, classes, constants en interfaces backporten:

PHP Versie Features
7.2 Functies:

Constants:

7.3 Functies:

Exceptions:

7.4 Functies:
8.0 Interfaces:
  • Stringable

Classes:

  • ValueError
  • UnhandledMatchError

Constants:

  • FILTER_VALIDATE_BOOL

Functies:

Voorbeelden van getranspileerde PHP

Laten we een paar voorbeelden van getranspileerde PHP code bekijken, en een paar pakketten die volledig worden getranspileerd.

PHP code

De match expressie werd geïntroduceerd in PHP 8.0. Deze broncode:

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

…wordt getranspileerd naar de equivalente PHP 7.4 versie met behulp van de switch operator:

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

Ook de nullsafe operator werd geïntroduceerd in PHP 8.0:

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

De getranspileerde code moet eerst de waarde van de operation aan een nieuwe variabele toewijzen, om te voorkomen dat de operation twee keer wordt uitgevoerd:

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

De constructor property promotion feature, die ook werd geïntroduceerd in PHP 8.0, stelt developers in staat om minder code te schrijven:

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

Bij het transpilen voor PHP 7.4, wordt het volledige stuk code geproduceerd:

 class QueryResolver
 {
  protected QueryFormatter $queryFormatter;

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

De getranspileerde code hierboven bevat typed properties, die zijn geïntroduceerd in PHP 7.4. Door die code naar PHP 7.3 te transpilen, worden ze vervangen door docblocks:

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

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

PHP packages

The following libraries are being transpiled for production:

Library/beschrijving Code/opmerkingen
Rector
PHP reconstructortool die transpilen mogelijk maakt
– Broncode
– Getranspileerde code
– Opmerkingen
Easy Coding Standards
Tool om PHP code aan een reeks regels te laten voldoen
– Broncode
– Getranspileerde code
– Opmerkingen
GraphQL API for WordPress
Plugin die een GraphQL server voor WordPress levert
– Broncode
– Getranspileerde code
– Opmerkingen

Voor- en nadelen van het transpilen van PHP

Het voordeel van het transpilen van PHP hebben we al kort beschreven: je kan in je broncode PHP 8.0 gebruiken (of de nieuwste versie van PHP) die zal worden getransformeerd naar een lagere versie van PHP zodat deze kan worden uitgevoerd in een oudere applicatie of omgeving.

Hiermee worden we betere ontwikkelaars en kunnen we onze code met een hogere kwaliteit produceren. Dit komt omdat onze broncode de union types van PHP 8.0, de typed properties van PHP 7.4 en de verschillende types en pseudo-types die aan elke nieuwe versie van PHP worden toegevoegd (mixed van PHP 8.0, object van PHP 7.2), kan gebruiken, naast andere moderne features van PHP.

Door deze features te gebruiken, kunnen we tijdens de ontwikkelingsfase bugs sneller vinden en code schrijven die makkelijker te lezen is.

Laten we nu eens kijken naar de nadelen.

Het moet worden gecodeerd en onderhouden

Rector kan code automatisch transpilen, maar er is zeker nog wel wat handmatige input vereist om het te laten werken met jouw specifieke setup.

Externe libraries moeten ook worden getranspileerd

Dit wordt een probleem wanneer het transpilen ervan fouten oplevert, omdat we dan in hun broncode moeten duiken om de mogelijke reden te achterhalen. Als het probleem kan worden opgelost en het project open source is, moeten we een pull-verzoek indienen. Als de library niet open source is, dan hebben we wellicht een probleem.

Rector informeert ons niet wanneer code niet kan worden getranspileerd

Als de broncode PHP 8.0 attributes bevat of een andere feature die niet kan worden getranspileerd, dan kunnen we niet verder gaan. Rector zal deze voorwaarde echter niet zelf checken, dus moeten we dit zelf doen. Dit is misschien geen groot probleem als het om onze eigen broncode gaat, omdat we het door en door kennen, maar het kan wel een probleem vormen met betrekking tot externe dependencies.

Debug-informatie gebruikt de getranspileerde code en niet de broncode

Wanneer de applicatie in productie een foutmelding geeft met een stacktrace, dan wijst het regelnummer naar de getranspileerde code. We moeten het dus terug converteren van getranspileerde naar originele code om het corresponderende regelnummer in de broncode te vinden.

De getranspileerde code moet ook worden geprefixed

Ons getranspileerde project en de andere libraries die ook in de productieomgeving zijn geïnstalleerd, kunnen dezelfde externe dependencies gebruiken. Deze externe dependency wordt getranspileerd voor ons project en behoudt de originele broncode voor de andere library. Daarom moet de getranspileerde versie worden geprefixed via PHP-Scoper, Strauss of een andere tool om mogelijke conflicten te voorkomen.

Transpiling moet plaatsvinden tijdens continue integratie (CI)

Omdat de getranspileerde code uiteraard de broncode overschrijft, moeten we het transpilingproces niet uitvoeren op onze ontwikkelcomputers, anders lopen we het risico dat er bijwerkingen ontstaan. Het proces tijdens een CI run uitvoeren is geschikter (hierover hieronder meer).

Zo transpile je PHP

Eerst moeten we Rector installeren in ons project voor ontwikkeling:

composer require rector/rector --dev

Vervolgens creëren we een rector.php configuratiebestand in de rootmap van het project met daarin de vereiste set regels. Om code te downgraden van PHP 8.0 tot 7.1, gebruiken we deze 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);
};

Om ervoor te zorgen dat het proces wordt uitgevoerd zoals we willen, kunnen we het process commando van Rector in dry mode uitvoeren, waarbij we de te verwerken locatie(s) doorgeven (in dit geval alle bestanden onder de map src/):

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

Om het transpilen uit te voeren, voeren we het process commando van Rector uit, dat de bestanden op hun bestaande locatie zal wijzigen:

vendor/bin/rector process src

Let op: als we het rector process uitvoeren op onze ontwikkelcomputers, wordt de broncode op de huidige plaats geconverteerd, onder src/. We willen de geconverteerde code echter op een andere locatie produceren om de broncode niet te overschrijven bij het downgraden van de code. Om deze reden is het uitvoeren van het proces het meest geschikt tijdens continue integratie.

Optimalisatie van het transpileproces

Om een getranspileerde deliverable voor productie te genereren, hoeft alleen de code voor productie te worden geconverteerd; code die alleen nodig is voor ontwikkeling, kan worden overgeslagen. Dat betekent dat we kunnen voorkomen dat alle tests (voor zowel ons project als de bijbehorende dependencies) en alle dependencies voor ontwikkeling moeten worden getranspileerd.

Wat betreft tests, we weten al waar degenen voor ons project zich bevinden – bijvoorbeeld in de map tests/. We moeten ook uitzoeken waar die voor onze dependencies zijn — bijvoorbeeld in hun submappen tests/, test/ en Test/ (voor verschillende libraries). Vervolgens moeten we Rector vertellen om deze mappen over te slaan tijdens de verwerking:

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

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

Met betrekking tot dependencies weet Composer welke voor ontwikkeling zijn (met entry require-dev in componist.json) en welke voor productie zijn (met entry require).

Om met Composer alle paden met alle dependencies voor productie te halen, voeren we uit:

composer info --path --no-dev

Dit commando produceert een lijst met dependencies met hun naam en pad, zoals hieronder:

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

We kunnen alle paden extracten en ze invoeren in het Rector commando, dat vervolgens de src/ map van ons project zal verwerken plus die mappen die alle dependencies voor productie bevatten:

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

Er is een verbetering mogelijk die kan voorkomen dat Rector dependencies verwerkt die de doel PHP versie al gebruikt. Als een library is gecodeerd met PHP 7.1 (of een versie eronder), dan hoeft deze niet naar PHP 7.1 worden getranspileerd.

Om dit te bereiken, moeten we een lijst met libraries krijgen die PHP 7.2 en hoger nodig hebben, en vervolgens alleen die libraries verwerken. De namen van deze libraries krijgen we via Composer’s why-not commando, zoals hieronder:

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

Omdat dit commando niet werkt met de --no-dev flag, waarmee we alleen dependencies voor productie zouden krijgen, moeten we eerst de dependencies voor ontwikkeling verwijden en de autoloader opnieuw genereren, vervolgens het commando uitvoeren en ze weer toevoegen:

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

Composer’s info --path commando haalt het pad voor een package op volgens dit format:

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

We voeren deze opdracht uit voor alle items in onze lijst om alle paden te krijgen die we willen transpilen:

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

Ten slotte geven we deze lijst aan Rector (plus de src/ map van het project):

vendor/bin/rector process src $paths

Te vermijden valkuilen bij het transpilen van code

Het transpilen van code kan als een kunst worden beschouwd, waarvoor vaak aanpassingen nodig zijn die specifiek zijn voor het project. Laten we eens kijken naar een paar problemen die we kunnen tegenkomen.

Chained rules worden niet altijd verwerkt

Een chained rule is wanneer een rule code moet converteren die door een eerdere rule is geproduceerd.

Library symfony/cache bevat bijvoorbeeld deze code:

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

Bij het transpilen van PHP 7.4 naar 7.3, moet de functie tag twee wijzigingen ondergaan:

Het eindresultaat zou als volgt moeten zijn:

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

Rector geeft als output echter alleen de tussenfase:

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

Het probleem is dat Rector niet altijd controle heeft over de volgende waarin de regels worden toegepast.

De oplossing is om te identificeren welke chained rules onverwerkt zijn gelaten en een nieuwe Rector run uit te voeren om ze toe te passen.

Om de chained rules te identificeren, draaien we Rector twee keer op de broncode, zoals hieronder:

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

De eerste keer draaien we Rector zoals verwacht, om de transpiling uit te voeren. De tweede keer gebruiken we de --dry-run flag om te kijken of er nog wijzigingen moeten worden aangebracht. Als dat het geval is, wordt het commando afgesloten met een foutcode, en geeft de uitvoer “diff” aan welke regel(s) nog kunnen worden toegepast. Dat zou betekenen dat de eerste run niet voltooid was en dat een of andere chained rule niet werd verwerkt.

Rector uitvoeren met --dry-run flag
Rector uitvoeren met –dry-run flag

Zodra we de niet-verwerkte chained rule(s) hebben geïdentificeerd, kunnen we een ander Rector configuratiebestand maken — rector-chained-rule.php kan bijvoorbeeld de ontbrekende rule uitvoeren. In plaats van een volledige set regels voor alle bestanden onder src/ te verwerken, kunnen we deze keer de specifieke ontbrekende rule uitvoeren binnen het specifieke bestand waar het moet worden toegepast:

// 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',
  ]);
};

En slotte vertellen we Rector in de tweede ronde om het nieuwe config bestand te gebruiken via input --config:

# 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 depencies kunnen inconsistent zijn

Libraries kunnen declaren dat een dependency bedoeld is voor ontwikkeling (bijv door require-dev in composer.json), maar toch voor productie naar een deel van de code ervan verwijzen (zoals sommige bestanden onder src/, niet tests/).

Meestal is dit geen probleem omdat die code vaak niet in productie wordt geladen, dus er zal nooit een fout in de applicatie zijn. Wanneer Rector echter de broncode en zijn dependencies verwerkt, controleert het of alle code waarnaar wordt verwezen ook kan worden geladen. Rector zal een foutmelding geven als een bestand verwijst naar een stukje code uit een niet-geïnstalleerde library (omdat er werd gedeclared dat deze alleen nodig was voor ontwikkeling).

EarlyExpirationHandler van Symfony’s Cache component implementeert bijvoorbeeld interface MessageHandlerInterface van de Messenger component:

class EarlyExpirationHandler implements MessageHandlerInterface
{
    //...
}

Maar symfony/cache declaret symfony/messenger als een dependency voor ontwikkeling. Als Rector vervolgens wordt uitgevoerd op een project dat afhankelijk is van symfony/cache, dan zal deze een foutmelding geven:

[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".   

Er zijn drie oplossingen voor dit probleem:

  1. Sla in de Rector config de verwerking over van het bestand waarnaar dat stukje code verwijst:
return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::SKIP, [
    __DIR__ . '/vendor/symfony/cache/Messenger/EarlyExpirationHandler.php',
  ]);
};
  1. Download de ontbrekende libary en voeg het pad toe dat door Rector automatisch moet worden geladen:
return static function (ContainerConfigurator $containerConfigurator): void {
  // ...

  $parameters->set(Option::AUTOLOAD_PATHS, [
    __DIR__ . '/vendor/symfony/messenger',
  ]);
};
  1. Laat je project voor productie ook afhankelijk van de ontbrekende library:
composer require symfony/messenger

Transpilen en continue integratie

Zoals eerder vermeld, moeten we in onze ontwikkelingscomputers de --dry-run flag gebruiken wanneer Rector wordt uitgevoerd, anders wordt de broncode overschreven door de getranspileerde code. Om deze reden is het geschikter om het daadwerkelijke transpiling-proces uit te voeren tijdens continue integratie (CI), waarbij we tijdelijke runners kunnen laten draaien om het proces uit te voeren.

Een ideaal moment om het transpiling-proces uit te voeren is bij het genereren van de release voor ons project. De onderstaande code is bijvoorbeeld een workflow voor Github Actions, die de release van een WordPress plugin creëert:

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 }}

Deze workflow bevat een standaard procedure om een WordPress plugin te releasen via GitHub Actions. De nieuwe toevoeging, om de code van de plugin te transpilen van PHP 7.4 naar 7.1, gebeurt in deze stap:

      - 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

Alles bij elkaar voert deze workflow nu de volgende stappen uit:

  1. Bekijkt de broncode voor een WordPress plugin uit de repository, geschreven met PHP 7.4
  2. Installeert de Composer dependencies
  3. Transpilet de code van PHP 7.4 naar 7.1
  4. Wijzigt de entry “Requires PHP” in de header van het hoofdbestand van de plugin van "7.4" naar "7.1"
  5. Verwijdert de dependencies die nodig zijn voor ontwikkel
  6. Maakt het .zip bestand van de plugin, met uitzondering van alle onnodige bestanden
  7. Uploadt het .zip bestand als een release asset (en bovendien als een artifact naar de GitHub Action)

Testen van getranspileerde code

Als de code eenmaal naar PHP 7.1, is getranspileerd, hoe weten we dan of deze goed werkt? Of, met andere woorden, hoe weten we dat het secuur is geconverteerd en dat er geen overblijfselen van nieuwere versies van PHP code zijn achtergebleven?

Net als bij het transpilen van de code zelf, kunnen we de oplossing toevoegen aan een CI proces. Het idee is om de omgeving van de runner op te zetten met PHP 7.1 en een linter uit te voeren op de getranspileerde code. Als een stukje code niet compatibel is met PHP 7.1 (zoals een typed property van PHP 7.4 die niet is geconverteerd), geeft de linter een foutmelding.

Een linter voor PHP die goed werkt, is PHP Parallel Lint. We kunnen deze library installeren als een dependency voor ontwikkeling binnen ons project, of dit tijdens het CI proces doen als een stand-alone Composer project:

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

Telkens wanneer de code PHP 7.2 en hoger bevat, geeft PHP Parallel Lint een foutmelding zoals deze:

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.

Laten we linter toevoegen aan onze CDI workflow. De stappen die nodig zijn om de code te transpilen van PHP 8.0 naar 7.1 en alles te testen, zijn:

  1. Broncode bekijken
  2. De omgeving op PHP 8.0 laten draaien, zodat Rector de broncode kan interpreteren
  3. De code transpilen naar PHP 7.1
  4. De PHP lintertool installeren
  5. De PHP versie van de omgeving overschakelen naar 7.1
  6. Linter uitvoeren op de getranspileerde code

Deze GitHub Action workflow klaart de klus:

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

Houd er rekening mee dat verschillende bootstrap80.php bestanden uit Symfony’s polyfill libraries (die niet hoeven te worden getranspileerd) van de linter moeten worden uitgesloten. Deze bestanden bevatten PHP 8.0, dus de linter zal fouten melden bij het verwerken ervan. Het uitsluiten van deze bestanden is echter veilig omdat ze alleen in productie worden geladen als PHP 8.0 of hoger wordt uitgevoerd:

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

Samenvatting

Dit artikel heeft je geleerd hoe we onze PHP code kunnen transpilen, waardoor we PHP 8.0 in de broncode kunnen gebruiken en een release kunnen maken die werkt op PHP 7.1. Transpiling doe je via Rector, een PHP reconstructortool.

Het transpilen van onze code maakt van ons betere ontwikkelaars, omdat we in de ontwikkelingsfase bugs sneller kunnen vinden en omdat we code kunnen produceren die gemakkelijker te lezen en begrijpen is.

Transpiling stelt ons ook in staat om onze code te ontkoppelen met de specifieke PHP vereisten van het CMS. We kunnen hiermee de nieuwste versie van PHP gebruiken om een openbaar beschikbare WordPress plugin of Drupal module te maken, zonder dat we hiermee het aantal potentiële klanten aanzienlijk beperken.

Heb je nog vragen over het transpilen van PHP? Laat het ons weten in de reacties hieronder!

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.