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
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-postprocessors, 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.
More examples:
Testing
composer install
composer test
packages/laravel and packages/postprocessors are separate Composer packages with their own
composer.json/test suite — cd into either and run the same two commands to test them independently.
Contributing
See CONTRIBUTING.md.
License
MIT — see LICENSE.