Skip to content

PHP Language Support

Status: ✅ Full Support
Example: examples/php-twirp/

PHP support provides Protocol Buffer messages and Twirp RPC framework integration for building modern PHP APIs.

  • Protocol Buffer Messages - Generate PHP classes for all your protobuf messages
  • gRPC Support - Full client and server code generation
  • RoadRunner Integration - High-performance application server with persistent workers
  • Framework Integration - Built-in support for Laravel and Symfony
  • Async PHP - ReactPHP, Swoole/OpenSwoole, and PHP 8.1+ Fibers support
  • Developer Experience - Auto-generated composer.json, examples, and helper scripts
PluginDescriptionGenerated Files
protoc-gen-phpMessage classes*.php, GPBMetadata/*.php
protoc-gen-twirp_phpTwirp RPC framework*Client.php, *Server.php
languages.php = {
enable = true;
outputPath = "gen/php";
namespace = "MyApp\\Proto";
};
languages.php = {
enable = true;
outputPath = "gen/php";
namespace = "MyApp\\Proto";
options = [
"aggregate_metadata" # Single metadata file
];
twirp = {
enable = true;
options = [
"generate_client=true"
"generate_server=true"
];
};
};
proto/example/v1/service.proto
syntax = "proto3";
package example.v1;
service HelloService {
rpc Hello(HelloRequest) returns (HelloResponse);
rpc ListGreetings(ListGreetingsRequest) returns (ListGreetingsResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}
message HelloRequest {
string name = 1;
string language = 2;
}
message HelloResponse {
string greeting = 1;
string language = 2;
}
message User {
string id = 1;
string name = 2;
string email = 3;
repeated string roles = 4;
map<string, string> metadata = 5;
UserStatus status = 6;
}
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0;
USER_STATUS_ACTIVE = 1;
USER_STATUS_INACTIVE = 2;
USER_STATUS_SUSPENDED = 3;
}
message CreateUserRequest {
User user = 1;
}
message CreateUserResponse {
User user = 1;
bool success = 2;
string message = 3;
}
message ListGreetingsRequest {
int32 limit = 1;
string language = 2;
}
message ListGreetingsResponse {
repeated HelloResponse greetings = 1;
int32 total_count = 2;
}
<?php
require_once 'vendor/autoload.php';
use MyApp\Proto\Example\V1\HelloServiceServer;
use MyApp\Proto\Example\V1\HelloRequest;
use MyApp\Proto\Example\V1\HelloResponse;
use MyApp\Proto\Example\V1\CreateUserRequest;
use MyApp\Proto\Example\V1\CreateUserResponse;
use MyApp\Proto\Example\V1\User;
use MyApp\Proto\Example\V1\UserStatus;
class HelloServiceImpl
{
private array $users = [];
private array $greetings = [
'en' => 'Hello',
'es' => 'Hola',
'fr' => 'Bonjour',
'de' => 'Guten Tag'
];
public function Hello(HelloRequest $request): HelloResponse
{
$language = $request->getLanguage() ?: 'en';
$greeting = $this->greetings[$language] ?? $this->greetings['en'];
$response = new HelloResponse();
$response->setGreeting($greeting . ', ' . $request->getName() . '!');
$response->setLanguage($language);
return $response;
}
public function CreateUser(CreateUserRequest $request): CreateUserResponse
{
$user = $request->getUser();
$userId = $user->getId() ?: uniqid('user_');
// Set ID if not provided
$user->setId($userId);
$user->setStatus(UserStatus::USER_STATUS_ACTIVE);
// Store user
$this->users[$userId] = $user;
$response = new CreateUserResponse();
$response->setUser($user);
$response->setSuccess(true);
$response->setMessage('User created successfully');
return $response;
}
public function ListGreetings(ListGreetingsRequest $request): ListGreetingsResponse
{
$language = $request->getLanguage() ?: 'en';
$limit = $request->getLimit() ?: 10;
$greetings = [];
$names = ['World', 'PHP', 'Protobuf', 'Twirp'];
for ($i = 0; $i < min($limit, count($names)); $i++) {
$greeting = new HelloResponse();
$greeting->setGreeting($this->greetings[$language] . ', ' . $names[$i] . '!');
$greeting->setLanguage($language);
$greetings[] = $greeting;
}
$response = new ListGreetingsResponse();
foreach ($greetings as $greeting) {
$response->getGreetings()[] = $greeting;
}
$response->setTotalCount(count($greetings));
return $response;
}
}
// Create and serve
$impl = new HelloServiceImpl();
$server = new HelloServiceServer($impl);
// Handle HTTP request
$method = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];
$headers = getallheaders();
$body = file_get_contents('php://input');
try {
$server->handle($method, $uri, $headers, $body);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
tests/ProtoTest.php
<?php
use PHPUnit\Framework\TestCase;
use MyApp\Proto\Example\V1\User;
use MyApp\Proto\Example\V1\UserStatus;
class ProtoTest extends TestCase
{
public function testUserCreation()
{
$user = new User();
$user->setId('test123');
$user->setName('Test User');
$user->setEmail('test@example.com');
$user->setStatus(UserStatus::USER_STATUS_ACTIVE);
$this->assertEquals('test123', $user->getId());
$this->assertEquals('Test User', $user->getName());
$this->assertEquals('test@example.com', $user->getEmail());
$this->assertEquals(UserStatus::USER_STATUS_ACTIVE, $user->getStatus());
}
public function testSerialization()
{
$user = new User();
$user->setName('Serialization Test');
$user->setEmail('serialize@test.com');
// Test binary serialization
$binary = $user->serializeToString();
$this->assertNotEmpty($binary);
$decoded = new User();
$decoded->mergeFromString($binary);
$this->assertEquals($user->getName(), $decoded->getName());
// Test JSON serialization
$json = $user->serializeToJsonString();
$this->assertJson($json);
$fromJson = new User();
$fromJson->mergeFromJsonString($json);
$this->assertEquals($user->getEmail(), $fromJson->getEmail());
}
public function testMapFields()
{
$user = new User();
$metadata = $user->getMetadata();
$metadata['key1'] = 'value1';
$metadata['key2'] = 'value2';
$this->assertCount(2, $user->getMetadata());
$this->assertEquals('value1', $user->getMetadata()['key1']);
}
}
{
"name": "myapp/proto-example",
"require": {
"php": "^8.1",
"google/protobuf": "^3.25"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"MyApp\\Proto\\": "gen/php/"
}
},
"scripts": {
"proto": "bufrnix",
"test": "phpunit tests/"
}
}
  1. Namespaces: Use proper PSR-4 namespaces matching your project structure
  2. Error Handling: Always catch TwirpError for RPC-specific errors
  3. Validation: Validate data before creating protobuf messages
  4. Serialization: Use binary format for performance, JSON for debugging
  5. Testing: Test both message creation and serialization/deserialization
  6. Autoloading: Configure Composer autoloading for generated classes
  • Directorygen/php/ - MyApp/ - Proto/ - Example/ - V1/ - HelloServiceClient.php - HelloServiceServer.php - GPBMetadata/ - Example/ - V1/ - Service.php
Terminal window
cd examples/php-twirp
nix develop
composer install
php -S localhost:8080 -t .
# In another terminal
php client.php

Ensure your composer.json includes the generated namespace:

{
"autoload": {
"psr-4": {
"MyApp\\Proto\\": "gen/php/"
}
}
}

Handle Twirp-specific errors properly:

try {
$response = $client->SomeMethod($request);
} catch (TwirpError $e) {
// Handle Twirp protocol errors
echo "Twirp Error: " . $e->getCode() . " - " . $e->getMessage();
} catch (Exception $e) {
// Handle other errors
echo "General Error: " . $e->getMessage();
}

For large messages, be aware of PHP’s memory limits:

ini_set('memory_limit', '256M');
OptionTypeDefaultDescription
enableboolfalseEnable PHP code generation
packagepackagephp with extensionsPHP package to use
outputPathstring”gen/php”Output directory for generated code
namespacestring”Generated”Base PHP namespace
metadataNamespacestring”GPBMetadata”Metadata namespace
classPrefixstring""Prefix for generated classes
OptionTypeDefaultDescription
composer.enablebooltrueEnable Composer integration
composer.autoInstallboolfalseAuto-install dependencies
OptionTypeDefaultDescription
grpc.enableboolfalseEnable gRPC generation
grpc.clientOnlyboolfalseGenerate only client code
grpc.serviceNamespacestring”Services”Service namespace suffix
OptionTypeDefaultDescription
roadrunner.enableboolfalseEnable RoadRunner server
roadrunner.workersint4Number of worker processes
roadrunner.maxJobsint64Jobs before worker restart
roadrunner.maxMemoryint128Memory limit per worker (MB)
roadrunner.tlsEnabledboolfalseEnable TLS support
use App\Proto\Example\V1\HelloRequest;
use App\Proto\Example\V1\HelloResponse;
// Create a request
$request = new HelloRequest();
$request->setName('World');
$request->setCount(5);
// Serialize to string
$data = $request->serializeToString();
// Deserialize from string
$decoded = new HelloRequest();
$decoded->mergeFromString($data);
echo $decoded->getName(); // "World"
use App\Proto\Example\V1\Services\GreeterServiceClient;
use Grpc\ChannelCredentials;
// Create client
$client = new GreeterServiceClient('localhost:9001', [
'credentials' => ChannelCredentials::createInsecure(),
]);
// Make unary call
[$response, $status] = $client->SayHello($request)->wait();
if ($status->code === \Grpc\STATUS_OK) {
echo $response->getMessage();
}
// Server streaming
$call = $client->SayHelloStream($request);
foreach ($call->responses() as $response) {
echo $response->getMessage() . "\n";
}
{
  description = "PHP gRPC with RoadRunner Generation using Bufrnix";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
    bufrnix.url = "github:conneroisu/bufrnix";
    bufrnix.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = {
    nixpkgs,
    flake-utils,
    bufrnix,
    ...
  }:
    flake-utils.lib.eachDefaultSystem (system: let
      pkgs = nixpkgs.legacyPackages.${system};

      # Create a bufrnix package for this project
      bufrnixPackage = bufrnix.lib.mkBufrnixPackage {
        inherit pkgs;

        config = {
          root = ./.;
          debug.enable = true;
          protoc = {
            sourceDirectories = ["./proto"];
            includeDirectories = ["./proto"];
          };
          languages.php = {
            enable = true;
            outputPath = "gen/php";
            namespace = "";
            metadataNamespace = "";

            # Enable gRPC support
            grpc = {
              enable = true;
              serviceNamespace = "Services";
              clientOnly = false; # Generate both client and server code
            };

            # Enable RoadRunner for server interfaces
            roadrunner = {
              enable = true;
              workers = 4;
              maxJobs = 100;
              maxMemory = 128;
            };
          };
        };
      };
    in {
      packages = {
        default = bufrnixPackage;
      };

      devShells.default = pkgs.mkShell {
        buildInputs = with pkgs; [
          # Core tools
          bufrnixPackage
          php82
          php82Packages.composer

          # Development tools
          git
          curl
          jq

          # PHP development
          php82Packages.psalm
          php82Packages.php-cs-fixer
        ];
      };
    });
}
worker.php
use Spiral\RoadRunner\GRPC\Server;
use Spiral\RoadRunner\Worker;
$server = new Server();
$server->registerService(
GreeterServiceInterface::class,
new GreeterService()
);
$server->serve(Worker::create());

Start the server:

Terminal window
./roadrunner-dev.sh start
// In a controller or service
public function __construct(
private GreeterServiceClient $greeterClient
) {}
public function greet(Request $request)
{
$grpcRequest = new HelloRequest();
$grpcRequest->setName($request->input('name'));
[$response, $status] = $this->greeterClient
->SayHello($grpcRequest)
->wait();
return response()->json([
'message' => $response->getMessage(),
]);
}
// In a controller
#[Route('/greet/{name}', name: 'greet')]
public function greet(
string $name,
GreeterServiceClient $client
): JsonResponse {
$request = new HelloRequest();
$request->setName($name);
[$response, $status] = $client->SayHello($request)->wait();
return $this->json([
'message' => $response->getMessage(),
]);
}
use App\Proto\Async\ReactPHPClient;
$client = new ReactPHPClient('localhost:9001');
$client->sendRequestAsync($request)->then(
function ($response) {
echo "Async: " . $response->getMessage();
}
);
$client->run();
use App\Proto\Async\SwooleGrpcServer;
$server = new SwooleGrpcServer('0.0.0.0', 9501);
$server->registerService(
GreeterServiceInterface::class,
new GreeterService()
);
$server->start();
use App\Proto\Async\FiberProtobufHandler;
$handler = new FiberProtobufHandler();
$results = $handler->processConcurrent([
'req1' => $request1->serializeToString(),
'req2' => $request2->serializeToString(),
]);

Install C extensions for better performance:

Terminal window
pecl install protobuf
pecl install grpc
.rr.yaml
grpc:
pool:
num_workers: 8 # Increase for more concurrency
max_jobs: 500 # More jobs before restart
supervisor:
max_worker_memory: 256 # Increase memory limit
; php.ini
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000

Extension not loaded

Terminal window
php -m | grep -E '(grpc|protobuf)'

RoadRunner workers crashing

Terminal window
# Check worker status
./roadrunner-dev.sh workers
# View logs
./roadrunner-dev.sh debug

Class not found errors

Terminal window
# Regenerate autoloader
composer dump-autoload
# Check namespace configuration
grep namespace .rr.yaml

Enable detailed logging:

.rr.yaml
logs:
level: debug
output: stdout

If you’re migrating from the deprecated Twirp support:

  1. Update your flake.nix to enable gRPC instead of Twirp
  2. Regenerate your code with buf generate
  3. Update service implementations to use RoadRunner interfaces
  4. Replace Twirp client calls with gRPC clients
  1. Use RoadRunner for production deployments
  2. Enable extensions for better performance
  3. Configure workers based on your workload
  4. Implement health checks for monitoring
  5. Use streaming for large data transfers
  6. Add metrics for observability