PHP Property Hooks: A Cost of Change Insurance

PHP 8.4 shipped a new feature called “Property Hooks”. To find out what they are, check out the documentation at https://www.php.net/manual/en/language.oop5.property-hooks.php or read about their origin story from Larry Garfield on the PHP Foundation blog https://thephp.foundation/blog/2024/11/01/how-hooks-happened/

A discussion of this feature on Mastodon prompted me to write down my thoughts on why I am a proponent of this feature and think it’s a great addition to the PHP language.

To me the useful part of Property Hooks is their existence but the less often they are used the better. They are a kind of insurance for cases of otherwise catastrophic future change, that you hopefully never need, but that gives you peace of mind.

Historical/Theoretical problematic public property use

Using plain public properties you could define a class Foo as follows and its property $name may be used in various places directly.

class Foo
{
    public string $name;
}

// OtherFile.php
$foo->name = 'Jane';

// AnotherFile.php
$output = 'Hello '.$foo->name;

If you now urgently need to change that names are always used in all caps everywhere in your application, it means that you have to touch every file that reads $foo->name or writes $foo->name or even both depending on how you want to store the data internally.

In reality this scenario also happens very rarely. You can tell from this contrived example, how likely is it that an application suddenly needs its name in all caps everywhere. More likely you’d want it in all caps in some places and you could just add a function to get the name in all caps for those places.

But if one needs to make this kind of a change to all calling sites of a property it would be an extremely error prone process and a fair bit of effort since a large codebase could have thousands of calling sites for the property.

State of the art today

So to combat this potential problem, in PHP today we often see classes defined with plain getters and setters that simply wrap access to a private or protected property, so that you must use the functions to modify or retrieve the property’s state. That means in the event described above, that you must make a change to all places reading or writing the property, you can simply modify the code in either of these two functions instead of thousands of calling sites.

class Foo
{
    private string $name;

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $value): void
    {
        $this->name = $value;
    }
}

// OtherFile.php
$foo->setName('Jane');

// AnotherFile.php
$output = 'Hello '.$foo->getName();

You can see that the getter and setters take up 8 non-empty lines here. All they accomplish is assigning and getting the value of the private property $name. One could instead declare the property $name public, but then you’d risk having to touch every place that access this code in the unlikely scenario that you want to generally change how names are read from our Foo class.

Property Hooks as insurance: Use public properties without fear

Now with the introduction of property hooks, we can delete those 8 lines of boilerplate code and return to our basic class definition from above.

class Foo
{
    public string $name;
}

// OtherFile.php
$foo->name = 'Jane';

// AnotherFile.php
$output = 'Hello '.$foo->name;

But the existence of property hooks lets us write this code without a bad feeling in the back of our head about that potential future event that will force us to touch thousands of places in our code base that access the name property. If that day ever comes, we could use a property hook to change read behavior without touching any calling sites.

class Foo
{
    public string $name {
        get => strtoupper($this->name);
    }
}

// OtherFile.php
$foo->name = 'Jane';

// AnotherFile.php
$output = 'Hello '.$foo->name;

But hopefully these events are just as exceedingly rare as it is uncommon today for anyone to actually modify a getter or setter to do anything other than the default read/write behavior.

I hope to see as little use of property hooks as possible in PHP applications. I support the creation of policies advocating against the use of property hooks unless absolutely unavoidable. If you plan on implementing more complex behavior to set or retrieve a value from the start, just define functions as you would today and use a private property.

And yet I applaud the addition of Property Hooks to PHP, that I’ve long waited for. I thank everyone involved in proposing and implementing this feature, simply because finally we can delete all these unnecessary boilerplate getter and setter functions. Because finally we feel safe to do so, cause Property Hooks serve as our insurance for potential future costs of change.

Restarting supervisord processes in parallel

At Private Packagist we manage some of our background processes with Supervisor. It comes with a command line interface supervisorctl which has an option to restart a group of processes. The way this works is by sending the respective signal to all processes, waiting for them to terminate and then starting them all up again. The way we called this so far is as follows:

supervisorctl signal INT packagist:*
supervisorctl restart packagist:*

As a consequence if you have some processes which take a while before they shut down all other process are already shut down and won’t get started up again yet. For us this happens because some processes may be in the middle of some work we don’t want to interrupt. So instead we want all processes to start up again immediately after they are stopped. My solution uses GNU parallel to run supervisorctl restart in parallel for each of our processes:

supervisorctl signal INT packagist:*
supervisorctl avail | cut -f 1 -d " "|grep packagist:| parallel supervisorctl restart {}

Hope this can be of help to someone looking for a solution to the same problem!

Composer: Replace, Conflict & Forks Explained

Recently there has been an increase of cases in which Composer installs a fork of a package instead of the package the user expects. Most frequently these are forks of packages using a “replace” statement in their composer.json. These forks are usually meant for private use only but are still published on Packagist.

Developers: composer update. Automated systems: composer install.

First of all, this behavior is not a security issue in Composer. While the behavior is unintuitive, it will not result in malicious code being used if you use Composer correctly. Most importantly you should only ever run composer update yourself manually on your development machine. You should read its output, verify it installed and updated packages as expected (use --dry-run to check what would happen without installing anything). You should then commit the generated composer.lock file to your version control system. Continous integration, deployment tools and other automated systems should always run composer install. A composer install run will always use the exact packages that were installed by composer update which was used to generate the lock file – no surprises possible.

Replace identifies which original versions a fork is API compliant with

Secondly it is not wrong to publish forks on Packagist. While we discourage the publication of forks intended solely for private use, you are encouraged to publish forks of open source projects which you intend to maintain for others on Packagist. You may use the replace key to specify which versions of the original package your fork is an API compliant replacement for. You should avoid using too generic version constraints (e.g. * or >=1.0.0) with replacements as with all other dependencies.

Dependency Resolution with Replace, Conflict & Provide

After downloading the list of available packages Composer tries to find a set of packages that satisfies all version constraints specified by all dependencies and their dependencies recursively. Package dependencies are specified as a tuple of name and version constraint. Only such packages will be considered that match the name and version constraint, or replace or provide the specified name with a version constraint matching the given version constraint, and do not match a name and version constraint of a conflict specified in any other package about to be installed. This means that a conflict resulting in a package being excluded, alternatives which replace or provide that name will be considered.

Debugging unexpected fork installations

If your dependencies lead to a conflict with a package Composer may decide to install a fork instead which does not have the same conflict. If you notice that an unexpected fork is installed when running composer update you can debug the dependency problem that lead to the fork installation. Use the conflict key in your composer.json to blacklist the fork. Its structure matches that of the require key. If you run composer update again you will now receive an error message explaining the problem, as the fork is no longer available as an alternative solution to the dependency issue.

{
    "require": {
        "my-favorite/framework": "1.0.0",
        "cool/library-which-conflicts": "0.0.1"
    }
    "conflict": {
        "fork-of/my-favorite-framework": "*"
    }
}

Planned changes to improve usability

We understand that Composer’s behaviour regarding forks and package replacements is unintuitive. So I’ve proposed a number of changes yet to be implemented to the handling of replace & provide on our issue tracker at https://github.com/composer/composer/issues/2690.

TL;DR

Replace is not a bug. Don’t run composer update in automated systems. Forks are allowed on Packagist. Don’t be an idiot when publishing a fork. Got an unexpected fork on update? Your dependencies conflict with the original package. Use conflict (syntax like require) in your composer.json to blacklist the fork and see an explanation of the dependency issue.

Need Composer Consulting? Contact me at composer-consulting@forumatic.com.

Composer: Skipping a dependency

Today Henrik Bjørnskov asked me if it was possible to prevent Composer from installing a package which is a dependency of another package you wish to install. The answer is yes, and this is how:

Let’s say we’re installing bakery/blueberry-cupcake 1.0 which depends on farm/blueberries 1.0. However we’d rather just have the cupcake and there’s no alternative cupcake package. So we’ll tell composer there’s an alternative to farm/blueberries 1.0 called imaginary/blueberries which aren’t blueberries at all.

{
    "repositories": [{
        "type": "package",
        "package": {
            "name": "imaginary/blueberries",
            "type": "metapackage",
            "version": "1.0",
            "replace": {"farm/blueberries": "*"}
        }
    }],
    "require": {
        "bakery/blueberry-cupcake": "1.0",
        "imaginary/blueberries": "1.0"
    }
}

Metapackage is a package type which lets composer know that there are no actual source files associated with the package and it exists solely for composer.

After running composer update to update the lock file and install packages composer show --installed informs us that we have successfully installed bakery/blueberry-cupcake with imaginary/blueberries:

$ composer show --installed
bakery/blueberry-cupcake 1.0
imaginary/blueberries 1.0

A simpler solution without a metapackage is available in this situation as well. Your project can directly replace the farm/blueberries package too.

{
    "require": {
        "bakery/blueberry-cupcake": "1.0"
    },
    "replace": {
        "farm/blueberries": "*"
    }

However this solution will lead to problems if you wish to depend on this package from other packages which want to keep the original dependency.

Need Composer Consulting? Contact me at composer-consulting@forumatic.com.