leric/php-thrift
Apache Thrift PHP library for building cross-language RPC clients and servers. Includes generated PHP types, TBinaryProtocol serialization, PSR-4 class loading, and optional APCu caching for sockets. Requires PHP 5+.
This package is a Composer wrapper around the Apache Thrift PHP runtime library (Thrift\Transport\*, Thrift\Protocol\*, Thrift\Exception\*). The README is essentially the upstream license plus the old Apache "copy lib/php/lib into your codebase" instructions — ignore those instructions. With Composer you install the runtime, then you generate service-specific code from a .thrift IDL file yourself.
1. Install and verify autoloading. The README says nothing about the package name or autoload map, so verify it yourself before writing code:
composer require leric/php-thrift
composer dump-autoload -o
php artisan tinker --execute="var_dump(class_exists(\Thrift\Transport\TSocket::class), class_exists(\Thrift\Protocol\TBinaryProtocol::class));"
If both are true, the Thrift\ namespace is mapped and you are good. If not, inspect vendor/leric/php-thrift/composer.json and add an explicit autoload.psr-4 entry in your app's composer.json pointing Thrift\\ at vendor/leric/php-thrift/lib/Thrift (or wherever lib lives).
2. Generate the client code. You need the thrift compiler binary (Docker: thrift:0.20 or similar) — it is not shipped with this package:
thrift -r --gen php:psr4 -out app/Thrift resources/thrift/user_service.thrift
Use the psr4 generator option if your compiler supports it; that emits namespaced classes PSR-4 can autoload directly. Without it you get non-namespaced classes that rely on require_once 'Types.php', which means adding them to Composer's classmap (see Gotchas).
3. Where to look first. In vendor/leric/php-thrift/lib/Thrift/:
Transport/TSocket.php — the TCP connection (host, port, timeouts)Transport/TBufferedTransport.php, Transport/TFramedTransport.php — must match the serverProtocol/TBinaryProtocol.php, Protocol/TCompactProtocol.php — must match the serverException/TException.php, Exception/TApplicationException.php, Exception/TTransportException.phpTransport/TSocketPool.php, Protocol/TMultiplexedProtocol.php — failover and multi-service routing4. First real use case. A service provider that lazily opens a client, calls a method, and always closes:
namespace App\Thrift;
use Thrift\Protocol\TBinaryProtocol;
use Thrift\Transport\TBufferedTransport;
use Thrift\Transport\TSocket;
class UserServiceConnection
{
public function __construct(private array $config) {}
public function client(): \App\Thrift\UserService\Client
{
$socket = new TSocket(
$this->config['host'],
$this->config['port'],
$this->config['timeout_ms'], // connect/send timeout
$this->config['timeout_ms'] // recv timeout (arg order varies by version)
);
$transport = new TBufferedTransport($socket);
$protocol = new TBinaryProtocol($transport); // framed? see Gotchas
$client = new \App\Thrift\UserService\Client($protocol);
$transport->open();
return $client; // caller must close; better: wrap in a callback
}
}
Prefer a withClient(callable $fn) helper that opens, runs, and closes in finally — see the next section.
Wrap the generated client behind an interface. The generated Client class is concrete, takes a TProtocol in its constructor, and cannot be mocked cleanly in a Laravel test where you want $this->mock(UserDirectory::class). Define one interface per remote service and an adapter that maps Thrift structs to your own DTOs/arrays:
interface UserDirectory {
public function find(int $id): ?UserData;
}
class ThriftUserDirectory implements UserDirectory
{
public function __construct(private UserServiceConnection $conn) {}
public function find(int $id): ?UserData
{
return $this->conn->withClient(function ($client) use ($id) {
try {
$u = $client->getUser($id);
} catch (\Thrift\Exception\TApplicationException $e) {
return null; // or throw a domain exception
}
return new UserData($u->id, $u->name, $u->email);
});
}
}
Bind it in a service provider with $this->app->bind(UserDirectory::class, ThriftUserDirectory::class) (bind, not singleton — see Gotchas on Octane).
Connection lifecycle helper. One place that owns open/close/retry, so every call site gets timeouts and cleanup:
public function withClient(callable $fn, array $retryOn = [])
{
$attempts = 0;
beginning:
$socket = new TSocket(...);
$transport = new TBufferedTransport($socket);
$transport->open();
try {
return $fn(new Client(new TBinaryProtocol($transport)));
} catch (\Thrift\Exception\TTransportException $e) {
if ($attempts++ < 1) { $transport->close(); goto beginning; }
throw $e;
} finally {
$transport->close();
}
}
Only retry on TTransportException and only for idempotent methods — a createOrder call that timed out on recv may have already mutated state.
Config in config/services.php. Thrift endpoints, protocol/framing choice, and timeouts belong in config, not in constructors, so staging and prod differ by env var:
'user_service' => [
'host' => env('USER_THRIFT_HOST', '127.0.0.1'),
'port' => (int) env('USER_THRIFT_PORT', 9090),
'timeout_ms' => (int) env('USER_THRIFT_TIMEOUT_MS', 500),
'framed' => env('USER_THRIFT_FRAMED', false),
'multiplexed' => env('USER_THRIFT_MULTIPLEXED', false),
],
Then build the protocol conditionally: TFramedTransport vs TBufferedTransport, and wrap in TMultiplexedProtocol($protocol, 'UserService') when the server hosts several services on one port.
High availability with TSocketPool. For multi-host services, Thrift\Transport\TSocketPool accepts an array of hosts, retries, and remembers dead nodes. Feed it from config and hand it to the same transport/protocol stack. This is the cheapest resilience win available in this library — there is no built-in circuit breaker.
Move RPCs out of the request cycle where possible. The runtime is fully blocking; a 500 ms remote call inside a Blade render is a 500 ms TTFB hit. Push non-essential writes into queued jobs (dispatch(new SyncUserToDirectory($id))) and keep only the calls the response actually needs synchronous. If you need fan-out, issue parallel queue jobs and collect via Bus::batch(), since PHP has no futures here.
Batch in the IDL, not in the loop. foreach ($ids as $id) { $client->getUser($id); } is N round trips. When you own the service, add a getUsers(list<i64> ids) method — one serialization pass, one TCP exchange. This is almost always a bigger win than any PHP-side optimization.
Codegen as a build artifact. Commit generator output, and pin the compiler version in Docker so the codegen stays in lockstep with the runtime library. Add a Composer script:
"scripts": {
"thrift:gen": "docker run --rm -v $PWD:/w thrift:0.20 thrift -r --gen php:psr4 -out /w/app/Thrift /w/resources/thrift/user_service.thrift && composer dump-autoload"
}
Regenerate whenever the .thrift file changes; never hand-edit app/Thrift/*.
Test the protocol layer without a server. Thrift\Transport\TMemoryBuffer (or a small in-memory TTransport implementation) lets you round-trip a struct through write/read to prove your DTO mapping and to prove generated code handles missing optional fields. For behaviour tests, mock your UserDirectory interface — you should never open a socket in a unit test.
Transport/protocol mismatch is a hang, not an error. If the server is framed (TFramedTransport) and your client is buffered/unframed, you generally get a stuck read rather than a clean exception. Always set an explicit socket timeout, and if you see requests "succeed" on the wire but the client never returns, check framing first, then binary-vs-compact protocol.
Watch the TSocket constructor argument order. It has changed across versions (and the README gives you nothing). Open the installed TSocket.php and read the signature before assuming (host, port, timeout). Getting this wrong silently gives you a default 1s-ish recv timeout or no timeout at all. If setRecvTimeout() / setSendTimeout() exist on your version, use them explicitly instead of relying on the constructor.
Autoloading is the most common setup failure. If the compiler didn't emit namespaced code, the generated Client/Types classes are global and require_once-based — PSR-4 will not find them. Add the output directory to autoload.classmap and run composer dump-autoload. Also remember that generated code often depends on Types.php for enums/constants; missing it produces Class not found deep inside a deserialize call.
Exception hierarchy, and Laravel's handler. Catch in this order: generated service exceptions (e.g. UserNotFoundException) → TApplicationException (the server understood you and rejected the call — usually a 4xx-class problem) → TTransportException/TException (network/protocol — 503/504-class). Uncaught, all of them become a generic 500 in Handler::render. Map them explicitly so your API returns honest status codes, and log the TApplicationException::getMessage() — the server's reason string is the only diagnostic you get.
i64 is a precision trap, not just a size trap. On 32-bit PHP it can surface as a string; on 64-bit it's a PHP int, but any ID above 2^53 will be corrupted the moment it's JSON-encoded into an API response and read by a JS client. Cast i64 IDs to string at the DTO boundary if they're snowflake-style.
PHP 8 deprecations will bite. This library (like the upstream PHP runtime) predates PHP 8 and can emit deprecations — dynamic properties, null-to-non-nullable params. Laravel's exception handler converts warnings/deprecations into ErrorException, which means a benign notice inside TBinaryProtocol::readString() can blow up a request. If you hit this, either patch the vendor code via a fork, add a narrow set_error_handler around the call, or set error_reporting to exclude E_DEPRECATED in that context. Pin the package to an exact commit — 38 stars and 0 dependents means there is no maintenance signal, so treat it as a vendored dependency and be prepared to fork.
Don't hold connections across requests or jobs. Under Octane or long-lived queue workers, a cached TSocket goes stale and every subsequent call fails or hangs. Bind clients as bind (not singleton), reconnect per unit of work, and close in finally. Queue workers especially: idle sockets die between jobs.
Generated clients expose send_* / recv_* separately. Useful for one-way (oneway void) methods and for pipelining several requests before reading responses, but only do this if you understand the transport semantics — mismatched send/recv pairing corrupts the stream.
Check whether upstream apache/thrift is enough before adopting this fork. The official Packagist package ships the PHP runtime for many versions and gets security/CI attention. This fork exists mainly to make the runtime installable via Composer; if the official package autoloads Thrift\* for your PHP version, you get the same classes with better maintenance. Whichever you pick, don't install both — duplicate Thrift\ class definitions produce confusing fatal errors.
Extension points worth knowing. Subclass TSocket and override open() to emit metrics (latency, endpoint) without touching call sites; implement TTransport yourself to log raw byte counts; wrap a TProtocol to log method names. All of these compose cleanly because the stack is Transport → Protocol → Client. Also note TBinaryProtocolAccelerated only helps if the thrift_protocol C extension is installed — otherwise use plain TBinaryProtocol.
How can I help you explore Laravel packages today?