gdbots/pbjc
gdbots/pbjc is a compiler for PBJ schemas that generates PHP classes and supporting code, helping teams define typed messages and keep generated models in sync with schema changes. It streamlines schema-driven development for PHP projects.
gdbots/pbjc is a build-time code generator, not a runtime library. It reads PBJ schema files (YAML) describing messages and emits PHP classes, JSON Schema, and JS. In a Laravel app you install it as a dev dependency and treat its output like compiled assets.
composer require --dev gdbots/pbjc
composer require gdbots/pbj # runtime the generated PHP depends on
Then look at, in this order:
vendor/bin/pbjc (the console entry point) — run --help first; this is the fastest way to learn the real command names and flags, since docs are thin.vendor/gdbots/pbjc/src/ — the Compiler class and the generators (PhpGenerator, JsonSchemaGenerator, …). Reading the generator for PHP tells you exactly what namespaces/files it writes.vendor/gdbots/pbj/schemas/ and any *.pbj.yml in the vendor tree — use these as canonical examples of the schema format rather than guessing at keys.First use case: define one message, compile it, use it.
# schemas/acme:blog:post.pbj.yml
types:
- acme:blog:post
fields:
title: {type: string, required: true}
body: {type: text}
vendor/bin/pbjc compile --path=schemas --out=app/Messages
composer dump-autoload
Now App\Messages\Acme\Blog\Post is a plain immutable message object you can construct from a request payload and serialize to JSON for storage. That round-trip — schema → DTO → JSON → DB — is the whole point of the package; everything else is packaging.
Schema-first, generate-on-build. Keep schemas/ in the repo, generate into a PSR-4 directory (app/Messages/), and commit the output or generate in CI. Pick one and enforce it — mixing generated and hand-edited files in the same directory causes painful merge conflicts. A composer.json script is the lowest-friction hook:
"scripts": {
"pbj:compile": "pbjc compile --path=schemas --out=app/Messages",
"post-autoload-dump": ["@pbj:compile"]
}
Wrap it in an Artisan command so the team has one invocation and you can add logging, path resolution, and a --watch mode for local dev:
class CompileSchemas extends Command
{
protected $signature = 'pbj:compile {--watch}';
public function handle()
{
// either Process::run(base_path('vendor/bin/pbjc ...'))
// or instantiate Gdbots\Pbjc\Compiler directly
}
}
Shelling out is usually safer: the binary already handles config resolution and exit codes, and you avoid coupling to internal constructor signatures that may change between patch releases.
Multi-target from one schema. Compile the same schemas to JSON Schema for a second consumer. Serve the JSON Schema from a controller (or bake it into an OpenAPI spec) so your Vue/React front end validates identically to the back end, instead of hand-maintaining two validation rulesets.
Use the JSON Schema for request validation. Rather than rewriting rules in FormRequest, validate the decoded payload against the emitted schema with justinrainbow/json-schema or opis/json-schema. This keeps validation authoritative to the schema and avoids drift. Wrap it in a custom rule or a base FormRequest::rules() that returns the schema-derived constraints.
Persistence. Generated messages are value objects, not Eloquent models. Store them in a json column and cast:
protected $casts = ['payload' => 'array'];
public function post(): Post
{
return Post::fromArray($this->payload); // generated fromArray/fromJson
}
Keep a thin mapper class per aggregate; don't scatter fromArray calls through controllers.
Schema versioning. Namespaced type names (acme:blog:post) are your migration boundary. Add fields as optional, never rename in place; emit a new type (acme:blog:post_v2) and map at the edges. Generated classes are cheap, so a parallel class tree costs little.
each(), create_function, or untyped property reflection will bite. If it fails on your runtime, pin PHP for the build step (a separate CI container running 7.2/7.3) and keep the runtime off the package entirely.app()->isProduction() and fail loudly if generated classes are missing rather than silently compiling at runtime.composer dump-autoload runs. If you generate into app/, PSR-4 covers it; if you generate into build/ or generated/, add an explicit classmap entry and re-dump. This is the #1 "it worked locally, 500s in CI" cause.--check / dry-run mode, if the CLI supports it, makes a good CI gate.: separators and typically camelCase fields, while Laravel/DB conventions are snake_case. Decide the mapping once (in your mappers, not in the schema) — mixing produces payloads that fail schema validation on round-trip.opis, ajv v8) default to draft-07/2020-12. Explicitly declare the draft in your validator config or you'll get silently ignored keywords like const and if/then.mixins are the usual culprit.gdbots/pbj before blaming the compiler; most failures are a missing types entry or a field referencing an undeclared type.pbjc as you would webpack or protoc: a tool that runs before your app boots, with the generated artifacts being the only contract.How can I help you explore Laravel packages today?