A Protocol Buffers proto2 implementation for PHP: a wire-format runtime plus a protoc plugin that generates typed message classes from .proto files.
Only proto2 syntax is supported — explicit field presence, required/optional/repeated labels, declared defaults, and group fields, with proto3 files rejected by the generator.
I wanted to consume a GTFS-realtime feed in PHP. GTFS-realtime is a proto2 schema and shows no sign of ever becoming a proto3 one — transit agencies are not exactly known for chasing the bleeding edge. So, PHP proto2 support: how hard could it be?
Turns out proto2 support for PHP is largely abandoned. Faced with a choice between necromancy and just writing the wire format myself, I picked the option that doesn't involve reading 400-comment GitHub issues asking "is this dead?" — so here we are: a bus tracker turned into a protobuf implementation.
The runtime is generic — it works with any proto2 message once given a field descriptor list — but nothing about it assumes protoc as the source. Descriptors can come from a generated class, from parsing a FileDescriptorProto you built yourself, or by hand for a one-off message, as shown below.
- PHP 8.4+
protocon yourPATH(only needed to generate code — the runtime itself has no external dependencies)
composer require aaronlow/proto2-phpGiven a proto2 file:
syntax = "proto2";
package acme.billing;
message Invoice {
required int32 id = 1;
optional string customer = 2;
repeated int32 line_totals = 3 [packed = true];
}Run protoc with the bundled plugin:
protoc --php2_out=./generated invoice.protoThis produces generated/Acme/Billing/Invoice.php: a final class extending the runtime Message base, with a fields() descriptor and typed get/set/has/clear accessors (add/clear variants too for repeated fields).
Pass a namespace plugin parameter to override the package-derived PHP namespace:
protoc --php2_out=./generated --php2_opt=namespace=Acme\\Billing invoice.protoNested proto messages flatten into one class per outer message, joined with _ (Outer.Inner → Outer_Inner), since PHP has no nested classes.
use Acme\Billing\Invoice;
$invoice = new Invoice()
->setId(1001)
->setCustomer('Jane Doe')
->setLineTotals([1500, 2200]);
$bytes = $invoice->serializeToString();
$parsed = Invoice::parseFromString($bytes);
$parsed->getCustomer(); // 'Jane Doe'
$parsed->hasCustomer(); // true — proto2 explicit presenceYou don't need protoc to use the runtime — a message is just a class extending Message that returns a Field list:
use Aaronlow\Proto2Php\Runtime\Field;
use Aaronlow\Proto2Php\Runtime\FieldType;
use Aaronlow\Proto2Php\Runtime\Label;
use Aaronlow\Proto2Php\Runtime\Message;
final class Invoice extends Message
{
public static function fields(): array
{
return [
new Field(1, 'id', FieldType::INT32, Label::REQUIRED),
new Field(2, 'customer', FieldType::STRING, Label::OPTIONAL),
new Field(3, 'line_totals', FieldType::INT32, Label::REPEATED, packed: true),
];
}
}
$invoice = new Invoice()->set(1, 1001)->set(2, 'Jane Doe')->add(3, 1500)->add(3, 2200);
$invoice->serializeToString();A runnable version, with a round trip and isInitialized() check, lives at example/invoice.php:
php example/invoice.php| Method | Behavior |
|---|---|
get(int $number) |
Present value, or the declared/type default if absent (never creates presence) |
set(int $number, mixed $value) |
Sets a singular or repeated (whole-array) field; marks it present |
add(int $number, mixed $value) |
Appends one element to a repeated field |
has(int $number) |
True iff the field was explicitly set or parsed — proto2 explicit presence |
clear(int $number) |
Removes presence |
isInitialized() |
Recursively checks all required fields are present |
serializeToString() / parseFromString() / mergeFromString() |
Wire-format I/O; mergeFromString follows proto2 merge semantics (singular scalars last-wins, singular messages merge field-by-field, repeated appends) |
unknownFieldBytes() |
Raw bytes of fields not in this message's schema, preserved verbatim from parsing |
toArray() |
Present fields as a nested associative array (for JSON export, etc.); absent fields are omitted, not defaulted |
Unknown fields — including a field arriving with the wrong wire type — round-trip byte-for-byte through parse/serialize, as proto2 requires.
src/
Runtime/ Wire-format-agnostic message base: presence, merge, serialize
Wire/ Varint, tag, and length-delimited encode/decode primitives
Codegen/
Descriptor/ Hand-written subset of descriptor.proto (FileDescriptorProto, etc.)
and the protoc plugin protocol (CodeGeneratorRequest/Response)
Generator/ Proto-name → PHP-namespace resolution and PHP source emission
Plugin.php protoc plugin entry point (stdin/stdout framing)
bin/
protoc-gen-php2 The executable protoc invokes
composer test # PHPUnit
composer analyse # PHPStan (level 7)
composer cs-check # php-cs-fixer, dry run
composer cs-fix # php-cs-fixer, apply- Proto2 extensions (
extendblocks) are not yet implemented — extension field numbers land inunknownFieldBytes()rather than being parsed. - Services and RPC definitions are ignored (this is a message library, not an RPC framework).
- An unrecognized enum value is stored as its raw int rather than routed to unknown fields.
Contributions are welcome — this project exists because the alternative was staring at an unmaintained repo, so PRs that keep it from becoming one are especially appreciated. The "Known limitations" above are all fair game (extensions support would be particularly well-received), and so are bug reports on messages that don't round-trip correctly.
Before opening a PR, please make sure composer test, composer analyse, and composer cs-check all pass.
MIT