The Problem
Every developer wants to use the latest and greatest features of their tools, and PHP is no exception. But sometimes you simply can’t upgrade—whether because of project constraints or because your users are still on an older PHP version. For instance, if you’re building a library, you’ll often need to target a version that’s a few releases behind the latest, so you’re not forcing your users to upgrade before they’re ready.
The Solution
Transpiling! Instead of writing code that only works on a modern PHP version, you write it using the newest features and then transpile it down to your target PHP version. One of the best tools for this job is Rector. You might know Rector as the tool that automatically upgrades your code to a newer version of PHP—but it works in reverse as well. Downgrading is just as easy. For example, to downgrade your code to PHP 7.4, your rector.php
file can be as simple as this:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
])
->withDowngradeSets(php74: true)
;
Now, simply run Rector as you normally would (for example, vendor/bin/rector process
), and you’re all set…
As an example, here’s a class that uses many modern PHP features:
final readonly class ModernClass
{
final protected const string TYPED_FINAL_CONSTANT = 'some-string';
public function __construct(
public int $promotedProperty,
private stdClass $data = new stdClass(),
) {
// new without parenthesis
$selfName = new ReflectionClass($this)->getName();
// named parameters and the new rounding mode enum
$rounded = round(5.5, mode: RoundingMode::HalfTowardsZero);
// previously those functions only worked with Traversable instances, in PHP 8.2 they work with both Traversable and array instances
$array = [1, 2, 3];
$count = iterator_count($array);
$array = iterator_to_array($array);
$callable = $this->methodThatReturnsNever(...);
$callable();
}
private function methodThatReturnsNever(): never
{
throw new Exception();
}
// standalone false/true/null type
public function returnTrue(): true
{
return true;
}
public function returnFalse(): false
{
return false;
}
public function returnNull(): null
{
return null;
}
}
And here’s what it looks like after downgrading:
final class ModernClass
{
/**
* @readonly
*/
public int $promotedProperty;
/**
* @readonly
*/
private stdClass $data;
/**
* @var string
*/
protected const TYPED_FINAL_CONSTANT = 'some-string';
public function __construct(
int $promotedProperty,
?stdClass $data = null
) {
$data ??= new stdClass();
$this->promotedProperty = $promotedProperty;
$this->data = $data;
// new without parenthesis
$selfName = (new ReflectionClass($this))->getName();
// named parameters and the new rounding mode enum
$rounded = round(5.5, 0, \PHP_ROUND_HALF_DOWN);
// previously those functions only worked with Traversable instances, in PHP 8.2 they work with both Traversable and array instances
$array = [1, 2, 3];
$count = iterator_count(is_array($array) ? new \ArrayIterator($array) : $array);
$array = iterator_to_array(is_array($array) ? new \ArrayIterator($array) : $array);
$callable = \Closure::fromCallable([$this, 'methodThatReturnsNever']);
$callable();
}
/**
* @return never
*/
private function methodThatReturnsNever()
{
throw new Exception();
}
// standalone false/true/null type
/**
* @return true
*/
public function returnTrue(): bool
{
return true;
}
/**
* @return false
*/
public function returnFalse(): bool
{
return false;
}
/**
* @return null
*/
public function returnNull()
{
return null;
}
}
This is now a perfectly valid PHP 7.4 class. It’s amazing to see how much PHP has evolved since 7.4—not to mention compared to the old 5.x days. I personally can’t live without property promotion anymore.
Note: Not every piece of modern PHP code can be downgraded automatically. For example, Rector leaves the following property definitions unchanged:
public bool $hooked { get => $this->hooked; } public private(set) bool $asymmetric = true;
I assume support for downgrading asymmetric visibility will eventually be added, but hooked properties are very hard to downgrade in general—even though in some specialized cases they could be converted to readonly properties.
Downgrading Your Composer Package
If you want to write your package using modern PHP features but still support older PHP versions, you need a way to let Composer know which version to install. One simple approach would be to publish a separate package for each PHP version—say, the main package as vendor/package
and additional ones like vendor/package-82
, vendor/package-74
, etc. While this works, it has a drawback. For instance, if you’re on PHP 8.3 and later upgrade your main package to PHP 8.4, you’d have to force users to switch to a new package (say, vendor/package-83
), rendering the package incompatible for anyone still on an older PHP version.
Instead, I leverage two behaviors of Composer:
- It always tries to install the newest version that matches your version constraints.
- It picks the latest version that is supported by the current environment.
This means you can add a suffix to each transpiled version. For version 1.2.0, you might have:
- 1.2.084 (for PHP 8.4)
- 1.2.083 (for PHP 8.3)
- 1.2.082 (for PHP 8.2)
- 1.2.081 (for PHP 8.1)
- 1.2.080 (for PHP 8.0)
- 1.2.074 (for PHP 7.4)
When someone runs composer require vendor/package
, Composer will select the version with the highest version number that is compatible with their PHP runtime. So, a user on PHP 8.4 gets 1.2.084, while one on PHP 8.2 gets 1.2.082. If you use the caret (^
) or greater-than-or-equal (=
) operator in your composer.json
, you also future-proof your package: if someone with a hypothetical PHP 8.5 tries to install it, they’ll still get the highest compatible version (in this case, 1.2.084).
Of course, you’ll need to run the transpilation before each release and automatically update your composer.json
file. For older PHP versions, you might also have to make additional adjustments. In one package I worked on, I had to include extra polyfills for PHP 7.2 and even downgrade PHPUnit—but overall, the process works really well.
You can see this approach in action in the Unleash PHP SDK. More specifically, check out this workflow file and, for example, this commit which shows all the changes involved when transpiling code from PHP 8.3 down to PHP 7.2.
Caveat: One important downside of this approach is that if a user installs the package in an environment that initially has a newer PHP version than the one where the code will eventually run (or where dependencies will be installed), Composer might install a version of the package that the actual runtime cannot handle.
I believe this approach offers the best of both worlds when writing packages. You get to enjoy all the modern PHP features (I can’t live without constructor property promotion, and public readonly properties are fantastic for writing simple DTOs), while still supporting users who aren’t able—or ready—to upgrade immediately.
It’s also a powerful tool if your development team can upgrade PHP versions faster than your server administrators. You can write your app using the latest syntax and features, and then transpile it to work on the servers that are actually in use.
So, what do you think? Is this an approach you or your team might consider?
@dominik Have you thought of using build-numbers instead of (ab)using the patch number?
x.y.z+84
x.y.z+83
etc…But my biggest concern is actually security or size-related. 'Cause how do I provide the data to packagist? I either need separate repositories for those ehich increases the maintenance burden or requires sophisticated automization. Or I provide precompiled “binaries” - which can contain anything. Even code that is not in the repo - a security nightmare…
How did you solve that?
Hmm, that build number would definitely be better! I tried adding another level (like 1.0.0.84) but that didn’t work. I’ll try it with the build numbers.
I wrote a GitHub workflow that transpiles it on release and tags each transpiled version, then pushes those tags to the repo. Packagist automatically fetches tags, so it gets them automatically.
For example, the tag v2.7.082.