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

Maintainers

Package info

gitlab.com/birb-group/fancy-stubs-codegen-packages/core

Issues

pkg:composer/birb/fancy-stubs-codegen

Transparency log

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

0.3.0 2026-08-06 03:00 UTC

This package is auto-updated.

Last update: 2026-08-06 13:46:08 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, 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:

  1. Why this library exists
  2. Using postprocessing

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.