Build-Time Code Generation in PHP: Generating Boilerplate Instead of Runtime Reflection
AI generated
<?php
8.4
PHP · Code Generation · Performance
Build-Time Code Generation in PHP
Generating boilerplate instead of runtime reflection

Build-time code generation produces finished, compilable PHP code before an application even starts, instead of reading metadata via reflection again on every request. Symfony and Laravel use this technique for compiled containers, because generated code is treated by OPcache like any regular code and reflection overhead disappears entirely from the hot path.

17 min read Code generator · Templates · Compiled container PHP 8.1+ · Composer

1. Build-time code generation versus runtime reflection

Build-time code generation means producing finished PHP code before an application ever handles a single request, instead of reading metadata about classes again via the Reflection API on every call. The distinction sounds technical at first, but it has a substantial impact on performance: runtime reflection searches the same metadata structures on every request, while code generated at build time does this work exactly once and locks the result in as directly executable PHP code.

A clear example is a dependency injection container. A reflection-based container reads, on every restart and sometimes even on every request, via reflection, which constructor parameters a class expects, and resolves these dependencies dynamically. A compiled container generated at build time, on the other hand, already contains finished PHP code that instantiates every dependency explicitly with new, without a single reflection call at runtime.

This article shows how to build your own simple code generator for PHP, how to cleanly hook generated code into a Composer-based build pipeline, and how to design cache invalidation so that changes in the source code are reliably reflected in the generated result.

2. When build-time code generation pays off

Not every application needs build-time code generation. For small projects with few classes, the per-request reflection overhead is usually negligible, and the additional build step would only add complexity without measurable benefit. Build-time code generation becomes interesting once an application manages hundreds or thousands of classes whose metadata is read repeatedly on every request, for example in a large DI container, an attribute-driven router, or an ORM with many entities.

A second criterion is the stability of the structure between deployments. Build-time code generation pays off especially when the relevant structure, such as which classes exist and which attributes they carry, does not change between two deployments. If this structure changes on every request instead, for example because plugins are loaded dynamically at runtime, runtime reflection is often the more practical, if slower, choice.

3. A simple code generator: from configuration to class

The simplest entry point into build-time code generation is a script that translates a configuration, such as an array of service definitions, into a finished PHP class. The generator reads the configuration once, builds a string of valid PHP code from it, and writes that string to a regular .php file. Afterward, this file is autoloaded by Composer and compiled by OPcache like any other file in the project.

It is important to treat the generator itself as a standalone, testable PHP script, not as part of the actual application logic. Ideally the generator only runs during the build or deployment process, never in the context of a real HTTP request, so an error in the generator does not affect the production application but shows up earlier in CI instead.


<?php

declare(strict_types=1);

/**
 * Minimal build-time code generator: config array to a plain PHP class.
 */
final class ServiceMapGenerator
{
    /** @param array<string, class-string> $services */
    public function generate(array $services, string $outputFile): void
    {
        $entries = [];

        foreach ($services as $id => $className) {
            $entries[] = sprintf("        '%s' => %s::class,", $id, $className);
        }

        $code = sprintf(
            "<?php\n\ndeclare(strict_types=1);\n\n" .
            "// Auto-generated at build time, do not edit by hand\n" .
            "final class GeneratedServiceMap\n{\n" .
            "    public const MAP = [\n%s\n    ];\n}\n",
            implode("\n", $entries)
        );

        file_put_contents($outputFile, $code, LOCK_EX);
    }
}

$generator = new ServiceMapGenerator();
$generator->generate(
    ['payment_gateway' => StripePaymentGateway::class, 'mailer' => SmtpMailer::class],
    __DIR__ . '/generated/GeneratedServiceMap.php'
);

4. Templates instead of string concatenation

Plain string concatenation, as in the previous example, quickly becomes unwieldy once generated code grows more complex, for example containing several methods or nested structures. A template approach is more robust, where a PHP template exists with clearly recognizable placeholders, and the generator specifically replaces these placeholders with real code. This visually separates the structure of the generated code from the generation logic and makes templates readable even for developers who don't know the generator itself in detail.

A proven convention is to choose placeholders in a form that never occurs in real PHP code, for example __PLACEHOLDER_NAME__. This prevents a placeholder from accidentally colliding with real generated code. For more complex templates, switching to a dedicated template engine pays off; for the manageable use cases shown in this article, str_replace() is entirely sufficient.


<?php

declare(strict_types=1);

final class TemplateBasedGenerator
{
    private const TEMPLATE = <<<'PHP'
<?php

declare(strict_types=1);

// Auto-generated at build time, do not edit by hand
final class __CLASS_NAME__
{
    public function __construct(
__CONSTRUCTOR_PARAMS__
    ) {
    }
}

PHP;

    /** @param array<string, class-string> $dependencies */
    public function generate(string $className, array $dependencies): string
    {
        $params = [];

        foreach ($dependencies as $name => $type) {
            $params[] = sprintf('        private readonly %s $%s,', $type, $name);
        }

        return str_replace(
            ['__CLASS_NAME__', '__CONSTRUCTOR_PARAMS__'],
            [$className, implode("\n", $params)],
            self::TEMPLATE
        );
    }
}

$generator = new TemplateBasedGenerator();
$code = $generator->generate('GeneratedOrderService', ['gateway' => 'PaymentGateway', 'mailer' => 'Mailer']);

5. Hooking generated code into the Composer pipeline

For build-time code generation to not become a manual extra task, the generation step belongs in the regular Composer pipeline. Composer supports its own lifecycle hooks in composer.json for this, such as post-autoload-dump, which automatically runs a PHP script after every composer install or composer dump-autoload. This script calls your own code generator and writes the generated files to a directory that is also included in the Composer autoload configuration.

This coupling to the Composer lifecycle ensures that generated code never sits stale in the repository without a developer having to manually remember the build step. It is important to consistently exclude the generated directory from version control, for example via .gitignore, so that stale generated code is never accidentally committed, since it would be overwritten on the next build anyway.


<?php

declare(strict_types=1);

/**
 * Composer script handler, referenced from composer.json as:
 * "scripts": { "post-autoload-dump": ["Mironsoft\\Build\\Generator::run"] }
 */
final class Generator
{
    public static function run(): void
    {
        $config = require __DIR__ . '/../config/services.php';

        $generator = new ServiceMapGenerator();
        $generator->generate($config, __DIR__ . '/../generated/GeneratedServiceMap.php');

        echo '[build] Generated service map with ' . count($config) . ' entries' . PHP_EOL;
    }
}

6. Practical example: a compiled DI container

The most prominent practical example of build-time code generation in PHP is the compiled dependency injection container, as Symfony has used in production for years. Instead of figuring out via reflection on every request which dependencies a service needs, Symfony generates a single PHP class at container build time that contains a dedicated, direct factory method for every registered service. This method instantiates the dependency with a simple new call, completely without reflection at runtime.

The result is a container whose resolution logic looks to OPcache exactly like hand-written code, because it actually is generated, but entirely regular, PHP code. The speed gain over a purely reflection-based container is clearly measurable in Symfony's benchmarks, especially for applications with hundreds of registered services that need to be instantiated on every request.

7. Validating generated code and checking it in CI

Generated code is only as trustworthy as the generator that produced it, and errors in the generator itself can lead to syntactically invalid or logically broken PHP code. That is why a syntax check of the generated code, for example with php -l, belongs in every CI pipeline that runs the generator. A failed syntax check should abort the build immediately, before broken generated code ever reaches a deployment.

Beyond a plain syntax check, it pays off to run the generated code through PHPStan as a test. Since generated code often has repetitive, predictable structures, PHPStan frequently catches real errors in the generator itself here, such as incorrectly assembled type declarations, before they lead to runtime errors in a production environment.

8. Cache invalidation on source changes

A central risk in build-time code generation is stale generated code that no longer matches the current configuration or source code. The most reliable countermeasure is a hash over the relevant source files, such as the service configuration, computed on every generation run and stored next to the generated code in a manifest file. Before using the generated code, the application compares the current hash to the stored hash and regenerates on a mismatch, or aborts in production with a clear error message.

In production environments, automatic regeneration at runtime is usually undesirable, because it defeats exactly the performance benefit build-time code generation is supposed to provide. There, a hash mismatch should instead trigger a visible error pointing to a broken deployment process, instead of silently continuing to use stale code.

9. Build-time versus runtime generation compared

Both techniques have their place, depending on project size and deployment process. The following table contrasts the key differences.

Criterion Runtime reflection Build-time code generation Benefit of the build-time variant
Performance per request Reflection overhead on every call Direct, compilable code No metadata lookup at runtime
OPcache usage Only the reflection calls themselves Generated code fully compilable Maximum opcode cache utilization
Runtime flexibility Immediate adaptation possible Requires a new build -
Debugging Stack traces show reflection calls Stack traces show real, readable code Easier troubleshooting in production
Additional build complexity None Generator, CI checks, invalidation needed -

The table shows: build-time code generation wins on performance and debugging clarity in production, but costs additional build complexity. For small applications with few services, the simplicity of runtime reflection often outweighs this, while for large, production-critical systems with many services, the performance benefit of the build-time variant almost always wins out.

Mironsoft

PHP performance, code generation and build pipelines

Replacing reflection overhead with build-time code generation?

We build code generators, Composer integrations and compiled containers that remove reflection overhead from the hot path of production PHP applications.

Code generators

Custom build-time generators for containers, routers and mappers

Build pipeline

Composer hooks, CI validation and cache invalidation

Performance audit

Migrating reflection-heavy systems to code generation

10. Summary

Build-time code generation replaces repeated reflection calls at runtime with directly executable PHP code produced once. A simple generator reads configuration and writes regular classes from it, ideally through templates with clear placeholders instead of fragile string concatenation. Composer lifecycle hooks like post-autoload-dump cleanly integrate the generation step into the existing build pipeline, without extra manual work for developers.

The compiled DI container is the most prominent practical example: instead of resolving dependencies via reflection on every request, the build-time variant generates direct factory code. Syntax checking and PHPStan in the CI pipeline catch errors in the generator before they reach production, and a hash over the source configuration prevents stale generated code from being used unnoticed.

Build-Time Code Generation in PHP — Key Takeaways

Core idea

Read metadata once at build time and lock it in as finished PHP code.

Templates

Clearly recognizable placeholders instead of fragile string concatenation for complex code.

Composer integration

post-autoload-dump hook runs the generator automatically after every install.

Safety

php -l and PHPStan in CI, hash-based cache invalidation against stale code.

11. FAQ: Build-Time Code Generation in PHP

1Build-time vs. runtime reflection?
Build-time generates code once, reflection reads metadata again on every call. Build-time is faster, less flexible.
2When is code generation not worth it?
For small applications with few classes, where reflection overhead is usually negligible.
3Commit generated code?
No, add it to .gitignore, it gets overwritten on the next build anyway.
4Hook generator into Composer?
Via lifecycle hooks like post-autoload-dump in composer.json.
5What is a compiled DI container?
A container with build-time generated, direct PHP code instead of dynamic reflection resolution.
6How to check generated code?
With php -l and PHPStan in the CI pipeline.
7Prevent stale generated code?
Store a hash of source files and compare it before use.
8Auto-regenerate at runtime?
Not recommended, better to raise a visible error on a hash mismatch.
9Is string concatenation enough?
For simple cases yes, complex code benefits from templates with clear placeholders.
10Which frameworks use this?
Symfony with its compiled DI container is the best known example.