birb / fancy-stubs-codegen
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
Package info
gitlab.com/birb-group/fancy-stubs-codegen-packages/core
pkg:composer/birb/fancy-stubs-codegen
Requires
- php: ^8.2
- nette/php-generator: ^4.1
- nikic/php-parser: ^5.0
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-25 21:15:25 UTC
README
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 runs | On every relevant call, in production | Once, at generation time |
| Production dependency | Required (it intercepts the calls) | None — output is plain PHP |
| What your IDE/PHPStan see | Nothing — the method isn't really there | Real methods on a real class |
| What you can generate | A fixed catalog of annotations | Anything — write your own WithContextClassAbstract |
Where this is useful
- Eloquent/DB models — derive
$tableand 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 ofCreateClassService` 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:
ClassVOreads 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/laravelin this monorepo, not as its own published package yet (tracked in issue #1). Same story forbirb/fancy-stubs-codegen-postprocessorsandbirb/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, viashell_execand a temp file.PhpCsFixerOnStringCode— runs php-cs-fixer (@PSR12rules) instead of PHPCS, sameshell_execpattern.EasyCodingStandardOnStringCode— runs ECS, which itself composes both PHPCS sniffs and php-cs-fixer fixers behind one config, sameshell_execpattern.RectorOnStringCode— runs Rector, an AST-based refactoring engine (not just style — code-quality rule sets, dead-code removal, etc.), configurable via arector.phpconfig file, sameshell_execpattern.Slevomat.xml/Doctrine.xml— extra PHPCS ruleset files (next to the existingPSR2.xml) for use withPhpCbfOnStringCode/InProcessPhpcbf, if youcomposer require slevomat/coding-standard/doctrine/coding-standardyourself (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 asPhpCbfOnStringCode. Calls PHP_CodeSniffer's fixer in-process viaPHP_CodeSniffer\Files\DummyFile, the same internal entry point PHPCS itself uses for stdin input.InProcessPhpCsFixer()— same (empty) constructor asPhpCsFixerOnStringCode. Replicates php-cs-fixer's own per-file fixer loop directly againstTokens::fromCode(), bypassing its Finder/parallel-worker/cache machinery.InProcessEasyCodingStandard(string $configPath)— same constructor asEasyCodingStandardOnStringCode. Uses ECS's ownFixerFileProcessordirectly.InProcessRector(string $configPath)— same constructor asRectorOnStringCode, with one caveat: it deliberately builds a fresh Rector container on everybuild()call rather than reusing one across calls. A reused container was found to silently stop applyingDeclareStrictTypesRectorfrom the 2nd call onward (no error — just a missingdeclare(strict_types=1)); root cause not yet diagnosed (tracked here). Still faster thanshell_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:
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.