this post was submitted on 19 Feb 2025
7 points (100.0% liked)

Programming

23 readers
9 users here now

My various programming endeavours, mainly in PHP (Symfony), Typescript (Angular), Go and C#. With a sprinkle of Java and C++ here and there.

founded 1 month ago
MODERATORS
 

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:

  1. It always tries to install the newest version that matches your version constraints.
  2. 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?

you are viewing a single comment's thread
view the rest of the comments
[–] [email protected] 0 points 3 days ago

@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?