apache/thrift
Apache Thrift is a scalable cross-language services framework combining a code generation engine with a software stack to build efficient RPC clients and servers across languages such as C++, Java, Python, PHP, Ruby, Node.js, and more.
apache/thrift on Packagist is only the PHP runtime (transports, protocols, server loops). The thrift compiler is a separate binary — you need both, and their versions must match.
composer require apache/thrift
# compiler: pick ONE and pin it everywhere
brew install thrift # macOS
apt-get install thrift-compiler # Debian/Ubuntu
docker run --rm -v "$PWD:/d" thrift:0.20 thrift -version
Minimal first use case — call an existing Thrift service (Cassandra, HBase, an internal Java/Go/Python service) from Laravel:
.thrift IDL from the service owner. Do not hand-write structs.thrift --gen php:server,psr4,nsglobal=App\\Thrift -out storage/thrift service.thrift
psr4 generator option emits a Types.php map you can feed to Composer):"autoload": { "psr-4": { "App\\Thrift\\": "storage/thrift/" } }
php artisan tinker before writing any app code:$socket = new TSocket('127.0.0.1', 9090);
$socket->setRecvTimeout(2000);
$transport = new TFramedTransport(new TBufferedTransport($socket));
$protocol = new TBinaryProtocol($transport);
$client = new App\Thrift\MyServiceClient($protocol);
$transport->open();
$result = $client->ping();
$transport->close();
Where to look first: the generated *Client.php / *Processor.php (that is your API surface), then Thrift/Transport/ and Thrift/Protocol/ in vendor/apache/thrift/lib/php/lib/Thrift/ to understand which transport stack the server expects.
Config + container binding. Never construct clients inline. One config block per remote service, one factory, one binding:
// config/thrift.php
'catalog' => [
'host' => env('CATALOG_THRIFT_HOST'),
'port' => (int) env('CATALOG_THRIFT_PORT', 9090),
'transport' => 'framed', // framed | buffered | http
'protocol' => 'binary', // binary | compact | json
'timeout_ms' => 2000,
],
A thin client wrapper is the main integration pattern. It owns connect/close, translates exceptions, and gives you a seam for retries and metrics:
final class ThriftClientFactory
{
public function make(array $cfg): TTransport
{
$socket = new TSocket($cfg['host'], $cfg['port']);
$socket->setSendTimeout($cfg['timeout_ms']);
$socket->setRecvTimeout($cfg['timeout_ms']);
$transport = $cfg['transport'] === 'framed'
? new TFramedTransport(new TBufferedTransport($socket), 512 * 1024)
: new TBufferedTransport($socket);
return $transport;
}
}
final class CatalogGateway
{
public function __construct(private ThriftClientFactory $factory, private array $cfg) {}
public function call(callable $fn)
{
$transport = $this->factory->make($this->cfg);
try {
$transport->open();
$client = new CatalogClient($this->protocol($transport));
return $fn($client);
} catch (TTransportException $e) {
report($e);
throw new RemoteServiceUnavailable('catalog', previous: $e);
} finally {
$transport->close(); // always
}
}
}
One connection per request, scoped. Bind the gateway as scoped() (not singleton()); under PHP-FPM the process ends anyway, and a stale singleton socket is the #1 source of "TSocket: timed out reading" in Octane.
Transport stack must match the server exactly. Framed vs buffered and binary vs compact are not interchangeable — negotiate this with the service owner and put it in config, not in code. Cassandra, for instance, requires framed + binary.
Serialization without the network is a strong pattern for queue payloads and test fixtures:
$buf = new TMemoryBuffer();
$proto = new TBinaryProtocol($buf);
$obj->write($proto);
$bytes = $buf->getBuffer(); // store in Redis / pass to a Go consumer
Contract-first across languages. Keep the .thrift files in one shared repo (or a contracts/ directory), generate into every consumer in CI, and treat a .thrift change as a versioned API break. Commit generated PHP or generate in a Docker build stage — pick one, never both.
Server side, if you expose services. Thrift servers are long-running processes, not HTTP requests. Run TSimpleServer/TThreadedServer from php artisan thrift:serve under Supervisor, or (more commonly in Laravel) skip the PHP server entirely and let a Go/Java service own the endpoint. If you must serve over HTTP, use the generated processor plus TPhpStream::in()/out() inside a single route action — no framework middleware applies to those bytes.
Testing. Generate a stub implementation of the generated *If interface and unit-test your handlers directly; use TMemoryBuffer round-trips for serialization tests. For integration, point the gateway at a container in docker-compose and assert on error translation, not on wire behaviour.
Multiplexing. If the remote host serves several services on one port, wrap the protocol in TMultiplexedProtocol with the correct service name — the error if you forget is a generic TApplicationException: Unknown function.
Bad version in readMessageBegin, garbage field IDs, or structs that read as empty. Pin the compiler in CI (thrift -version assertion) and pin apache/thrift to the same constraint.strictRead/strictWrite mismatch. TBinaryProtocol defaults to strictWrite = true; old Java servers with strictRead = false will reject your calls. Only flip these when the counterparty forces it.storage/thrift (or wherever you generate) from PHPStan, Pint, and PHPCS, but do keep it in autoload. Use the psr4 generator option; the legacy ThriftClassLoader was removed in recent versions, so old tutorials will not work.TProtocolException: Required field ... is unset appears when you finally call the RPC — validate your DTO before the call if the payload is user-driven.isset($obj->field) is the only reliable "was it sent" check. Always check before reading to distinguish 0/'' from absent.optional over required when adding fields. This is your only real schema-evolution tool — there is no reflection fallback.TSocket is blocking, single-threaded, and has no async client. Under PHP-FPM accept the per-request connect cost (~1ms on a LAN); with Octane, keep one client per worker and add a health-check call plus reconnect-on-TTransportException.setSendTimeout/setRecvTimeout; wrap calls in try/finally so close() runs on exceptions and you don't leak FDs.TSSLSocket parity with other languages — front the service with stunnel/Envoy, or use THttpClient over an HTTPS base URL.i64 and integers. PHP ints are 64-bit on modern platforms, but the JSON protocol represents i64 as a string; TCompactProtocol uses zigzag varints that will silently mis-decode if the runtime version differs. Watch for ID fields that come back as strings.\Exception subtypes you recognize. Server-thrown IDL exceptions deserialize into their generated classes; transport/protocol faults throw TTransportException/TProtocolException; everything else arrives as TApplicationException. Map all three at the gateway boundary or you leak Thrift types into controllers.TMemoryBuffer + bin2hex the framed bytes, compare against a known-good client in another language, and check the server log for the function name. thrift --gen php output has enough structure that a dd() on the generated client shows exactly which args struct went out.TTransport yourself to add tracing/metrics (bytes sent/received, latency) or to route through an existing pooled HTTP client. That's the supported seam — subclassing generated clients is not.How can I help you explore Laravel packages today?