Search by

birb / fancy-stubs-codegen

Birbbbb

Build-time, attribute-driven code generator: write a stub class, mark members with #[DefinerAttribute], and get a real, finished PHP class back — conventions filled in (event names, ids, ...), or whole getters/setters/builder classes generated from the stub's properties. Unlike runtime-reflection to

0.3.0 2026-08-06 03:00 UTC

README

Pipeline status Latest Version Total Downloads License: MIT

A library of helpers for generating PHP classes from stub templates — command classes, models, DDD building blocks, and anything else you'd otherwise hand-write from a boilerplate. Write a normal PHP class as a stub, annotate members with #[DefinerAttribute], and let the library fill in the blanks (table names, id columns, event names, default values, etc.) based on the target class name — then optionally run the result through a postprocessor (e.g. phpcbf) for a clean, PSR-conformant output. The generated code is plain PHP with no runtime dependency on this library — it's a build-time/codegen tool, not something your emitted classes need installed.

Built on top of nette/php-generator and nikic/php-parser.

Build-time, not runtime reflection

This is a code generator, not a runtime helper — it runs once (in a console command, a build step, a test), and its output is a real .php file with the boilerplate already written out. That's a different tradeoff than annotation libraries that add the boilerplate at request time via reflection — e.g. marcin-orlowski/lombok-php, whose README is explicit that it works "at runtime, without... generating any additional code files":

Runtime reflection (e.g. lombok-php)fancy-stubs-codegen
When it runsOn every relevant call, in productionOnce, at generation time
Production dependencyRequired (it intercepts the calls)None — output is plain PHP
What your IDE/PHPStan seeNothing — the method isn't really thereReal methods on a real class
What you can generateA fixed catalog of annotationsAnything — write your own WithContextClassAbstract

Where this is useful

  • Eloquent/DB models — derive $table and the primary key column from the model's class name instead of repeating the same convention by hand in every model (see Laravel conventions below).
  • DDD building blocks — value objects, aggregates, domain events: stamp a snake_case event name, an aggregate id property, or any other name-derived constant onto a generated class from a shared stub.
  • Command/console classes and other framework boilerplate — generate the repetitive parts (signatures, names) from a class name convention instead of a text-templating engine.
  • *Internal codegen tooling / `make:-style commands** — build your own generator on top of CreateClassService` and ship consistently formatted output via a postprocessor, without pulling in a full templating engine.
  • Cutting down accessor/builder boilerplate — generate getters and a fluent builder companion class from a stub's typed properties (see Generating methods and companion classes below), without paying a runtime reflection cost for it.
  • Keeping doc code samples honest — because the stub is just a normal, type-checked PHP class, you can reuse it to (re)generate the code blocks in your own documentation whenever the convention changes, instead of hand-editing markdown.

Requirements

  • PHP >= 8.2

Installation

composer require birb/fancy-stubs-codegen

Usage

Write a stub class the way you'd want the generated class to look, and mark the members that should be filled in automatically with #[DefinerAttribute]. DefinerAttribute points at any WithContextClassAbstract\PutDefaultValue subclass — here's one, with zero dependencies beyond core, that derives a snake_case domain event name from the target class name:

<?php
// App/Stubs/SnakeCaseEventNameDefaultValue.php

namespace App\Stubs;

use Birb\FancyStubsCodegen\WithContextClassAbstract\PutDefaultValue;

readonly class SnakeCaseEventNameDefaultValue extends PutDefaultValue
{
    public function getValue(): string
    {
        return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $this->classTypeContext->classType->getName()));
    }
}
<?php
// App/Stubs/OrderShippedStub.php

namespace App\Stubs;

use Birb\FancyStubsCodegen\CreateClassService\Attributes\DefinerAttribute;

class OrderShippedStub
{
    #[DefinerAttribute(definerClass: SnakeCaseEventNameDefaultValue::class)]
    public const NAME = '';
}

Then build the real class from that stub, giving it the final class name and namespace:

<?php

use Birb\FancyStubsCodegen\CreateClassService;
use Birb\FancyStubsCodegen\CreateClassService\ClassLikeWrapper;
use Birb\FancyStubsCodegen\ValueObjects\ClassVO;
use Birb\FancyStubsCodegen\ValueObjects\PrinterVO;
use Nette\PhpGenerator\PhpNamespace;
use App\Stubs\OrderShippedStub;

$service = new CreateClassService(
    new ClassLikeWrapper(
        classVO: new ClassVO(
            new ReflectionClass(OrderShippedStub::class),
            name: 'OrderShipped'
        ),
        namespace: new PhpNamespace('App\Events')
    ),
    new PrinterVO(resolveTypes: true, omitEmptyNamespaces: false)
);

file_put_contents('OrderShipped.php', $service->build());

Result:

<?php

namespace App\Events;

class OrderShipped
{
	public const NAME = 'order_shipped';
}

The event name was derived from the final class name (OrderShipped) by SnakeCaseEventNameDefaultValue#[DefinerAttribute] works the same way on class constants as on properties (getAttributeTokens() collects both).

Note: ClassVO reads the whole source file of the reflected stub class and parses the first class-like declaration it finds in it. Keep one stub (or definer) class per file, same as any PSR-4 autoloaded class — a file with several classes will build the wrong one.

Generating methods and companion classes

Everything above uses WithContextClassAbstract\PutDefaultValue — it fills in one member's default value and stops there. WithContextClassAbstract\HandlerContainer is the other base class: its handle() gets the same ClassTypeContext, but the ClassType and PhpNamespace inside it are mutable, so a definer can add methods, properties, interfaces — or entire extra classes into the same namespace.

Two such definers ship with the library. Put both on a stub with typed properties:

<?php
// App/Stubs/PersonStub.php

namespace App\Stubs;

use Birb\FancyStubsCodegen\CreateClassService\Attributes\DefinerAttribute;
use Birb\FancyStubsCodegen\CreateClassService\Definers\GenerateBuilderClass;
use Birb\FancyStubsCodegen\CreateClassService\Definers\GenerateGetterMethods;

#[DefinerAttribute(definerClass: GenerateGetterMethods::class)]
#[DefinerAttribute(definerClass: GenerateBuilderClass::class)]
class PersonStub
{
    public string $name;
    public ?int $age;
}

Building it as Person in namespace App produces a getter on Person itself, plus a whole separate PersonBuilder class in the same file — GenerateBuilderClass adds it via $this->classTypeContext->namespace->addClass(...):

<?php

namespace App;

class Person
{
	public string $name;
	public ?int $age;


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


	public function getAge(): ?int
	{
		return $this->age;
	}
}

class PersonBuilder
{
	private string $name;
	private ?int $age;


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


	public function age(?int $age): self
	{
		$this->age = $age;
		return $this;
	}


	public function build(): Person
	{
		$object = new Person();
		$object->name = $this->name;
		$object->age = $this->age;
		return $object;
	}
}

Both definers skip static properties and respect declared (nullable) types. Since the result is plain PHP, PersonBuilder is a real class your IDE and static analyzer see — nothing is synthesized at request time.

Laravel conventions

Framework-specific conventions ship as separate packages, not core, so core never needs illuminate/support. birb/fancy-stubs-codegen-laravel has two ready-made PutDefaultValue implementations for the Eloquent $table/$primaryKey convention:

<?php
// App/Stubs/GigachadStub.php

namespace App\Stubs;

use Birb\FancyStubsCodegen\CreateClassService\Attributes\DefinerAttribute;
use Birb\FancyStubsCodegen\Laravel\Definers\LaravelConventForTableUseClassNameContextPutDefaultValue;
use Birb\FancyStubsCodegen\Laravel\Definers\LaravelConventForIdsUseClassNameContextPutDefaultValue;

class GigachadStub
{
    #[DefinerAttribute(
        definerClass: LaravelConventForTableUseClassNameContextPutDefaultValue::class
    )]
    protected $table;

    #[DefinerAttribute(
        definerClass: LaravelConventForIdsUseClassNameContextPutDefaultValue::class
    )]
    protected $primaryKey;
}

Building it as Gigachad fills in protected $table = 'gigachads'; and protected $primaryKey = 'gigachad_id';, same mechanics as the Usage example above, just with a Laravel-flavored convention instead of a hand-written one.

Not yet on Packagist — this package currently lives at packages/laravel in this monorepo, not as its own published package yet (tracked in issue #1). Same story for birb/fancy-stubs-codegen-postprocessors and birb/fancy-stubs-codegen-code-quality, below.

Postprocessing

build() returns a plain string, so anything that turns one string of PHP into another string of PHP can sit between it and file_put_contents(). Every postprocessor implements the same one-method interface:

namespace Birb\FancyStubsCodegen\Postprocessing;

interface PostprocessorInterface
{
    public function build(string $code): string;
}

DeclareStrictTypesPostprocessor ships in core (zero dependencies — makes sure declare(strict_types=1); is there, idempotent). Anything that needs a real dependency lives in a satellite package instead — e.g. PhpCbfOnStringCode, in birb/fancy-stubs-codegen-postprocessors, which runs the code through phpcbf against a given ruleset.

Chain any number of them with PostprocessorPipeline (core), which is itself a PostprocessorInterface — so pipelines nest, and anywhere in this library that expects "a postprocessor" happily accepts a whole pipeline, mixing core and satellite postprocessors freely:

<?php

use Birb\FancyStubsCodegen\Postprocessing\DeclareStrictTypesPostprocessor;
use Birb\FancyStubsCodegen\Postprocessing\PostprocessorPipeline;
use Birb\FancyStubsCodegen\Postprocessors\PhpCbfOnStringCode;

$pipeline = new PostprocessorPipeline(
    new DeclareStrictTypesPostprocessor(),
    new PhpCbfOnStringCode('PSR2.xml'),
);

$code = $pipeline->build($rawGeneratedCode);

To add your own step — a license header, an import sorter, whatever — just implement PostprocessorInterface and drop it into the pipeline; nothing else needs to change.

birb/fancy-stubs-codegen-postprocessors ships more than just PhpCbfOnStringCode:

  • PhpCbfOnStringCode — runs phpcbf against a given ruleset, via shell_exec and a temp file.
  • PhpCsFixerOnStringCode — runs php-cs-fixer (@PSR12 rules) instead of PHPCS, same shell_exec pattern.
  • EasyCodingStandardOnStringCode — runs ECS, which itself composes both PHPCS sniffs and php-cs-fixer fixers behind one config, same shell_exec pattern.
  • RectorOnStringCode — runs Rector, an AST-based refactoring engine (not just style — code-quality rule sets, dead-code removal, etc.), configurable via a rector.php config file, same shell_exec pattern.
  • Slevomat.xml / Doctrine.xml — extra PHPCS ruleset files (next to the existing PSR2.xml) for use with PhpCbfOnStringCode/InProcessPhpcbf, if you composer require slevomat/coding-standard / doctrine/coding-standard yourself (kept out of this package's own dependencies so you're not forced to install rulesets you don't use).

Each of the four above has an in-process sibling that skips shell_exec entirely — no subprocess per call, no reliance on the CWD happening to be the project root, and (per-call, once warmed up) roughly 5-200x faster depending on the tool, since the tool's own engine bootstrap is paid once instead of on every build() call. All four are verified to produce identical output to their shell_exec sibling for the same input:

  • InProcessPhpcbf(string $standard) — same constructor as PhpCbfOnStringCode. Calls PHP_CodeSniffer's fixer in-process via PHP_CodeSniffer\Files\DummyFile, the same internal entry point PHPCS itself uses for stdin input.
  • InProcessPhpCsFixer() — same (empty) constructor as PhpCsFixerOnStringCode. Replicates php-cs-fixer's own per-file fixer loop directly against Tokens::fromCode(), bypassing its Finder/parallel-worker/cache machinery.
  • InProcessEasyCodingStandard(string $configPath) — same constructor as EasyCodingStandardOnStringCode. Uses ECS's own FixerFileProcessor directly.
  • InProcessRector(string $configPath) — same constructor as RectorOnStringCode, with one caveat: it deliberately builds a fresh Rector container on every build() call rather than reusing one across calls. A reused container was found to silently stop applying DeclareStrictTypesRector from the 2nd call onward (no error — just a missing declare(strict_types=1)); root cause not yet diagnosed (tracked here). Still faster than shell_exec (~5-25x depending on call volume), just not as fast as the other three, which are safe to hold as a single long-lived instance across many calls.

They all implement PostprocessorInterface, so they compose the same way — construct each postprocessor once and reuse it (rather than new-ing one per build() call) to get the full benefit of not re-bootstrapping the underlying tool every time:

$pipeline = new PostprocessorPipeline(
    new InProcessPhpcbf('PSR2.xml'),
    new InProcessPhpCsFixer(),
);

// reuse $pipeline across every file you postprocess in this process -
// each postprocessor's engine bootstrap only happens once
$fixed = $pipeline->build($rawGeneratedCode);

The shell_exec-based classes still work identically if you specifically want the isolation of a real subprocess per call (e.g. running arbitrary/untrusted generated code you don't want sharing a PHP process with your own application).

Quality checks

Postprocessing transforms code (string -> string). Some tools instead evaluate code without touching it — static analysis and lint gates (PHPStan, Psalm, PHPCS in check-only mode) don't produce fixed code, they produce a report. QualityCheckInterface (core) is the contract for that:

namespace Birb\FancyStubsCodegen\QualityControl;

interface QualityCheckInterface
{
    public function check(string $code): QualityCheckResult;
}

readonly class QualityCheckResult
{
    /** @param list<string> $violations */
    public function __construct(
        public bool $passed,
        public array $violations = [],
    ) {
    }
}

QualityCheckPipeline (core) runs several checks against the same code and aggregates: passed is the AND of every child's passed, violations is the concatenation of all of them. It's itself a QualityCheckInterface, so pipelines nest, same as PostprocessorPipeline.

Real checks — PhpstanCheck, PsalmCheck, PhpcsCheck, plus their in-process siblings InProcessPhpstanCheck and InProcessPsalmCheck — ship in birb/fancy-stubs-codegen-code-quality:

use Birb\FancyStubsCodegen\CodeQuality\PhpcsCheck;
use Birb\FancyStubsCodegen\CodeQuality\PhpstanCheck;
use Birb\FancyStubsCodegen\QualityControl\QualityCheckPipeline;

$gate = new QualityCheckPipeline(
    new PhpcsCheck('PSR12'),
    new PhpstanCheck(level: 5),
);

$result = $gate->check($generatedCode);

$result->passed;      // bool
$result->violations;  // list<string>, e.g. "line 7, col 36: [Squiz.Functions.MultiLineFunctionDeclaration...] ..."

PhpcsCheck runs in-process (same DummyFile approach as InProcessPhpcbf, just without ever calling the fixer, so the code is never modified). PhpstanCheck/PsalmCheck shell out to their CLI against a temp file.

Psalm now also has an in-process option: InProcessPsalmCheck([int $errorLevel = 4]) — same constructor as PsalmCheck, but must be constructed once and reused across every check() call to get its speed benefit (~25x faster than PsalmCheck on a small benchmark), since it holds a persistent ProjectAnalyzer that would otherwise re-bootstrap Psalm's engine on every call. It's still correct (just not fast) if you construct it fresh per call instead.

PHPStan has one too: InProcessPhpstanCheck([int $level = 5]) — same constructor as PhpstanCheck. Unlike InProcessPsalmCheck, this one does not hold a persistent engine instance — it builds a fresh PHPStan container on every check() call. That's a deliberate compromise, not a missed optimization: PHPStan's own BetterReflection layer (MemoizingReflectionProvider/MemoizingReflector) caches class reflection by class name for the lifetime of the container it belongs to, with no reset hook, so a persistent container across calls was found to serve stale reflection for a reused class name (e.g. a method removed from a class in a later check() call still silently "existed" as far as the cached container was concerned) — the same category of bug that forced InProcessRector (above, in the postprocessing section) into the same fresh-container-per-call design, since Rector's own internal use of PHPStan hit this exact caching layer. Confirmed both ways by actually running it: a throwaway persistent-container version reproduced the stale-result symptom verbatim, and the shipped fresh-container-per-call version was verified NOT to on the same scenario. Still meaningfully faster than PhpstanCheck's shell_exec (~4x on a small benchmark — see InProcessPhpstanCheckTest's docblock for the real numbers), since it skips spawning a fresh php CLI process per call even though it still pays PHPStan's DI container assembly every time.

More examples:

  1. Why this library exists
  2. Using postprocessing

Testing

composer install
composer test

packages/laravel, packages/postprocessors and packages/code-quality are separate Composer packages with their own composer.json/test suite — cd into any of them and run the same two commands to test them independently.

Contributing

See CONTRIBUTING.md.

License

MIT — see LICENSE.