gdbots/pbj
gdbots/pbj brings Protocol Buffers to PHP with schema-driven message classes, built-in validation, and serialization to JSON, binary, and protobuf. Define a schema once, generate PHP classes, and move data reliably between services with a consistent, type-safe format.
This package is a runtime, not a framework integration — it ships the message base classes, builders, and serializers. The message classes themselves are produced by schema code generation (the Gdbots schema tooling, not this repo). That distinction drives your first hour.
composer require gdbots/pbjpbj codegen CLI to produce PHP classes. Check the repo README for the exact generator package name; don't assume it's bundled.app/Pbj/ or generated/Pbj/ and commit them, then autoload via PSR-4 (or classmap if the generator doesn't namespace consistently).src/: Message.php / AbstractMessage, AbstractMessageBuilder, then Serializer/. Those four files tell you 90% of what you need; external docs are thin.First working use case — a request DTO:
// app/Http/Controllers/PostController.php
public function store(StorePostRequest $request)
{
$post = AcmeBlogPostV1::builder()
->setTitle($request->string('title'))
->setBody($request->string('body'))
->addToTags(...$request->array('tags'))
->build(); // required-field validation fires here
$json = app(JsonSerializer::class)->serialize($post);
}
If you only take one thing from this package, take the builder → validate → build → serialize cycle. Everything else is variation on it.
Register the resolver once, in a provider. Both serializers depend on a SchemaResolver, and the resolver must know every message type you serialize — including nested and Any-typed ones. Populate it exhaustively:
// app/Providers/PbjServiceProvider.php
public function register(): void
{
$this->app->singleton(SchemaResolver::class, function () {
return new SchemaResolver([
AcmeBlogPostV1::schema(),
AcmeBlogCommentV1::schema(),
AcmeUserV1::schema(),
]);
});
$this->app->singleton(JsonSerializer::class, fn ($app) =>
new JsonSerializer($app->make(SchemaResolver::class))
);
$this->app->singleton(BinarySerializer::class, fn ($app) =>
new BinarySerializer($app->make(SchemaResolver::class))
);
}
Use builders at the boundaries, messages everywhere else. Controllers, jobs, and listeners accept arrays from the outside world; convert once at the edge with Message::fromArray($data) or ::builder(), then pass immutable messages around. Never let an unvalidated array leak past the controller.
Immutability → toBuilder() for changes. This is the single most common workflow:
$edited = $post->toBuilder()
->setTitle('Updated')
->build(); // new instance, original untouched
Use this for event sourcing, audit logs, and "before/after" diffs — it's cheap and side-effect free.
Queue payloads: binary; HTTP/API: JSON. Binary is compact and canonical for internal transport. JSON is what you return to clients and what humans read in logs:
public function handle(): void
{
$message = app(BinarySerializer::class)
->deserialize($this->payload); // string of bytes from the job
// do work with typed accessors
$title = $message->getTitle();
}
Validate in the FormRequest, construct in the controller. Use Laravel rules for HTTP-shape validation (required|string|max:255), and let the builder enforce schema invariants. Catch the pbj exception at a single point:
try {
$post = AcmeBlogPostV1::builder()->...->build();
} catch (\Gdbots\Pbj\Exception\GdbotsPbjException $e) {
throw ValidationException::withMessages(['payload' => $e->getMessage()]);
}
Enums: validate against the generated class, don't hand-roll. Generated enum types expose their values; validate the request with Rule::in(AcmePostStatus::values())-style checks rather than trusting a string, then pass through the builder.
Testing helpers. Build fixtures with builders (not arrays) so required fields stay honest, and assert on toArray() shapes. Derive variants with toBuilder() instead of copying arrays — a fixture that builds is a fixture that validates.
Versioning lives in the curie. Schemas are named acme:blog:post:v1. Additive changes still generally warrant v2 for external consumers; keep the old generated classes autoloadable so previously-persisted blobs deserialize. Store _schema alongside the blob if you're keeping binary around.
ServiceProvider in the package — you write bindings yourself, and codegen is a separate CLI step in CI or a composer script. Treat generated PHP like compiled output: pin the generator version, regenerate in CI, and fail the build on git diff --exit-code against generated/.build(). withTitle(null) won't complain; the exception appears at the end of the chain. When debugging a validation failure, bisect by calling build() after each with… — or just trust the exception message, which names the field.SchemaResolver misses are the number-one runtime failure. Polymorphic payloads (AnyMessage/DynamicMessage) resolve nested types through the resolver, so a comment embedded in a post needs its own schema registered — on both producer and consumer. Registering only the root type works in tests and blows up in production._schema, different well-known-type encodings). Third-party proto tooling and protoc-generated readers will not interoperate — don't plan on it.DateTimeImmutable in UTC, RFC3339 with microsecond precision. Carbon instances with a non-UTC timezone, or now() without an explicit zone, are a common source of silent mismatches and validation errors. Normalize at the request boundary.$post->setTitle() doesn't exist; $post->getTitle() does. Every mutation is a new object — that's the feature, but it means "update in place" loops must reassign.payload blob column (binary) plus a handful of scalar columns (id, type, created_at) extracted from the message for indexing/querying. Don't try to query into the blob.toArray()/jsonSerialize(), never binary. Dump the array form; keep _schema visible so you can see which type is actually being resolved.src/ rather than trusting blog posts. Pin an exact version in composer.json.spatie/laravel-data or plain readonly classes will get you there with far less ceremony. Reach for pbj when you need schema evolution and cross-language wire compatibility, not just structure.How can I help you explore Laravel packages today?