[PHP-DEV] Module or Class Visibility, Season 2

Hi!

It’s been a few days since I wanted to send this email to internals, but real life has been a bit chaotic so I apologize if it comes off as if I didn’t research the archives enough. I glossed over the Module conversation from 10 months ago and the one that recently surfaced and after deeply thinking about Rowan’s and Larry’s comments I wanted to throw this idea into the pit.

Lets preface the conversation with the fact that 1) a module system for PHP has been discussed for several years and 2) if there was an easy and perfect solution it would have been long implemented by now. With that in mind, I think there are mainly two major “camps”: the ones that would support something new similar to Node ESM vs CommonJS and those who won’t. Having dealt with this mess on the NodeJS side, I’m still on the side that would support it because even though it’s been 10 years worth of “mess”, it has greatly empowered progress. But I think PHP is too conservative to indulge this camp, so I’m going to focus on Rowan’s and Larry’s position of “we need something that builds on top of namespace, not replace it”.

If we consider how GitHub, Composer and Docker Hub works, we can pin a very important aspect of “namespaces”: {entity}/{project}. Entity may either be an individual or an organization, but the concept is mostly the same. Although it can be argued that PHP has nothing to do with that, I think that could be a “good-enough” foundation considering the complexity of the subject. Here is what we could do:


~~~

<?php declare(strict_types=1); namespace Acme\ProjectOne { public class Foo {} // same as class Foo {} private class Bar {} // only visible inside Acme\ProjectOne protected class Baz {} // visible inside Acme } namespace Acme\ProjectTwo { new \Acme\ProjectOne\Foo; // Work as always new \Acme\ProjectOne\Bar; // Fatal error: Uncaught Error: Cannot instantiate private class \Acme\ProjectOne\Bar from \Acme\ProjectTwo new \Acme\ProjectOne\Baz; // Works } namespace Corp\Corp { new \Acme\ProjectOne\Foo; // Work as always new \Acme\ProjectOne\Bar; // Fatal error: Uncaught Error: Cannot instantiate private class \Acme\ProjectOne\Bar from \Corp\Corp new \Acme\ProjectOne\Baz; // Fatal error: Uncaught Error: Cannot instantiate protected class \Acme\ProjectOne\Baz from \Corp\Corp } function (\Acme\ProjectOne\Foo $foo) {} // Works as always function (\Acme\ProjectOne\Bar $bar) {} // Open question: allow or disallow it? function (\Acme\ProjectOne\Baz $baz) {} // Open question: allow or disallow it? ``` ~~~ ``` This would allow public, private and protected classes in a way that I believe to be useful for the large ecosystem that surrounds Composer. From my extremely limited understanding of the engine, I think the easy/natural step would be to allow private/protected classes to be **received** outside its namespace because a type declaration does not trigger autoload. However, an important question is whether this is enough groundwork that could lead to optimizations that have been discussed when the topic of module is brought up. For instance, if type-hint outside the module is disallowed, could that make it easier to pack and optimize an entire module if we could instruct PHP how to load all symbols of a namespace all at once? I don't know. ------------------------ As I'm writing this down I don't know if it could be related or if its something only making sense inside my head, but I see the above proposal paired with a potential amendment to PSR-4 (and Composer), to stimulate the community to pack small related symbols in a single file with an opt-in approach: composer.json: ``` // ... ~~~ ``` "autoload": { "psr-4-with-module": { "App\\": "app/", } }, ``` ~~~ // ... ``` ``` ``` <?php declare(strict_types=1); // app/Foo/Bar.php namespace App\Foo; class Bar {} // app/Foo.module.php namespace App\Foo; enum Baz {} enum Qux {} new \App\Foo\Bar; // loads app/Foo/Bar.php \App\Foo\Baz::option; // file app/Foo/Baz.php does not exist, tries app/Foo.module.php before giving up \App\Foo\Qux::option; // app/Foo.module.php has been loaded and Qux has been registered already ``` ``` Thoughts? --

Hey all,

Anyone familiar with C++'s friend keyword? It’s not a direct replacement for modules, but it solves similar problems — allowing trusted classes or functions to access private/protected members without making them public.

The idea: allow one class to explicitly grant access to another class or function. Useful for tightly coupled code that still wants to maintain encapsulation. Since friend would be a new keyword, it’s safe to add (currently a parse error).

Examples:
`
class Engine {
private string $status = ‘off’;
friend class Car;
friend function debugEngine;
}

class Car {
public function start(Engine $e) {
$e->status = ‘on’; // allowed
}
}

function debugEngine(Engine $e) {
echo $e->status; // also allowed
}
`
This avoids reflection, awkward internal APIs, or overly permissive visibility. Could be useful in frameworks, testing tools, or any place where selective trust is helpful.

Thoughts?

Hammed

···

Marco Deleu

On Wed, May 21, 2025 at 8:27 AM Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> wrote:

So if we can come up with a solution where only the WordPress plugins need to be changed, and you can use whatever dependencies you want without waiting for them to be changed to a new way of working, is that not a good thing?

Yes, and that’s all I think is needed here. I could modify plugin code if I needed to. The majority of developers capable of doing this have left WordPress in disgust - I did too, but I needed the work. Since I have to work with it I’d like to make it more sane. One major step in that direction is the Timber library which bridges WP to Twig and gets rid of that god damned loop architecture they think is the bees knees but in reality is an antipattern and untestable spaghetti nightmare.

I’ve tried several times to explain why I think Linux containers are a good analogy; I’m not sure if you didn’t understand, or just didn’t agree, so I don’t know what else I can say.

I have no disagreement with that, but it’s an implementation detail. I’m not there yet - I’m just trying to describe what I think is needed from outside the engine.

Looking closely, I see I did make one honest mistake: in your example, the WordPress plugins are A and B, not B and C. So my sentence should have read “A and B are one kind of thing, but D is a different kind of thing”.

That’s what set me off the most and I over-reacted. To you and to the list at large, I apologize. I’m just frustrated - I feel like a five year old trying to explain a problem to a physicist.

On Wed, May 21, 2025 at 6:09 PM Hammed Ajao <hamiegold@gmail.com> wrote:

Anyone familiar with C++'s friend keyword? It’s not a direct replacement for modules, but it solves similar problems — allowing trusted classes or functions to access private/protected members without making them public.

Friend has been brought up before and I believe it was in at least one RFC before and voted down. That doesn’t mean the issue can’t be revisited, but look into the archive and see if my memory is right and if so why was it voted down before? IIRC it’s tied to the fact PHP doesn’t have a notion of namespace level visibility. Classes and functions outside of classes must be public in the current architecture.

On 22/05/2025 12:09, Michael Morris wrote:

    I've tried several times to explain why I think Linux containers
    are a good analogy; I'm not sure if you didn't understand, or just
    didn't agree, so I don't know what else I can say.

I have no disagreement with that, but it's an implementation detail. I'm not there yet - I'm just trying to describe what I think is needed from outside the engine.

I think this is where we're not seeing eye to eye, and why we're getting frustrated with each other, because I see it as far more fundamental than details you have already gone into, like how autoloading will work.

Perhaps a more realistic example will help, and also avoid the confusion over "A, B, and D" from earler.

Imagine a WordPress plugin, AlicesCalendar, which uses the Composer packages monolog/monolog and google/apiclient. The google/apiclient package also requires monolog/monolog.

Another WordPress plugin, BobsDocs, also uses both monolog/monolog and google/apiclient, but using different versions.

Inside those different places, there are lines of code like this:

$logger = new \Monolog\Logger('alices-calendar'); // in AlicesCalendar
$logger = new \Monolog\Logger('bobs-docs'); // in BobsDocs
$logger = new \Monolog\Logger('google-api-php-client'); // in google/apiclient

We need to rewrite those lines so that they all refer to the correct version of Monolog\Logger.

If every package/module/whatever rewrites the classes inside every other package/module/whatever, we might start with this:

$logger = new \AlicesCalendar\Monolog\Logger('alices-calendar'); // in AlicesCalendar
$logger = new \BobsDocs\Monolog\Logger('bobs-docs'); // in BobsDocs
$logger = new \GoogleApiClient\Monolog\Logger('google-api-php-client'); // in google/apiclient

That only works if we somehow know that AlicesCalendar and BobsDocs use the same google/apiclient; if not, we need four copies:

$logger = new \AlicesCalendar\Monolog\Logger('alices-calendar'); // in AlicesCalendar
$logger = new \AlicesCalendar\GoogleApiClient\Monolog\Logger('google-api-php-client'); // in google/apiclient when called from AlicesCalendar

$logger = new \BobsDocs\Monolog\Logger('bobs-docs'); // in BobsDocs
$logger = new \BobsDocs\GoogleApiClient\Monolog\Logger('google-api-php-client'); // in google/apiclient when called from BobsDocs

All of these are separate classes, which can't be used interchangeably, and the names get longer and longer to isolate dependencies inside dependencies.

But we don't actually need the Monolog\Logger used by AlicesCalendar to be a different version from the one used by google/api-client. In fact, it would be useful if they were the same, so we could pass around the objects interchangeably *inside* the plugin code.

So what we want is some way of saying that AlicesCalendar and BobsDocs are special; they want to isolate code in a way that normal modules/packages/whatever don't. Then we can have 2 copies of Monolog\Logger, not 3 or 4:

$logger = new \AlicesCalendar\Monolog\Logger('alices-calendar'); // in AlicesCalendar
$logger = new \AlicesCalendar\Monolog\Logger('google-api-php-client'); // in google/apiclient when called from AlicesCalendar

$logger = new \BobsDocs\Monolog\Logger('bobs-docs'); // in BobsDocs
$logger = new \BobsDocs\Monolog\Logger('google-api-php-client'); // in google/apiclient when called from BobsDocs

In this case, PHP doesn't need to know monolog/monolog even exists. It just puts either "AlicesCalendar" or "BobsDocs" on any class name it sees.

Before we can even think about *how* we'd implement the rewriting (or shadowing, or whatever) we need some requirements of *what* we want to rewrite. By suggesting an image of "containers" or "sandboxes" rather than "packages" or "modules", I was trying to define the requirement that "AlicesCalendar and BobsDocs are special, in a way that monolog/monolog and google/apiclient are not".

--
Rowan Tommins
[IMSoP]

On Thu, May 22, 2025 at 4:29 PM Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> wrote:

On 22/05/2025 12:09, Michael Morris wrote:

I’ve tried several times to explain why I think Linux containers are a good analogy; I’m not sure if you didn’t understand, or just didn’t agree, so I don’t know what else I can say.

I have no disagreement with that, but it’s an implementation detail. I’m not there yet - I’m just trying to describe what I think is needed from outside the engine.

I think this is where we’re not seeing eye to eye, and why we’re getting frustrated with each other, because I see it as far more fundamental than details you have already gone into, like how autoloading will work.

Perhaps a more realistic example will help, and also avoid the confusion over “A, B, and D” from earler.

Imagine a WordPress plugin, AlicesCalendar, which uses the Composer packages monolog/monolog and google/apiclient. The google/apiclient package also requires monolog/monolog.

Another WordPress plugin, BobsDocs, also uses both monolog/monolog and google/apiclient, but using different versions.

Inside those different places, there are lines of code like this:

$logger = new \Monolog\Logger(‘alices-calendar’); // in AlicesCalendar
$logger = new \Monolog\Logger(‘bobs-docs’); // in BobsDocs
$logger = new \Monolog\Logger(‘google-api-php-client’); // in google/apiclient

We need to rewrite those lines so that they all refer to the correct version of Monolog\Logger.

If every package/module/whatever rewrites the classes inside every other package/module/whatever, we might start with this:

$logger = new \AlicesCalendar\Monolog\Logger(‘alices-calendar’); // in AlicesCalendar
$logger = new \BobsDocs\Monolog\Logger(‘bobs-docs’); // in BobsDocs
$logger = new \GoogleApiClient\Monolog\Logger(‘google-api-php-client’); // in google/apiclient

That only works if we somehow know that AlicesCalendar and BobsDocs use the same google/apiclient; if not, we need four copies:

$logger = new \AlicesCalendar\Monolog\Logger(‘alices-calendar’); // in AlicesCalendar
$logger = new \AlicesCalendar\GoogleApiClient\Monolog\Logger(‘google-api-php-client’); // in google/apiclient when called from AlicesCalendar

$logger = new \BobsDocs\Monolog\Logger(‘bobs-docs’); // in BobsDocs
$logger = new \BobsDocs\GoogleApiClient\Monolog\Logger(‘google-api-php-client’); // in google/apiclient when called from BobsDocs

All of these are separate classes, which can’t be used interchangeably, and the names get longer and longer to isolate dependencies inside dependencies.

But we don’t actually need the Monolog\Logger used by AlicesCalendar to be a different version from the one used by google/api-client. In fact, it would be useful if they were the same, so we could pass around the objects interchangeably inside the plugin code.

So what we want is some way of saying that AlicesCalendar and BobsDocs are special; they want to isolate code in a way that normal modules/packages/whatever don’t. Then we can have 2 copies of Monolog\Logger, not 3 or 4:

$logger = new \AlicesCalendar\Monolog\Logger(‘alices-calendar’); // in AlicesCalendar
$logger = new \AlicesCalendar\Monolog\Logger(‘google-api-php-client’); // in google/apiclient when called from AlicesCalendar

$logger = new \BobsDocs\Monolog\Logger(‘bobs-docs’); // in BobsDocs
$logger = new \BobsDocs\Monolog\Logger(‘google-api-php-client’); // in google/apiclient when called from BobsDocs

In this case, PHP doesn’t need to know monolog/monolog even exists. It just puts either “AlicesCalendar” or “BobsDocs” on any class name it sees.

Before we can even think about how we’d implement the rewriting (or shadowing, or whatever) we need some requirements of what we want to rewrite. By suggesting an image of “containers” or “sandboxes” rather than “packages” or “modules”, I was trying to define the requirement that “AlicesCalendar and BobsDocs are special, in a way that monolog/monolog and google/apiclient are not”.

This is worlds better, and I think I can work with this.

First, let’s revisit how autoloading works, if for no other reason than to test if I understand what’s going on correctly. When PHP encounters a symbol it doesn’t recognize, it triggers the autoload process. Autoloaders are closures registered with the engine using spl_autoload_register, and PHP queries them one at a time (I don’t remember the order offhand). The autoloader function runs and PHP retests to see if it can resolve the symbol. If it can, code execution continues. If it can’t the next autoloader is ran and if none are left a Fatal Error is thrown. Autoload closures get 1 argument - the fully qualified class name. They are expected to return void.

I believe it would be best to leave the wild and wooly world of package management alone and just give the engine the ability to allow code in one area to use a different code even though it has the same label, at least on the surface. I think this is possible if the engine handles the symbol assignment in a different way from the existing include statements. The cleanest way to do that would be to have the autoloader return the file path to require and, optionally, what namespace to prefix onto all namespaces in the file.

In summary, let the package manager resolve packages and give it better tools towards that end.

Returning to your example and closing question, how do we know that AlicesCalendar and BobsDocs are special? Let the package manager tell us with this hook:

spl_package_register( array[string] $packages):void

To use composer the user has to run require "/vendor/composer/autoload.php"; near the beginning of their application. So inside that file a package aware version of composer can call this to tell the engine what the package namespaces are - in your example [‘AlicesCalendar’, ‘BobsDocs’]. (Aside, if spl_package_register is called multiple times the arrays are merged).

Now, PHP executes the application and enters the code of AlicesCalendar, it will be largely unchanged:

namepace AlicesCalendar;

$logger = new Monolog\Logger('alices-calendar');
$api = new Google\ApiClient();

But thanks to the spl_package_register hook the engine knows that when it sees a namespace that starts with or matches any string in the packages array that the code is part of a package. This will cause it to sent the autoload closure a second argument with that package namespace so that it can determine what to send back.

So next it sees the Monolog\Logger symbol. Does AlicesCalendar\Monolog\Logger exists? No, so we invoke the autoloader callback with arguments (‘AlicesCalendar\Monolog\Logger’, ‘AlicesCalendar’). The autologger checks its rules (way, way out of scope here) and determines that AlicesCalendar is using the latest Monolog\Logger. So it responds with [‘file/path/to/latest/Monolog/Logger.php’, ‘’], telling the engine what code to require and that there is no prefix for the namespaces appearing in that file ( "" should also work). The engine aliases AlicesCalendar\Monolog\Logger to \Monolog\Logger so it doesn’t have to pester the autoloader again for this symbol.

The Google\ApiClient goes through the same process. As a result:

namepace AlicesCalendar;

$logger = new Monolog\Logger('alices-calendar');
$api = new Google\ApiClient();

echo $logger::class // \Monolog\Logger
echo $api::class // \Google\ApiClient

Now for the complexity - we reach BobsDocs

namespace BobsDocs;

$logger = new Monolog\Logger('bobs-docs')
$api = new Google\ApiClient();

Bobs docs needs an older version of Monolog and is configured appropriately in its composer.json file, so when the engine calls the autoloader with (‘BobsDocs\Monolog\Logger’, ‘BobsDocs’) the autoloader returns [‘file/path/to/older/Monolog/Logger.php’, ‘v1’]. v1 is prefixed to the namespace declarations in Monolog\Logger and the file is included. The engine aliases BobsDocs\Monolog\Logger to \v1\Monolog\Logger.

Keep in mind - namespace prefix is a decision left to the package manager. I’m sure a PSR will be made to establish best practice, but that’s out of scope here.

The Googl\ApiClient of BobDocs is again, up to the autoloader. Assuming it too is different (since it’s using an older Monolog) we’d get something like this.

namespace BobsDocs;

$logger = new Monolog\Logger('bobs-docs')
$api = new Google\ApiClient();

echo $logger::class // \v1\Monolog\Logger
echo $api::class // \v1\Google\ApiClient

Now later in the code if we make a new \Monolog\Logger the autoloader won’t be invoked - the symbol was written when AlicesCalendar caused it to be created indirectly.

This approach keeps package resolution out of the engine entirely, which I think is consistent with PHP’s setup. We’d just be improving the tools the package manager / autoloader can leverage. Older code would still work since the new autoloader behavior is opt in.

Hi Michael,

I'm going to skip over all the details about the autoloader for now, because I think they're going deep into implementation details, and I want to focus on the same top-level design as my previous email.

On 23 May 2025 02:27:41 BST, Michael Morris <tendoaki@gmail.com> wrote:

Bobs docs needs an older version of Monolog and is configured appropriately
in its composer.json file, so ... v1 is prefixed to the
namespace declarations in Monolog\Logger and the file is included. The
engine aliases BobsDocs\Monolog\Logger to \v1\Monolog\Logger.

If I'm following correctly, you suggest that we would end up with class names like this:

\v1\Monolog\Logger
\v2\Monolog\Logger
\v5\Google\Client
\v7\Google\Client

It feels like there's a lot of complexity in the package manager here - it's got to keep track of which versions of each package are installed, what they depend on, and decide what prefixes need to be used where. You also suggest that one version of each package is left with no prefix, which adds even more complexity.

The Googl\ApiClient of BobDocs is again, up to the autoloader. Assuming it
too is different (since it's using an older Monolog)

The biggest problem comes when this assumption doesn't hold. I actually chose these particular packages to illustrate this problem, then left it out of my previous message. It happens that the latest version of google/apiclient supports both monolog/monolog 2.9 and 3.0, so it's possible to have:

- AlicesCalendar wants to use google/apiclient 2.18 and monolog/monolog 2.9
- BobsDocs wants to use google/apiclient 2.18 and monolog/monolog 3.0

If the package manager is adding prefixes to individual package versions, we will have one class called \v2_18\Google\Client containing our familiar "new Logger" line. AlicesCalendar will expect that line to create a \v2_9\Monolog\Logger, but BobsDocs will expect it to create a \v3_0\Monolog\Logger. We can't please both of them without creating an extra copy of Google\Client with a different prefix.

So the version of an individual package isn't enough to decide the prefix, we need to know which *set* of packages it belongs to.

My suggestion uses a much simpler rule to define the prefix: if it's loaded "inside" AlicesCalendar, add the prefix "\AlicesCalendar\". All the classes that are "inside" are completely sandboxed from the classes "outside", without needing any interaction with a package manager.

As far as I know, this is how existing userland solutions work, and I haven't yet spotted a reason why it needs to be any more complex than that.

Regards,
Rowan Tommins
[IMSoP]

On Sat, May 24, 2025, at 11:34, Rowan Tommins [IMSoP] wrote:

Hi Michael,

I’m going to skip over all the details about the autoloader for now, because I think they’re going deep into implementation details, and I want to focus on the same top-level design as my previous email.

On 23 May 2025 02:27:41 BST, Michael Morris <tendoaki@gmail.com> wrote:

Bobs docs needs an older version of Monolog and is configured appropriately
in its composer.json file, so … v1 is prefixed to the
namespace declarations in Monolog\Logger and the file is included. The
engine aliases BobsDocs\Monolog\Logger to \v1\Monolog\Logger.

If I’m following correctly, you suggest that we would end up with class names like this:

\v1\Monolog\Logger
\v2\Monolog\Logger
\v5\Google\Client
\v7\Google\Client

It feels like there’s a lot of complexity in the package manager here - it’s got to keep track of which versions of each package are installed, what they depend on, and decide what prefixes need to be used where. You also suggest that one version of each package is left with no prefix, which adds even more complexity.

The Googl\ApiClient of BobDocs is again, up to the autoloader. Assuming it
too is different (since it’s using an older Monolog)

The biggest problem comes when this assumption doesn’t hold. I actually chose these particular packages to illustrate this problem, then left it out of my previous message. It happens that the latest version of google/apiclient supports both monolog/monolog 2.9 and 3.0, so it’s possible to have:

  • AlicesCalendar wants to use google/apiclient 2.18 and monolog/monolog 2.9
  • BobsDocs wants to use google/apiclient 2.18 and monolog/monolog 3.0

If the package manager is adding prefixes to individual package versions, we will have one class called \v2_18\Google\Client containing our familiar “new Logger” line. AlicesCalendar will expect that line to create a \v2_9\Monolog\Logger, but BobsDocs will expect it to create a \v3_0\Monolog\Logger. We can’t please both of them without creating an extra copy of Google\Client with a different prefix.

So the version of an individual package isn’t enough to decide the prefix, we need to know which set of packages it belongs to.

My suggestion uses a much simpler rule to define the prefix: if it’s loaded “inside” AlicesCalendar, add the prefix "\AlicesCalendar". All the classes that are “inside” are completely sandboxed from the classes “outside”, without needing any interaction with a package manager.

As far as I know, this is how existing userland solutions work, and I haven’t yet spotted a reason why it needs to be any more complex than that.

Regards,
Rowan Tommins
[IMSoP]

My only concern is how this would be handled in the class tables. Right now, \AlicesCalendar\Monolog\Logger and \BobsDocs\Monolog\Logger would be considered entirely different types – as in, not compatible. So if AlicesCalendar returns a type that BobsDocs expects, they won’t be able to talk to each other.

So, this means we’d need a couple of different types of dependencies:

  1. “direct dependencies” that work in a containerized way
  2. “parent dependencies” that expect a parent to provide the dependency so it can interoperate between packages
    I assume that it will be up to a dependency resolver (either composer or something else) will need to figure out which direct dependencies to “hoist” up and provide a compatible version between the two packages.

That then begs the question of whether this complication is needed at all? I can understand why having a ‘containerized’ package system is useful (in the case of WordPress or plugins in general), but I’m wondering if it is actually needed?

If we look at npm and yarn and how they handle this in the Javascript space, they basically install compatible packages when possible, and only ‘contain’ them when it would introduce an incompatibility.

I have some ideas here, but I need some time to think on it; but I also want to point out the problem to see if anyone else has any ideas.

— Rob

On 24 May 2025 14:11:57 BST, Rob Landers <rob@bottled.codes> wrote:

My only concern is how this would be handled in the class tables. Right now, \AlicesCalendar\Monolog\Logger and \BobsDocs\Monolog\Logger would be considered entirely different types -- as in, not compatible. So if AlicesCalendar returns a type that BobsDocs expects, they won't be able to talk to each other.

Once again, I'd like to use the Linux Container analogy: a process in one container never communicates directly with a process in another container. The process "thinks" it's running as normal, but is actually isolated inside a sandbox. The container then defines the inputs and outputs it wants to open between that sandbox and the host, and something running on the host can wire those up as necessary.

I assume that it will be up to a dependency resolver (either composer or something else) will need to figure out which direct dependencies to "hoist" up and provide a compatible version between the two packages.

I see this as the responsibility of each "container": if AlicesCalendar wants to use an un-sandboxed version of a PSR interface or a framework component, it declares that to the "host" (e.g. WordPress core). The PHP engine then knows to leave that interface name without a prefix. Any other class - whether it's written by Alice or installed by Composer - exists inside the sandbox, and gets a prefix.

Importantly, all of this should happen on the *PHP symbol* level (classes, interfaces, functions); the sandboxing mechanism doesn't need to know about package managers - just as Docker, Kunernetes, etc, don't know about APT / Yum / whatever Apine calls it.

Rowan Tommins
[IMSoP]

On Sat, May 24, 2025, at 23:18, Rowan Tommins [IMSoP] wrote:

On 24 May 2025 14:11:57 BST, Rob Landers <rob@bottled.codes> wrote:

My only concern is how this would be handled in the class tables. Right now, \AlicesCalendar\Monolog\Logger and \BobsDocs\Monolog\Logger would be considered entirely different types – as in, not compatible. So if AlicesCalendar returns a type that BobsDocs expects, they won’t be able to talk to each other.

Once again, I’d like to use the Linux Container analogy: a process in one container never communicates directly with a process in another container. The process “thinks” it’s running as normal, but is actually isolated inside a sandbox. The container then defines the inputs and outputs it wants to open between that sandbox and the host, and something running on the host can wire those up as necessary.

I assume that it will be up to a dependency resolver (either composer or something else) will need to figure out which direct dependencies to “hoist” up and provide a compatible version between the two packages.

I see this as the responsibility of each “container”: if AlicesCalendar wants to use an un-sandboxed version of a PSR interface or a framework component, it declares that to the “host” (e.g. WordPress core). The PHP engine then knows to leave that interface name without a prefix. Any other class - whether it’s written by Alice or installed by Composer - exists inside the sandbox, and gets a prefix.

Importantly, all of this should happen on the PHP symbol level (classes, interfaces, functions); the sandboxing mechanism doesn’t need to know about package managers - just as Docker, Kunernetes, etc, don’t know about APT / Yum / whatever Apine calls it.

Rowan Tommins
[IMSoP]

Yes, that aligns with what I was thinking too, for the most part.

Here are my thoughts, but first some vocabulary:

  • direct dependency: a package that is used by the current package
  • exported dependency: a direct dependency that can be used outside the current package
  • peer dependency: an indirect dependency on another package that isn’t required to function but may offer additional functionality if installed. I have no idea how this would be defined or used yet.
  • package: the physical artefact containing one or more modules.

Thinking back on several of my implementation explorations of nested classes, it should be possible to identify if a dependency/class is able to be used outside the package during compilation. So, I don’t think we need the user to explicitly state an exported dependency. In other words (and making up some syntax):

use module AlicesCalendar;
use module BobsDocs;

// AlicesCalendar needs to be “exposed” outside the package
public function doSomething(\AlicesCalendar\Week $week) { /* do stuff */ }

// BobsDocs remains an internal-only dependency
private function otherSomething(\BobsDocs\Doc $doc) { /* do stuff */ }

When compiling, we will see a public function “exposing” a direct dependency. Thus we would know that the current module would need to export the direct dependency on AlicesCalendar. This would prevent the developer from having to keep track of all the dependencies that need to be exported. From a developer’s point of view, they would use it like normal.

So, you could imagine a generated package manifest might look something like this:

{
“rootNamespace”: “OfficeSuite”
“dependencies”: {
“AlicesCalendar”: “^1.0.0”,
“BobsDocs”: “^0.1.0”
},
“exports”: {
“dependencies”: [“AlicesCalendar”],
“functions”: [“OfficeSuite\doSomething”],
“classes”:
}
}

A package manager (IDE, or even a human) could then surmise what packages they need to be shared between modules. It would also know that BobsDocs is entirely private to the module, so it doesn’t need to be compatible with any other module using BobsDocs.

Anyway, this is a bit into the weeds, but I want to point out what is possible or not, based on my experience working on nested classes. In other words, I’m 99% sure we can infer exported dependencies without requiring a human to manually do the work; what that actually looks like in practice is still very much in the air. So, please don’t take the above as an actual proposal, but as inspiration.

— Rob

On Sat, May 24, 2025, at 4:18 PM, Rowan Tommins [IMSoP] wrote:

I assume that it will be up to a dependency resolver (either composer or something else) will need to figure out which direct dependencies to "hoist" up and provide a compatible version between the two packages.

I see this as the responsibility of each "container": if AlicesCalendar
wants to use an un-sandboxed version of a PSR interface or a framework
component, it declares that to the "host" (e.g. WordPress core). The
PHP engine then knows to leave that interface name without a prefix.
Any other class - whether it's written by Alice or installed by
Composer - exists inside the sandbox, and gets a prefix.

Importantly, all of this should happen on the *PHP symbol* level
(classes, interfaces, functions); the sandboxing mechanism doesn't need
to know about package managers - just as Docker, Kunernetes, etc, don't
know about APT / Yum / whatever Apine calls it.

Rowan Tommins
[IMSoP]

This is where I'm not clear. Why wouldn't it need a concept of package/module/thing?

Even if we develop some way such that in Foo.php, loading the class \Beep\Boop\Narf pulls from /beep/boop/v1/Narf.php and loading it from Bar.php pulls the same class from /beep/boop/v2/Narf.php, and does something or other to keep the symbols separate... Narf itself is going to load \Beep\Boop\Poink at some point. So which one does it get? Or rather, there's now two Narfs. How do they know that the v1 version of Narf should get the v1 version of Poink and the v2 version should get the v2 version.

If there was a package/module/cluster/thing concept, then it would be easy enough (at least in concept) to extend whatever translation logic exists to the rest of that package/module/cluster/thing. Without that, however, I don't know how that transitive class usage would be addressed.

--Larry Garfield

On 25/05/2025 09:27, Rob Landers wrote:

Here are my thoughts, but first some vocabulary:
- direct dependency: a package that is used by the current package
- exported dependency: a direct dependency that can be used outside the current package
- peer dependency: an indirect dependency on another package that isn’t required to function but may offer additional functionality if installed. I have no idea how this would be defined or used yet.
- package: the physical artefact containing one or more modules.

It seems my repeated suggestions of "container" or "sandbox" as the key concept aren't being picked up. I'm still not sure if people disagree with my reasoning, or just don't understand the distinction I'm trying to make.

A key point I want to reiterate is that I think 99% of PHP applications should not be using the feature we're talking about here. Most of the time, having a package manager resolve a set of constraints to a set of versions that are all mutually compatible is emphatically a good thing.

I also think that the "elephpant in the room" here is that any implementation of this sandboxing is going to be imperfect, because it just doesn't fit with PHP's nature. There are too many cases like `return ['\SomeNs\SomeClass', 'someMethod'];` where the compiler won't know that a class name is being used, so won't know to rewrite/alias/whatever to the sandboxed version.

It may actually be that anything we design on this list is doomed to be worse than existing userland implementations, because userland
tools can make specific assumptions about things like the WordPress core which we could never build into the engine.

Thinking back on several of my implementation explorations of nested classes, it should be possible to identify if a dependency/class is able to be used outside the package during compilation.

I think there are many types of "dependency" where you can't automate the decision, e.g.

namespace MyPlugin;
class MyLogger implements \Psr\Log\LogInterface {
public function logWithBellsOn(\DingDong\BellInterface $bell, string $message): \Duolog\Message {
return new \ExtraLoud\Message(new \Helpful\BellAdapter($bell), $message);
}
}

Does that refer to a global \Psr\Log\LogInterface, or a sandboxed \MyPlugin\Psr\Log\LogInterface? Is \ExtraLoud\Message a dependency which should be imported/exported, or a hidden implementation detail? And so on, for every class/interface mentioned.

Only the code's author can say which was intended in each case.

So, you could imagine a generated package manifest might look something like this:

{
"rootNamespace": "OfficeSuite"
"dependencies": {
"AlicesCalendar": "^1.0.0",
"BobsDocs": "^0.1.0"
},
"exports": {
"dependencies": ["AlicesCalendar"],
"functions": ["OfficeSuite\doSomething"],
"classes":
}
}

Just to recap, the example wasn't of an office suite. AlicesCalendar and BobsDocs were supposed to be two unrelated WordPress plugins, which both happened to use Google's SDK. The only application that would know about both of them is WordPress, and it wouldn't need to export anything.

The way I'm picturing it is more like this:

// The bootstrap file in AlicesCalendar will register a directory of files as a "Container"
// Within this Container, class definitions and references are rewritten to use a unique prefix
// The Container also has a separate autoloader stack
Container\register(
     // This prefix is added to all names to make them unique
     // e.g. \Monolog\Logger might become \__Container\AlicesCalendar\Monolog\Logger
     prefix: 'AlicesCalendar',

     // Code in any file in these directories is considered to be "inside" the container
     directories: [
          '/var/www/wordpress/wp-plugins/AlicesCalendar/src',
          // This directory is probably populated by Composer, but the Container logic doesn't care about that
          '/var/www/wordpress/wp-plugins/AlicesCalendar/vendor',
     ],

     // Classes that should be "imported" from outside the Container
     // Classes matching these patterns will not be auto-prefixed
     // If they are not defined before use, the "host" (e.g. WordPress core) autoload stack will be called
     import: [
         // Classes inside the Container can implement the shared definition of LoggerInterface
         '\Psr\Log\LoggerInterface',
         // Classes inside the Container can make use of this namespace of classes defined outside the Container
         '\WordPress\PluginTools\*'
     ],

     // Classes that should be "exported" from inside the Container
     // These will use the autoload stack inside the Container, but will not be auto-prefixed
     export: [
         '\AlicesCalendar\PluginDefinition',
         '\AlicesCalendar\Hooks\*'
     ],
);

// A completely unmodified Composer autoloader is loaded
// Because it's inside the Container, everything it registers will be on a separate stack
require_once '/var/www/wordpress/wp-plugins/AlicesCalendar/vendor/autoload.php';

// The plugin is registered to the application, which doesn't need to know about the Container setup
wp_register_plugin('\AlicesCalendar\PluginDefinition');

// In a completely separate file, BobsDocs does all the same setup
// It lists its own imports and exports, and uses its own unique prefix
// Any relationship between the two plugins happens in the WordPress Core code as usual

The guiding principle is that the code inside the container should need as little modification as possible to be compatible, so that all the code on packagist.org immediately becomes available to whatever plugin wants it.

--
Rowan Tommins
[IMSoP]

On 25/05/2025 21:28, Larry Garfield wrote:

Even if we develop some way such that in Foo.php, loading the class \Beep\Boop\Narf pulls from /beep/boop/v1/Narf.php and loading it from Bar.php pulls the same class from /beep/boop/v2/Narf.php, and does something or other to keep the symbols separate... Narf itself is going to load \Beep\Boop\Poink at some point. So which one does it get? Or rather, there's now two Narfs. How do they know that the v1 version of Narf should get the v1 version of Poink and the v2 version should get the v2 version.

The prefixing, in my mind, has nothing to do with versions. There is no "v1" and "v2" directory, there are just two completely separate "vendor" directories, with the same layout we have right now.

So it goes like this:

1. Some code in wp-plugins/AlicesCalendar/vendor/Beep/Boop/Narf.php mentions a class called \Beep\Boop\Poink
2. The Container mechanism has rewritten this to \__Container\AlicesCalendar\Beep\Boop\Poink, but that isn't defined yet
3. The isolated autoloader stack (loaded from wp-plugins/AlicesCalendar/vendor/autoload.php) is asked for the original name, \Beep\Boop\Poink
4. It includes the file wp-plugins/AlicesCalendar/vendor/Beep/Boop/Poink.php which contains the defintion of \Beep\Boop\Poink
5. The Container mechanism rewrites the class to \__Container\AlicesCalendar\Beep\Boop\Poink and carries on

When code in wp-plugins/BobsDocs/vendor/Beep/Boop/Narf.php mentions \Beep\Boop\Poink, the same thing happens, but with a completely separate sandbox: the rewritten class name is \__Container\BobsDocs\Beep\Boop\Poink, and the autoloader was loaded from wp-plugins/BobsDocs/vendor/autoload.php

Unless explciitly specified as an "import" or "export", any reference to any class name is prefixed in the same way, and loaded with the isolated autoloader stack. To the host application, and any other plugins, the code inside the "wp-plugins/AlicesCalendar/vendor" and "wp-plugins/BobsDocs/vendor" directories is entirely hidden.

--
Rowan Tommins
[IMSoP]

Hey all,

It took me a while, but I'm finally caught up with this thread, and would like to give my 2 cents.

On 25 May 2025, at 23:17, Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> wrote:

On 25/05/2025 21:28, Larry Garfield wrote:

Even if we develop some way such that in Foo.php, loading the class \Beep\Boop\Narf pulls from /beep/boop/v1/Narf.php and loading it from Bar.php pulls the same class from /beep/boop/v2/Narf.php, and does something or other to keep the symbols separate... Narf itself is going to load \Beep\Boop\Poink at some point. So which one does it get? Or rather, there's now two Narfs. How do they know that the v1 version of Narf should get the v1 version of Poink and the v2 version should get the v2 version.

The prefixing, in my mind, has nothing to do with versions. There is no "v1" and "v2" directory, there are just two completely separate "vendor" directories, with the same layout we have right now.

So it goes like this:

1. Some code in wp-plugins/AlicesCalendar/vendor/Beep/Boop/Narf.php mentions a class called \Beep\Boop\Poink
2. The Container mechanism has rewritten this to \__Container\AlicesCalendar\Beep\Boop\Poink, but that isn't defined yet
3. The isolated autoloader stack (loaded from wp-plugins/AlicesCalendar/vendor/autoload.php) is asked for the original name, \Beep\Boop\Poink
4. It includes the file wp-plugins/AlicesCalendar/vendor/Beep/Boop/Poink.php which contains the defintion of \Beep\Boop\Poink
5. The Container mechanism rewrites the class to \__Container\AlicesCalendar\Beep\Boop\Poink and carries on

When code in wp-plugins/BobsDocs/vendor/Beep/Boop/Narf.php mentions \Beep\Boop\Poink, the same thing happens, but with a completely separate sandbox: the rewritten class name is \__Container\BobsDocs\Beep\Boop\Poink, and the autoloader was loaded from wp-plugins/BobsDocs/vendor/autoload.php

In this thread I see a lot of talking about Composer and autoloaders. But in essence those are just tools we use to include files into our current PHP process, so for the sake of simplicity (and compatibility), let's disregard all of that for a moment. Instead, please bear with me while we do a little gedankenexperiment...

First, imagine one gigantic PHP file, `huge.php`, that contains all the PHP code that is included from all the libraries you need during a single PHP process lifecycle. That is in the crudest essence how PHP's include system currently works: files get included, those files declare symbols within the scope of the current process. If you were to copy-paste all the code you need (disregarding the `declare()` statements) in one huge PHP file, you essentially get the same result.

So in our thought experiment we'll be doing just that. The only rule is that we copy all the code verbatim (again, disregarding the `declare()` statements), because that's how PHP includes also work.

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar
{
    public readonly Foo $foo;
    public function __construct()
    {
        // For some reason, there is a string reference here. Don't ask.
        $fooClass = '\Acme\Foo';
        $this->foo = new $fooClass();
    }
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

namespace Spam;
use Acme\Bar;
class Bacon extends Bar {}

// ...

Now, the problem here is that if we copy-paste two different versions of the same class with the same FQN into our `huge.php` file they will try to declare the same symbols which will cause a conflict. Let's say our `Ham` depends on one version of `Acme\Bar` and our `Bacon` depends on another version of `Acme\Bar`:

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar {
    public readonly Foo $foo;
    public function __construct()
    {
        // For some reason, there is a string reference here. Don't ask.
        $fooClass = '\Acme\Foo';
        $this->foo = new $fooClass();
    }
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

namespace Acme;
class Foo {}  // Fatal error: Cannot declare class Foo, because the name is already in use

namespace Acme;
class Bar {   // Fatal error: Cannot declare class Bar, because the name is already in use
    public readonly Foo $foo;
    public function __construct()
    {
        // For some reason, there is a string reference here. Don't ask.
        $fooClass = '\Acme\Foo';
        $this->foo = new $fooClass();
    }
}

namespace Spam;
use Acme\Bar;
class Bacon extends Bar {}

// ...

So how do we solve this in a way that we can copy-paste the code from both versions of `Acme\Foo`, verbatim into `huge.php`?

Well, one way is to break the single rule we have created: modify the code. What if we just let the engine quietly rewrite the code? Well, then we quickly run into an issue. Any non-symbol references to classes are hard to detect and rewrite, so this would break:

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar {
    public readonly Foo $foo;
    public function __construct()
    {
        // For some reason, there is a string reference here. Don't ask.
        $fooClass = '\Acme\Foo';
        $this->foo = new $fooClass();
    }
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

namespace Spam\Bacon\Acme;        // Quietly rewritten
class Foo {}

namespace Spam\Bacon\Acme;        // Quietly rewritten
class Bar {
    public readonly Foo $foo;
    public function __construct()
    {
        // For some reason, there is a string reference here. Don't ask.
        $fooClass = '\Acme\Foo';  // <== Whoops, missed this one!!!
        $this->foo = new $fooClass();
    }
}

namespace Spam;
use Spam\Bacon\Acme\Bar;          // Quietly rewritten
class Bacon extends Bar {}

// ...

So let's just follow our rule for now. Now how do we include Foo and Bar twice? Well, let's try Rowan's approach of "containerizing." Let's take a very naive approach to what that syntax might look like. We simply copy-paste the code for our second version of `Acme\Bar` into the scope of a container. For the moment let's assume that the container works like a "UnionFS" of sorts, where symbols declared inside the container override any symbols that may already exist outside the container:

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar {
    public readonly Foo $foo;
    public function __construct()
    {
        // For some reason, there is a string reference here. Don't ask.
        $fooClass = '\Acme\Foo';
        $this->foo = new $fooClass();
    }
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

container Bacon_Acme {
    namespace Acme;
    class Foo {}
    
    namespace Acme;
    class Bar {
        public readonly Foo $foo;
        public function __construct()
        {
            // For some reason, there is a string reference here. Don't ask.
            $fooClass = '\Acme\Foo';
            $this->foo = new $fooClass();
        }
    }
}

namespace Spam;
use Bacon_Acme\\Acme\Bar;
class Bacon extends Bar {}

// ...

That seems like it could work. As you can see, I've decided to use double backspace (`\\`) to separate container and namespace in this example. You may wonder how this would look in the real world, where not all code is copy-pasted into a single `huge.php`. Of course, part of this depends on the autoloader implementation, but let's start with a first step of abstraction by replacing the copy-pasted code with includes:


// ...

require_once '../vendor/acme/acme/Foo.php';
require_once '../vendor/acme/acme/Bar.php';

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

container Bacon_Acme {
    require_once '../lib/acme/acme/Foo.php';
    require_once '../lib/acme/acme/Bar.php';
}

namespace Spam;
use Bacon_Acme\\Acme\Bar;
class Bacon extends Bar {}

Now what if we want the autoloader to be able to resolve this? Well, once a class symbol is resolved with a container prefix, it would have to also perform all its includes inside the scope of that container.

function autoload($class_name) {
    // Do autoloading shizzle.
}

spl_autoload_register();

namespace Spam;
use Bacon_Acme\\Acme\Bar;
class Bacon extends Bar {}

    // Meanwhile, behind the scenes, in the autoloader: 
    container Bacon_acme {
        autoload(Acme\Bar::class);
    }

Now this mail is already quite long enough, so I'm gonna wrap it up here and do some more brainstorming. But I hope I may have inspired some of you. All in all, I think my approach might actually work, although I haven't even considered what the implementation would even look like.

Again, the main point I want to make is to just disregard composer and the autoloader for now; those are just really fancy wrappers around import statements. Whatever solution we end up with would have to work independently of Composer and/or the autoloader.

Alwin

On Mon, May 26, 2025, at 21:39, Alwin Garside wrote:

Hey all,

It took me a while, but I’m finally caught up with this thread, and would like to give my 2 cents.

On 25 May 2025, at 23:17, Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> wrote:

On 25/05/2025 21:28, Larry Garfield wrote:

Even if we develop some way such that in Foo.php, loading the class \Beep\Boop\Narf pulls from /beep/boop/v1/Narf.php and loading it from Bar.php pulls the same class from /beep/boop/v2/Narf.php, and does something or other to keep the symbols separate… Narf itself is going to load \Beep\Boop\Poink at some point. So which one does it get? Or rather, there’s now two Narfs. How do they know that the v1 version of Narf should get the v1 version of Poink and the v2 version should get the v2 version.

The prefixing, in my mind, has nothing to do with versions. There is no “v1” and “v2” directory, there are just two completely separate “vendor” directories, with the same layout we have right now.

So it goes like this:

  1. Some code in wp-plugins/AlicesCalendar/vendor/Beep/Boop/Narf.php mentions a class called \Beep\Boop\Poink
  2. The Container mechanism has rewritten this to __Container\AlicesCalendar\Beep\Boop\Poink, but that isn’t defined yet
  3. The isolated autoloader stack (loaded from wp-plugins/AlicesCalendar/vendor/autoload.php) is asked for the original name, \Beep\Boop\Poink
  4. It includes the file wp-plugins/AlicesCalendar/vendor/Beep/Boop/Poink.php which contains the defintion of \Beep\Boop\Poink
  5. The Container mechanism rewrites the class to __Container\AlicesCalendar\Beep\Boop\Poink and carries on

When code in wp-plugins/BobsDocs/vendor/Beep/Boop/Narf.php mentions \Beep\Boop\Poink, the same thing happens, but with a completely separate sandbox: the rewritten class name is __Container\BobsDocs\Beep\Boop\Poink, and the autoloader was loaded from wp-plugins/BobsDocs/vendor/autoload.php

In this thread I see a lot of talking about Composer and autoloaders. But in essence those are just tools we use to include files into our current PHP process, so for the sake of simplicity (and compatibility), let’s disregard all of that for a moment. Instead, please bear with me while we do a little gedankenexperiment…

First, imagine one gigantic PHP file, huge.php, that contains all the PHP code that is included from all the libraries you need during a single PHP process lifecycle. That is in the crudest essence how PHP’s include system currently works: files get included, those files declare symbols within the scope of the current process. If you were to copy-paste all the code you need (disregarding the declare() statements) in one huge PHP file, you essentially get the same result.

So in our thought experiment we’ll be doing just that. The only rule is that we copy all the code verbatim (again, disregarding the declare() statements), because that’s how PHP includes also work.

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar
{
public readonly Foo $foo;
public function __construct()
{
// For some reason, there is a string reference here. Don't ask.
$fooClass = '\Acme\Foo';
$this->foo = new $fooClass();
}
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

namespace Spam;
use Acme\Bar;
class Bacon extends Bar {}

// ...

Now, the problem here is that if we copy-paste two different versions of the same class with the same FQN into our huge.php file they will try to declare the same symbols which will cause a conflict. Let’s say our Ham depends on one version of Acme\Bar and our Bacon depends on another version of Acme\Bar:

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar {
public readonly Foo $foo;
public function __construct()
{
// For some reason, there is a string reference here. Don't ask.
$fooClass = '\Acme\Foo';
$this->foo = new $fooClass();
}
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

namespace Acme;
class Foo {} // Fatal error: Cannot declare class Foo, because the name is already in use

namespace Acme;
class Bar { // Fatal error: Cannot declare class Bar, because the name is already in use
public readonly Foo $foo;
public function __construct()
{
// For some reason, there is a string reference here. Don't ask.
$fooClass = '\Acme\Foo';
$this->foo = new $fooClass();
}
}

namespace Spam;
use Acme\Bar;
class Bacon extends Bar {}

// ...

So how do we solve this in a way that we can copy-paste the code from both versions of Acme\Foo, verbatim into huge.php?

Well, one way is to break the single rule we have created: modify the code. What if we just let the engine quietly rewrite the code? Well, then we quickly run into an issue. Any non-symbol references to classes are hard to detect and rewrite, so this would break:

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar {
public readonly Foo $foo;
public function __construct()
{
// For some reason, there is a string reference here. Don't ask.
$fooClass = '\Acme\Foo';
$this->foo = new $fooClass();
}
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

namespace Spam\Bacon\Acme; // Quietly rewritten
class Foo {}

namespace Spam\Bacon\Acme; // Quietly rewritten
class Bar {
public readonly Foo $foo;
public function __construct()
{
// For some reason, there is a string reference here. Don't ask.
$fooClass = '\Acme\Foo'; // <== Whoops, missed this one!!!
$this->foo = new $fooClass();
}
}

namespace Spam;
use Spam\Bacon\Acme\Bar; // Quietly rewritten
class Bacon extends Bar {}

// ...

So let’s just follow our rule for now. Now how do we include Foo and Bar twice? Well, let’s try Rowan’s approach of “containerizing.” Let’s take a very naive approach to what that syntax might look like. We simply copy-paste the code for our second version of Acme\Bar into the scope of a container. For the moment let’s assume that the container works like a “UnionFS” of sorts, where symbols declared inside the container override any symbols that may already exist outside the container:

<?php

// ...

namespace Acme;
class Foo {}

namespace Acme;
class Bar {
public readonly Foo $foo;
public function __construct()
{
// For some reason, there is a string reference here. Don't ask.
$fooClass = '\Acme\Foo';
$this->foo = new $fooClass();
}
}

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

container Bacon_Acme {
namespace Acme;
class Foo {}

namespace Acme;
class Bar {
public readonly Foo $foo;
public function __construct()
{
// For some reason, there is a string reference here. Don't ask.
$fooClass = '\Acme\Foo';
$this->foo = new $fooClass();
}
}
}

namespace Spam;
use Bacon_Acme\\Acme\Bar;
class Bacon extends Bar {}

// ...

That seems like it could work. As you can see, I’ve decided to use double backspace (\\) to separate container and namespace in this example. You may wonder how this would look in the real world, where not all code is copy-pasted into a single huge.php. Of course, part of this depends on the autoloader implementation, but let’s start with a first step of abstraction by replacing the copy-pasted code with includes:


// ...

require_once '../vendor/acme/acme/Foo.php';
require_once '../vendor/acme/acme/Bar.php';

namespace Spam;
use Acme\Bar;
class Ham extends Bar {}

container Bacon_Acme {
require_once '../lib/acme/acme/Foo.php';
require_once '../lib/acme/acme/Bar.php';
}

namespace Spam;
use Bacon_Acme\\Acme\Bar;
class Bacon extends Bar {}

Now what if we want the autoloader to be able to resolve this? Well, once a class symbol is resolved with a container prefix, it would have to also perform all its includes inside the scope of that container.

function autoload($class_name) {
// Do autoloading shizzle.
}

spl_autoload_register();

namespace Spam;
use Bacon_Acme\\Acme\Bar;
class Bacon extends Bar {}

// Meanwhile, behind the scenes, in the autoloader:
container Bacon_acme {
autoload(Acme\Bar::class);
}

Now this mail is already quite long enough, so I’m gonna wrap it up here and do some more brainstorming. But I hope I may have inspired some of you. All in all, I think my approach might actually work, although I haven’t even considered what the implementation would even look like.

Again, the main point I want to make is to just disregard composer and the autoloader for now; those are just really fancy wrappers around import statements. Whatever solution we end up with would have to work independently of Composer and/or the autoloader.

Alwin

I’m starting to think that maybe modules might be a bad idea; or at least, class/module visibility.

As an anecdote, I was looking to extract a protobuf encoding library from a larger codebase and create a separate library for Larry’s Serde library. During the extraction I realized that many of the classes and functions I was relying on actually used @internal classes/functions. If “module” visibility were a thing… would my implementation have been possible?

In other words, if visibility comes with modules; there really needs to be some kind of escape hatch. Some way to say, “I know what I’m doing, so get out of my way.”

In Java, you can do this by creating a namespaced class in the target namespace. I’m not sure about other languages; but it’s something to think about.

— Rob

On 30 May 2025 08:57:34 BST, Rob Landers <rob@bottled.codes> wrote:

I’m starting to think that maybe modules might be a bad idea; or at least, class/module visibility.

As an anecdote, I was looking to extract a protobuf encoding library from a larger codebase and create a separate library for Larry’s Serde library. During the extraction I realized that many of the classes and functions I was relying on actually used @internal classes/functions. If “module” visibility were a thing… would my implementation have been possible?

In other words, if visibility comes with modules; there really needs to be some kind of escape hatch. Some way to say, “I know what I’m doing, so get out of my way.”

Isn't this exactly the same as any other "access control" feature?

We have private/protected methods and properties, final methods and classes, readonly properties; other languages also have sealed classes, module and/or file private, etc. All of these are ways for the author of the code to express how they intend it to be used, and to protect users against *accidentally* violating assumptions the code is relying on.

They are of course not *security* measures, as they can be bypassed via Reflection or just editing the source code.

If you're using someone else's code in ways they didn't intend, that's up to you, but you may need to make changes to do so, i.e. fork it rather than relying on the distributed version.

In your example, the author clearly marked that those classes were internal implementation details; if you use them directly and later update the library, you risk your code breaking either completely or subtly. If you copy them into your own codebase, you are free to remove the "@internal" annotations, or future "module private" declarations, and make whatever other changes are needed to suit your use case.

Regards,
Rowan Tommins
[IMSoP]

On 30 May 2025, at 18:38, Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> wrote:

On 30 May 2025 08:57:34 BST, Rob Landers <rob@bottled.codes> wrote:

I’m starting to think that maybe modules might be a bad idea; or at least, class/module visibility.

As an anecdote, I was looking to extract a protobuf encoding library from a larger codebase and create a separate library for Larry’s Serde library. During the extraction I realized that many of the classes and functions I was relying on actually used @internal classes/functions. If “module” visibility were a thing… would my implementation have been possible?

In other words, if visibility comes with modules; there really needs to be some kind of escape hatch. Some way to say, “I know what I’m doing, so get out of my way.”

Isn't this exactly the same as any other "access control" feature?

We have private/protected methods and properties, final methods and classes, readonly properties; other languages also have sealed classes, module and/or file private, etc. All of these are ways for the author of the code to express how they intend it to be used, and to protect users against *accidentally* violating assumptions the code is relying on.

They are of course not *security* measures, as they can be bypassed via Reflection or just editing the source code.

If you're using someone else's code in ways they didn't intend, that's up to you, but you may need to make changes to do so, i.e. fork it rather than relying on the distributed version.

If the goal is to hint consumers of a library about the (lack of) guarantees regarding a method or its signature, then perhaps an `#[\Internal]` attribute makes sense.

namespace Acme\Foo;

class Foo
{
    #[\Internal('Acme')]
    public function bar() { /* ... */ }
}

In the example above, I image calling or extending the `Foo::bar()` method from somewhere outside the `Acme` namespace would trigger an E_USER_WARNING or E_USER_NOTICE. The warning/notice could then be suppressed when explicitly overriding an `#[\Internal]` method with `#[\Override]`.

Alwin

On 30 May 2025 19:21:08 BST, Alwin Garside <alwin@garsi.de> wrote:

In the example above, I image calling or extending the `Foo::bar()` method from somewhere outside the `Acme` namespace would trigger an E_USER_WARNING or E_USER_NOTICE. The warning/notice could then be suppressed when explicitly overriding an `#[\Internal]` method with `#[\Override]`.

I don't see any reason for the message to be any quieter or easier to override than calling a private method.

Indeed, one use of an internal/module-private feature would be when the author wants to split up a class that has a large number of private methods: the new classes need to be able to talk to each other, so the existing "private" keyword is no longer appropriate, but the intended surface to users of the library has not changed. The user is making exactly the same decision by ignoring the "internal" flag as they are if they use reflection or code-rewriting to ignore/remove the "private" flag.

Rowan Tommins
[IMSoP]

On 30 May 2025, at 21:29, Rowan Tommins [IMSoP] <imsop.php@rwec.co.uk> wrote:

On 30 May 2025 19:21:08 BST, Alwin Garside <alwin@garsi.de> wrote:

In the example above, I image calling or extending the `Foo::bar()` method from somewhere outside the `Acme` namespace would trigger an E_USER_WARNING or E_USER_NOTICE. The warning/notice could then be suppressed when explicitly overriding an `#[\Internal]` method with `#[\Override]`.

I don't see any reason for the message to be any quieter or easier to override than calling a private method.

If there were a dedicated `internal` modifier keyword, sure. However if one simply wants to advertise clearly to other developers that they should expect the code to break anytime using an attribute, I feel a compile-time critical error is a bit too strict.

Indeed, one use of an internal/module-private feature would be when the author wants to split up a class that has a large number of private methods: the new classes need to be able to talk to each other, so the existing "private" keyword is no longer appropriate, but the intended surface to users of the library has not changed. The user is making exactly the same decision by ignoring the "internal" flag as they are if they use reflection or code-rewriting to ignore/remove the "private" flag.

I understand your point, but playing devil's advocate for a minute here: I'd argue that if a class reaches the point where it has too many private methods, something has clearly gone wrong in the architecture or abstraction of that class. It probably means part of the functionality needs to be hoisted out to a separate class with its own self-contained, stable interface.

One could also argue that if the author really wishes to break private methods up across multiple classes, they could also use reflection (or preferably: a method call wrapped in a closure bound to the target class) to access their own private methods.

My experience with module-level visibility out in the wild (mostly in Java), has mostly been libraries where the authors apparently couldn't be bothered to dedicate to a stable interface for more specific implementations of certain template-pattern adapters and the like – the Android source code is full of this.

[rant]
I specifically remember dealing with a DB adapter interface and accompanying abstract class, along with implementations for several DBMSs. There was an implementation for the specific DBMS I wanted to use, however, per the interface, it was completely tied into a separate pagination construction which led to a leaky abstraction, and was badly optimized for my use case. I just needed to override one or two methods to make it work for me, but the platform visibility on those methods meant I had to copy-paste both the abstract class and the adapter class to fix the functionality, giving me more code to maintain in the process.
I think this is a good example where package/module-level visibility makes developers complacent, and not care about offering a library that is easy to extend or encapsulate.
[/rant]

Alwin

Ok, the conversation is getting sidetracked, but I think some progress is being made.

I started this latest iteration last year with a thread about introducing something similar to the ES module system of JavaScript to PHP. What attracts me to this particular model is that it should already be familiar to the vast majority of PHP users. Prior to ES modules browsers had no natural module import mechanic. Prior to ES modules all symbols were attached to the window. You can see this if you serve open this index.html from a server (Note that opening the file locally will result in the js being blocked by modern browser security. )

<!DOCTYPE html>
<html>
<head>
<script>
var a = 1234
</script>
</head>
<body>
<script>
console.log(a)
console.log(window.a)
</script>
</body>
</html>

The above spits 1234 into the console twice. Second example - let’s put a module in.

<!DOCTYPE html>
<html>
<head>
<script>
var a = 1234
</script>
<script type="module">
const a = 5678
var b = 9123
</script>
</head>
<body>
<script>
console.log(a)
console.log(window.a)
console.log(b)
</script>
</body>
</html>

This outputs 1234 twice and an error is raised about b being undefined.

I bring the above up to demonstrate that is the desired behavior of what I originally called a PHP module and have been bullied over and taken to task about not understanding the meaning of “module”. Rowain seems to be more comfortable characterizing this as containers. If everyone is happy with that term I really don’t care - I just want a way to isolate a code block so that whatever happens in there stays in there unless I explicitly export it out, and the only way I see things in that scope is if I bring them in.

The other thing that was done with ES is that the syntax for the modules was tightened. JavaScripters cannot dictate what browser a user chooses, so the bad decisions of the early days of JS never really went away until ES came along which enforced their strict mode by default. PHP has no such strict mode - it has a strict types mode but that isn’t the same thing. There are multiple behaviors in PHP that can’t go away because of backwards compatibility problems, and one of those might indeed be how namespaces are handled. In PHP a namespace is just a compile shortcut for resolving symbol names. The namespace is prefixed to the start of every symbol within it. Unlike Java or C#, PHP has no concept of namespace visibility. At the end of the day it’s a shortcut and its implementation happens entirely at compile time.

Previously in the discussion Alwin Garside made a long but insightful post on namespaces and their workings that I’ve been thinking on and trying to digest for the last several days. What I’ve arrived at is the discussions about composer and autoloaders are indeed a red herring to the discussion. At the end of the day, PHP’s include statements are a means to separate the php process into multiple files. In his email he explored some of the rewriting that could be done, and myself and Rowain have also explored this in the form of namespace pathing and aliasing.

We’ve gotten away from the original focus of containing this code and how that would work. So once again this moron is going to take a stab at it.

Container modules are created with require_module(‘file/path’). All code that executes as a result of this call is isolated to its container. That includes the results of any require or include calls made by the module file itself or any file it requires.

Since the module file is cordoned off to its own container from the rest of the application whatever namespaces it uses are irrelevant to outside code. Any symbols created in the module will not be established in the script that made the require_module() call. Since it is coming into being with a new require mechanism it could be subjected to more efficient parsing rules if that is desired, but that’s a massive can of worms for later discussion. One of those will be necessary - it will need to return something to the php code that called it. The simplest way to go about this is to just require that it have a return. So…

$myModule = require_module(‘file/path’);

or perhaps

const myModule = require_module(‘file/path’);

The module probably should return a static class or class instance, but it could return a closure. In JavaScript the dynamic import() statement returns a module object that is most similar to PHP’s static classes, with each export being a member or method of the module object.

Circling back to a question I know will be asked - what about autoloaders? To which I answer, what about them? If the module wants to use an autoloader it has to require one just as the initial php file that required it had to have done at some point. The container module is for all intents and purposes its own php process that returns some interface to allow it to talk to the process that spawned it.

Will this work? I think yes. Will it be efficient? Hell no. Can it be optimized somehow? I don’t know.

On Sun, Jun 1, 2025, at 07:26, Michael Morris wrote:

Ok, the conversation is getting sidetracked, but I think some progress is being made.

I started this latest iteration last year with a thread about introducing something similar to the ES module system of JavaScript to PHP. What attracts me to this particular model is that it should already be familiar to the vast majority of PHP users. Prior to ES modules browsers had no natural module import mechanic. Prior to ES modules all symbols were attached to the window. You can see this if you serve open this index.html from a server (Note that opening the file locally will result in the js being blocked by modern browser security. )


<!DOCTYPE html>
<html>
<head>
<script>
var a = 1234
</script>
</head>
<body>
<script>
console.log(a)
console.log(window.a)
</script>
</body>
</html>

The above spits 1234 into the console twice. Second example - let’s put a module in.


<!DOCTYPE html>
<html>
<head>
<script>
var a = 1234
</script>
<script type="module">
const a = 5678
var b = 9123
</script>
</head>
<body>
<script>
console.log(a)
console.log(window.a)
console.log(b)
</script>
</body>
</html>

This outputs 1234 twice and an error is raised about b being undefined.

I bring the above up to demonstrate that is the desired behavior of what I originally called a PHP module and have been bullied over and taken to task about not understanding the meaning of “module”. Rowain seems to be more comfortable characterizing this as containers. If everyone is happy with that term I really don’t care - I just want a way to isolate a code block so that whatever happens in there stays in there unless I explicitly export it out, and the only way I see things in that scope is if I bring them in.

The other thing that was done with ES is that the syntax for the modules was tightened. JavaScripters cannot dictate what browser a user chooses, so the bad decisions of the early days of JS never really went away until ES came along which enforced their strict mode by default. PHP has no such strict mode - it has a strict types mode but that isn’t the same thing. There are multiple behaviors in PHP that can’t go away because of backwards compatibility problems, and one of those might indeed be how namespaces are handled. In PHP a namespace is just a compile shortcut for resolving symbol names. The namespace is prefixed to the start of every symbol within it. Unlike Java or C#, PHP has no concept of namespace visibility. At the end of the day it’s a shortcut and its implementation happens entirely at compile time.

Previously in the discussion Alwin Garside made a long but insightful post on namespaces and their workings that I’ve been thinking on and trying to digest for the last several days. What I’ve arrived at is the discussions about composer and autoloaders are indeed a red herring to the discussion. At the end of the day, PHP’s include statements are a means to separate the php process into multiple files. In his email he explored some of the rewriting that could be done, and myself and Rowain have also explored this in the form of namespace pathing and aliasing.

We’ve gotten away from the original focus of containing this code and how that would work. So once again this moron is going to take a stab at it.

Container modules are created with require_module(‘file/path’). All code that executes as a result of this call is isolated to its container. That includes the results of any require or include calls made by the module file itself or any file it requires.

Since the module file is cordoned off to its own container from the rest of the application whatever namespaces it uses are irrelevant to outside code. Any symbols created in the module will not be established in the script that made the require_module() call. Since it is coming into being with a new require mechanism it could be subjected to more efficient parsing rules if that is desired, but that’s a massive can of worms for later discussion. One of those will be necessary - it will need to return something to the php code that called it. The simplest way to go about this is to just require that it have a return. So…

$myModule = require_module(‘file/path’);

or perhaps

const myModule = require_module(‘file/path’);

The module probably should return a static class or class instance, but it could return a closure. In JavaScript the dynamic import() statement returns a module object that is most similar to PHP’s static classes, with each export being a member or method of the module object.

Circling back to a question I know will be asked - what about autoloaders? To which I answer, what about them? If the module wants to use an autoloader it has to require one just as the initial php file that required it had to have done at some point. The container module is for all intents and purposes its own php process that returns some interface to allow it to talk to the process that spawned it.

Will this work? I think yes. Will it be efficient? Hell no. Can it be optimized somehow? I don’t know.

This could work! I have a couple of critiques, but they aren’t negative:

I think I like it. It might be worth pointing out that JavaScript “hoists” the imports to file-level during compilation — even if you have the import statement buried deep in a function call. Or, at least it used to. I haven’t kept track of the language that well in the last 10 years, so I wouldn’t be surprised if it changed; or didn’t. I don’t think this is something we need to worry about too much here.

It’s also worth pointing out that when PHP compiles a file, every file has either an explicit or implicit return. https://www.php.net/manual/en/function.include.php#:~:text=Handling%20Returns%3A,from%20included%20files.

So, in other words, what is it about require_module that is different from require or include? Personally, I would then change PHP from “compile file” mode when parsing the file to “compile module” mode. From a totally naive point-of-view, this would cause PHP to:

  1. if we already have a module from that file; return the module instead of compiling it again.
  2. swap out symbol tables to the module’s symbol table.
  3. start compiling the given file.
  4. concatenate all files as included/required.
  5. compile the resulting huge file.
  6. switch back to the calling symbol table (which may be another module).
  7. return the module.
    For a v1, I wouldn’t allow autoloading from inside a module — or any autoloaded code automatically isn’t considered to be part of the module (it would be the responsibility of the main program to handle autoloading). This is probably something that needs to be solved, but I think it would need a whole new approach to autoloading which should be out of scope for the module RFC (IMHO).

In other words, you can simply include/require a module to load the entire module into your current symbol table; or use require_module to “contain” it.

As for what should a module return? I like your idea of just returning an object or closure.

— Rob

On Sun, Jun 1, 2025, at 09:17, Rob Landers wrote:

On Sun, Jun 1, 2025, at 07:26, Michael Morris wrote:

Ok, the conversation is getting sidetracked, but I think some progress is being made.

I started this latest iteration last year with a thread about introducing something similar to the ES module system of JavaScript to PHP. What attracts me to this particular model is that it should already be familiar to the vast majority of PHP users. Prior to ES modules browsers had no natural module import mechanic. Prior to ES modules all symbols were attached to the window. You can see this if you serve open this index.html from a server (Note that opening the file locally will result in the js being blocked by modern browser security. )


<!DOCTYPE html>
<html>
<head>
<script>
var a = 1234
</script>
</head>
<body>
<script>
console.log(a)
console.log(window.a)
</script>
</body>
</html>

The above spits 1234 into the console twice. Second example - let’s put a module in.


<!DOCTYPE html>
<html>
<head>
<script>
var a = 1234
</script>
<script type="module">
const a = 5678
var b = 9123
</script>
</head>
<body>
<script>
console.log(a)
console.log(window.a)
console.log(b)
</script>
</body>
</html>

This outputs 1234 twice and an error is raised about b being undefined.

I bring the above up to demonstrate that is the desired behavior of what I originally called a PHP module and have been bullied over and taken to task about not understanding the meaning of “module”. Rowain seems to be more comfortable characterizing this as containers. If everyone is happy with that term I really don’t care - I just want a way to isolate a code block so that whatever happens in there stays in there unless I explicitly export it out, and the only way I see things in that scope is if I bring them in.

The other thing that was done with ES is that the syntax for the modules was tightened. JavaScripters cannot dictate what browser a user chooses, so the bad decisions of the early days of JS never really went away until ES came along which enforced their strict mode by default. PHP has no such strict mode - it has a strict types mode but that isn’t the same thing. There are multiple behaviors in PHP that can’t go away because of backwards compatibility problems, and one of those might indeed be how namespaces are handled. In PHP a namespace is just a compile shortcut for resolving symbol names. The namespace is prefixed to the start of every symbol within it. Unlike Java or C#, PHP has no concept of namespace visibility. At the end of the day it’s a shortcut and its implementation happens entirely at compile time.

Previously in the discussion Alwin Garside made a long but insightful post on namespaces and their workings that I’ve been thinking on and trying to digest for the last several days. What I’ve arrived at is the discussions about composer and autoloaders are indeed a red herring to the discussion. At the end of the day, PHP’s include statements are a means to separate the php process into multiple files. In his email he explored some of the rewriting that could be done, and myself and Rowain have also explored this in the form of namespace pathing and aliasing.

We’ve gotten away from the original focus of containing this code and how that would work. So once again this moron is going to take a stab at it.

Container modules are created with require_module(‘file/path’). All code that executes as a result of this call is isolated to its container. That includes the results of any require or include calls made by the module file itself or any file it requires.

Since the module file is cordoned off to its own container from the rest of the application whatever namespaces it uses are irrelevant to outside code. Any symbols created in the module will not be established in the script that made the require_module() call. Since it is coming into being with a new require mechanism it could be subjected to more efficient parsing rules if that is desired, but that’s a massive can of worms for later discussion. One of those will be necessary - it will need to return something to the php code that called it. The simplest way to go about this is to just require that it have a return. So…

$myModule = require_module(‘file/path’);

or perhaps

const myModule = require_module(‘file/path’);

The module probably should return a static class or class instance, but it could return a closure. In JavaScript the dynamic import() statement returns a module object that is most similar to PHP’s static classes, with each export being a member or method of the module object.

Circling back to a question I know will be asked - what about autoloaders? To which I answer, what about them? If the module wants to use an autoloader it has to require one just as the initial php file that required it had to have done at some point. The container module is for all intents and purposes its own php process that returns some interface to allow it to talk to the process that spawned it.

Will this work? I think yes. Will it be efficient? Hell no. Can it be optimized somehow? I don’t know.

This could work! I have a couple of critiques, but they aren’t negative:

I think I like it. It might be worth pointing out that JavaScript “hoists” the imports to file-level during compilation — even if you have the import statement buried deep in a function call. Or, at least it used to. I haven’t kept track of the language that well in the last 10 years, so I wouldn’t be surprised if it changed; or didn’t. I don’t think this is something we need to worry about too much here.

It’s also worth pointing out that when PHP compiles a file, every file has either an explicit or implicit return. https://www.php.net/manual/en/function.include.php#:~:text=Handling%20Returns%3A,from%20included%20files.

So, in other words, what is it about require_module that is different from require or include? Personally, I would then change PHP from “compile file” mode when parsing the file to “compile module” mode. From a totally naive point-of-view, this would cause PHP to:

  1. if we already have a module from that file; return the module instead of compiling it again.
  2. swap out symbol tables to the module’s symbol table.
  3. start compiling the given file.
  4. concatenate all files as included/required.
  5. compile the resulting huge file.
  6. switch back to the calling symbol table (which may be another module).
  7. return the module.
    For a v1, I wouldn’t allow autoloading from inside a module — or any autoloaded code automatically isn’t considered to be part of the module (it would be the responsibility of the main program to handle autoloading). This is probably something that needs to be solved, but I think it would need a whole new approach to autoloading which should be out of scope for the module RFC (IMHO).

In other words, you can simply include/require a module to load the entire module into your current symbol table; or use require_module to “contain” it.

As for what should a module return? I like your idea of just returning an object or closure.

— Rob

I just had another thought; sorry about the back-to-back emails. This wouldn’t preclude something like composer (or something else) from being used to handle dependencies, it would just mean that the package manager might export a “Modules” class + constants — we could also write a composer plugin that does just this:

require_once ‘vendor/autoload.php’;

$module = require_module Vendor\Module::MyModule;

where Vendor\Module is a generated and autoloaded class containing consts to the path of the exported module.

— Rob