Skip to content

Composer & Compile-Time Autoloading

PHP is a dynamic language; this comes with advantages and disadvantages. In this article, we'll be focusing on PHP's need for declaring classes during run-time, over and over, the same classes for each request, and how this causes unnecessary overhead.

Architectural Basics

There is a necessary redundancy built in the fabric of PHP. A file containing your class declaration needs to be located, "injected" using an include statement, evaluated, and as, a result, the containing class gets declared and only then can it be used. The composer makes this simpler with its generated autoloading mechanism - it locates and includes the file for you the first time your program attempts to use an undefined class. The overhead is the same, maybe even worse, and it is still happening during run-time in every single request for every single class your program uses. The sneaky part is that this mechanism makes it invisible to the programmer.

Composer, however, forces package authors to abide to a folder structure and to nicely annotate their package with information how to locate the file with a class. See composer's autload for more.

So, how does the compilation help?

Performance Overhead in PHP

First, we'll profile a regular PHP program. I'm using PHP 8.2, the Xdebug extension, fresh Laravel 11, and Visual Studio with the PHP Profiler Tool Window. Just how much time does the program spend autoloading?

Laravel 11 profiler result

The profiling result above depicts a single request to the root of a fresh Laravel 11 installation. ClassLoader::loadClass() is a function registered as SPL autoload:

spl_autoload_register( array($this, 'loadClass') );

Every time an undefined class is used during run-time, this function gets called by PHP, locates the file that's needed, includes it, and then PHP tries to check whether the class is not undefined anymore. As you can see:

  • 453 classes (including interfaces, traits, enums, ...) were loaded using autoload.
  • 49% of request time was spent autoloading.
  • 9% for initializing the autoload map and for including static files (always loaded files - there's about 30 of them).

This happens during every request.

PeachPie Compiler and Runtime

PeachPie is a compiler and a whole runtime for PHP programs. It compiles everything it can to MSIL byte-code which runs on .NET. It runs with the help of supporting runtime libraries. For example, PHP's echo statement is compiled to a call to C# method Context.Echo(string value), which is implemented in the Peachpie.Runtime.dll.

The compiler also resolves used classes and functions, only once, in compile-time. So the emitted MSIL byte-code refers to a real symbol, not just to its name. This can avoid all the autoloading, including, and double-checking for classes all the time.

Composer Autoload Removed with Compilation

PeachPie Compiler looks into the composer.json file in the root of the compiled project (see $(ComposerJsonPath) MSBuild property for customization). It specifically checks the "autoload" section to see where the Composer would look for files.

During the compilation, it does two things:

First: It annotates each compiled class with one of the following flags:

  • 0: None - the class does not match the autload map.
  • 1: AutoloadAllow - the class matches the autoload map, but the file with this class also contains other stuff (this is important).
  • 2: AutoloadAllowNoSideEffect - the class matches the autoload map and there is nothing else in the file.

Second: When compiling a use of class X (for example in new X or X::foo()), it looks up the X class in the compiled project and .NET references. A few cases may happen:

  • X is found in the compilation unit and it is annotated with the flag AutoloadAllowNoSideEffect (2) - the compiler can refer to the class X directly, if it's the only X it sees in the compilation unit.
  • X is found in the compiled project but it not annotated with the flag AutoloadAllowNoSideEffect - the compiler may refer to the class X directly but it also needs to emit some stuff that simulates autoloading (see ExpectTypeDeclared<X>() below).
  • X is found in a project's .NET reference - the compiler will refer to the class X directly as well. It is a regular .NET/C# assembly. No autoloading.
  • X is not found - the compiler will emit autoloading stuff. This may result in autoloading, and eventually a runtime exception.

Additionally, the compiler annotates the script files themselves (note: .php script files are compiled into a .NET static class (see Compiler Assembly reference)). This is used to simulate composer's "files" static autoloading. The runtime lists those annotated files and "includes" them automatically on start. We don't save any performance here, it is what it is.

What does it mean for developers?

Write Code that Avoids Autoloading

Write a clean composer.json, follow the psr-4 folder structure, and the compiler will do the rest for you. The following project will work nicely, you don't need to write any include, and no autoloading will be invoked during run-time.

.\composer.json:

{
    "autoload": {
        "psr-4": {
            "MyNamespace\\": "src/",
        },
        "files": [],
    }
}

Put the classes into files respecting the PSR-4 folder structure. For example, a class with a fully qualified name MyNamespace\\A\\B\\C will be placed into ./src/a/b/c.php.

The file must contain the class declaration only, no code with side-effects or other classes.

Sample Program

See the sample project at GitHub - peachpie-samples - composer-autoload.

The project contains a simple composer.json file that tells the compiler where to look for classes:

./composer.json

{
    "autoload": {
        "psr-4": {
            "MyApp\\": "src/"
        }
    }
}

In the source code file ./main.php, we use the class MyApp\X:

$x = new \MyApp\X;

The class X itself is defined in the file ./src/x.php:

<?php
namespace MyApp;
class X {
}

For the sake of completeness, here is the MSBuild project file:

<Project Sdk="Peachpie.NET.Sdk/1.1.9">
  <PropertyGroup>
    <OutputType>exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <StartupObject>main.php</StartupObject>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="**/*.php;*.phar" />
  </ItemGroup>
</Project>

Build the project using the dotnet build command (this assumes you have .NET 6.0 SDK installed).

> dotnet build
  Determining projects to restore...
  PeachPie PHP Compiler version 1.1.9+7a15ac46204eb95ffbbc5cff067069ae452a3b4e
  Donate to support the development of PeachPie! https://bit.ly/3pfXw2q
  composer-autoload -> .\bin\Debug\net6.0\composer-autoload.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:02.91

Build Output

The sample above produces an executable .exe file with .dll assembly. The .dll file contains the MSIL byte-code that we can inspect. We can recommend ILSpy, which has a nice GUI and decompiles the MSIL "back" to C# (!!). Yes, we compile PHP to MSIL, and ILSpy lets you to investigate the compiled code as it would look in C#.

Important note

PeachPie compiler does not transpile PHP code to C#; keep this difference in mind.

Let's investigate the compiled code.

using MyApp;
...
X x = new X(<ctx>);
  • The compiler refers to X directly, without the overhead of resolving the class name, including the x.php script, and autoloading.
  • It knows the file x.php does not have any side-effect, so it avoids including it.
  • Note, <ctx> is a special parameter added by the compiler. See Context for the details.

Needless to say, there is no autoloading overhead. Literally 0%.

Build Output without Compiled Autoloading

How does it look like without composer.json "autoload"? We can either delete composer.json or specify MSBuild property ComposerAutoload to false.

The compiler still knows that there is exactly one X class, so it refers to the symbol directly. However, it needs to simulate PHP's autoloading behavior by prepending the class use with:

<ctx>.ExpectTypeDeclared<X>();
X x = new X(<ctx>);

This is an API call to Peachpie.Runtime.dll, which checks whether X is included, eventually it invokes autoloading as usual, and may throw an exception if the class was not declared.

There may be a worse case, if there is an ambiguity, or X is declared dynamically.

object x = <ctx>.Create(Helpers.EmptyRuntimeTypeHandle, <ctx>.GetDeclaredTypeOrThrow("MyApp\\X", autoload: true));

Here, the compiler does not know and postpones everything to the runtime. The runtime needs to resolve the class by name MyApp\X, to invoke autoloading if necessary, and to instantiate the resolved type dynamically using .NET reflection.

Conclusion

The compiler may really save all the autoloading and reduce it to zero. Ensure you have composer.json with an "autoload" section, respect the psr-4 folder structure, and check the compilation output with an IL decompiler such as ILSpy.

This improves the performance significantly, and reduces the resulting .dll assembly size.

If you liked this article, please consider supporting our project at Patreon.

See Also