Pop PHP
The Toolkit

Code Generation

pop-code writes PHP. You build a class, function or enum out of generator objects, and render it to a string or a file. The same component reads in the other direction: reflection turns existing code back into those generator objects, so you can add a method to a class that already exists and write it out again. This is what scaffolding tools are built from — pop-kettle generates its controllers and models this way.

BASH
composer require popphp/pop-code

Generating a Class#

ClassGenerator is the container. Give it a namespace, properties and methods, and render it:

PHP
use Pop\Code\Generator;

$class = new Generator\ClassGenerator('Widget');
$class->setNamespace(new Generator\NamespaceGenerator('App\Model'));

$class->addProperty(new Generator\PropertyGenerator('name', 'string', null, 'protected'));

$setName = new Generator\MethodGenerator('setName', 'public');
$setName->addArgument('name', null, 'string')
        ->setBody('$this->name = $name;')
        ->addReturnType('void')
        ->setDesc('Set the widget name.');

$class->addMethod($setName);

echo $class;
PHP
/**
 * @namespace 
 */
namespace App\Model;

class Widget
{

    /**
     * @var   string
     */
    protected string|null $name = null;

    /**
     * Set the widget name.
     * 
     * @param  string|null  $name
     * @return void
     */
    public function setName(string|null $name = null): void
    {
        $this->name = $name;
    }

}

InterfaceGenerator, TraitGenerator and EnumGenerator are the same shape. An interface's methods render as semicolon-terminated signatures with no body, and its parents come from a second constructor argument taking an array or a comma-separated string:

PHP
use Pop\Code\Generator;

$interface = new Generator\InterfaceGenerator('Arrayable');
$interface->addMethod((new Generator\MethodGenerator('toArray'))->addReturnType('array'));

echo $interface;
PHP
interface Arrayable
{

    /**
     * @return array
     */
    public function toArray(): array;

}

A trait takes properties and methods but has no extends, implements, abstract, final or readonly flag. An enum takes a backing type and EnumCaseGenerator cases, and can carry constants, methods and implemented interfaces exactly as a class does. Leave the backing type off for a pure enum:

PHP
use Pop\Code\Generator;

$enum = new Generator\EnumGenerator('Status', 'string');
$enum->addCase(new Generator\EnumCaseGenerator('Active', 'active'));
$enum->addCase(new Generator\EnumCaseGenerator('Inactive', 'inactive'));

echo $enum;
PHP
enum Status: string
{

    case Active = 'active';

    case Inactive = 'inactive';

}

FunctionGenerator covers plain functions and, with closure: true, anonymous ones — a closure with a name renders as $name = function(...) {...};, and one with no name renders as a bare expression.

Flags cover the rest of a class declaration. setAsAbstract() and setAsFinal() are on both ClassGenerator and MethodGenerator and are mutually exclusive — setting one clears the other. setAsReadonly() is on ClassGenerator and PropertyGenerator. addUse() composes a trait in, and addConstant() takes a ConstantGenerator, optionally with setTyped(true) for a PHP 8.3 typed constant.

addUse($trait, $as) honors the alias on NamespaceGenerator; inside a class body use SomeTrait as Alias; is not valid PHP, so the alias is dropped there.

Methods, Properties and Docblocks#

addArgument($name, $value, $type, $variadic, $byRef, $attributes) is where most of the detail lives. The second argument is the default value, and there's a trap in it: passing PHP null means "default to null", not "no default". It emits = null and widens the type, which is why setName(string $name) above came out as setName(string|null $name = null).

Two marker classes cover the cases a bare value cannot express. Generator\NoValue is a required parameter with no default at all, and Generator\Literal is a raw expression emitted verbatim rather than quoted:

PHP
use Pop\Code\Generator;

$sum = new Generator\FunctionGenerator('sum');
$sum->addArgument('numbers', new Generator\NoValue(), 'int', true)
    ->setBody('return array_sum($numbers);')
    ->addReturnType('int');

echo $sum;  // function sum(int ...$numbers): int

The fourth and fifth arguments are the variadic and by-reference flags — addArgument('counter', new Generator\NoValue(), 'int', false, true) renders int &$counter. Any type position accepts a union (int|string) or an intersection (\Countable&\Traversable); an intersection defaulted to null is wrapped in parentheses to satisfy PHP's DNF grammar.

NoValue gives a property a bare typed declaration with no initializer — new PropertyGenerator('name', 'string', new NoValue(), 'protected') renders protected string $name;.

Promoted constructor properties come from addPromotedArgument($name, $visibility, $value, $type, $readonly), valid only on a method named __construct:

PHP
use Pop\Code\Generator;

$class = new Generator\ClassGenerator('Point');

$constructor = new Generator\MethodGenerator('__construct');
$constructor->addPromotedArgument('x', 'public', new Generator\NoValue(), 'int')
            ->addPromotedArgument('y', 'public', new Generator\NoValue(), 'int')
            ->setBody('');

$class->addMethod($constructor);

echo $class;
PHP
class Point
{

    /**
     * @param int  $x
     * @param int  $y
     */
    public function __construct(public int $x, public int $y)
    {
        
    }

}

Call setBody('') for a concrete method with an empty body. MethodGenerator renders a semicolon-terminated signature for a body that was never set.

Docblocks are generated for you from the types and the setDesc() description, but DocblockGenerator can be built by hand and passed to setDocblock() when you want more than @param and @return:

PHP
use Pop\Code\Generator;

$docblock = new Generator\DocblockGenerator('Adds two numbers together.');
$docblock->addParam('int', '$a', 'The first number');
$docblock->setReturn('int', 'The sum');
$docblock->setThrows('\InvalidArgumentException', 'If either number is negative');
$docblock->addTag('since', '7.0.0');

Attributes are AttributeGenerator objects, addable to a class, member, or individual parameter. addArgument($value, $name) takes the value first and the name second, so a positional argument is a one-argument call and a named one passes the name second:

PHP
use Pop\Code\Generator;

$table = new Generator\AttributeGenerator('Table');
$table->addArgument('products', 'name');

$class = new Generator\ClassGenerator('Product');
$class->addAttribute($table);

echo $class;  // #[Table(name: 'products')] above the class declaration

Generating Files#

Pop\Code\Generator is the file container. It takes one or more code objects, adds the <?php tag, and either renders the whole file or writes it:

PHP
use Pop\Code\Generator;

$class = new Generator\ClassGenerator('Widget');
$class->setNamespace(new Generator\NamespaceGenerator('App\Model'));

$code = new Generator($class);

echo $code->render();
$code->writeToFile(__DIR__ . '/../app/src/Model/Widget.php');

addCodeObjects() takes an array, which is how you put several functions in one file. Keyed by namespace, it produces a multi-namespace file with each in its own braced block:

PHP
use Pop\Code\Generator;

$code = new Generator();
$code->addCodeObjects([
    'App\One' => new Generator\ClassGenerator('Foo'),
    'App\Two' => new Generator\ClassGenerator('Bar'),
]);

BodyGenerator renders statements with no class or function around them, for a file that is nothing but executable code. Its createReturnConfig() turns an array into a formatted return [...], which is exactly the shape of a Pop config file:

PHP
use Pop\Code\Generator;

$body = new Generator\BodyGenerator();
$body->createReturnConfig([
    'debug'  => true,
    'routes' => ['home' => '/'],
]);

echo new Generator($body);
PHP
<?php

    return [
        'debug' => true,
        'routes' => [
            'home' => '/',
        ],
    ];

NamespaceGenerator::addUse() adds the imports at the top of the file, and is the one addUse() that honors an alias:

PHP
use Pop\Code\Generator;

$namespace = new Generator\NamespaceGenerator('App\Model');
$namespace->addUse('Pop\Db\Record');
$namespace->addUse('App\Service\Mailer', 'AppMailer');

Nothing validates the code before it is written — setBody() takes a string and emits it as given, so a body with a typo produces a file that will not parse. When a generator is part of a build step, run php -l over what it wrote before trusting it.

Reflecting over Existing Code#

Pop\Code\Reflection reads a construct that already exists and hands back the generator object for it. That closes the loop: you can add to a class you did not write and render the whole thing again.

PHP
use Pop\Code\Generator;
use Pop\Code\Reflection;

$class = Reflection::createClass('App\Model\Widget');

$hasName = new Generator\MethodGenerator('hasName', 'public');
$hasName->setBody('return ($this->name !== null);')
        ->addReturnType('bool')
        ->setDesc('Whether the name is set.');

$class->addMethod($hasName);

(new Generator($class))->writeToFile(__DIR__ . '/../app/src/Model/Widget.php');

The file that comes back has the original namespace, property and method intact, with hasName() appended. Attributes on the reflected code are detected and reproduced without any extra work.

Ten factory methods cover the constructs, one per generator type:

Method Returns
Reflection::createClass() Generator\ClassGenerator
Reflection::createInterface() Generator\InterfaceGenerator
Reflection::createTrait() Generator\TraitGenerator
Reflection::createEnum() Generator\EnumGenerator
Reflection::createNamespace() Generator\NamespaceGenerator
Reflection::createFunction() Generator\FunctionGenerator
Reflection::createMethod() Generator\MethodGenerator
Reflection::createProperty() Generator\PropertyGenerator
Reflection::createConstant() Generator\ConstantGenerator
Reflection::createDocblock() Generator\DocblockGenerator

Each takes a name, an object, or — for the smaller constructs — a native Reflector or a value. Built-in code works as well as your own: Reflection::createFunction('array_map') and Reflection::createInterface('Countable') both return usable generators.

Reflection reads a class PHP has already loaded, so the class has to be autoloadable from the script doing the reflecting.

The generator surface is much wider than one page — closures, variadics, typed constants, enum interfaces, parameter attributes and the multi-namespace file container all have more to them. See the pop-code README.

See Also#