A full-featured FoundationDB client for PHP, built on PHP FFI bindings to libfdb_c.
- Full transactional API — get, set, clear, commit, with automatic retry loop and conflict resolution
- Range reads — lazy Generator-based iteration and eager fetching (
getRangeAll) - Atomic operations — add, bitAnd, bitOr, bitXor, max, min, byteMax, byteMin, compareAndClear, versionstamps
- Snapshot reads — conflict-free reads within transactions
- Watches — get notified when a key changes (
watch,getAndWatch,setAndWatch,clearAndWatch) - Tuple layer — binary-compatible with Python/Go/Java tuple encoding, with
compare()andhasIncompleteVersionstamp() - Subspace — key prefix management with
packWithVersionstampsupport - Directory layer — hierarchical namespace management with HighContentionAllocator and partitions
- Typed options — fluent API for network, database, and transaction options
- Multi-tenancy — Tenant support with
getId()and scoped transactions - Admin client — cluster administration (tenant management, server exclusion, configuration, status, force recovery)
- Database monitoring —
getMainThreadBusyness(),getClientStatus() - Locality API —
getBoundaryKeys()for data distribution analysis - Key utilities —
strinc(),printable(),prefixRange() - Error predicates —
isRetryable(),isMaybeCommitted(),isRetryableNotCommitted() - Connection string support —
openWithConnectionString() - Explicit lifecycle management —
Database::close()
- PHP 8.2+
ext-ffi— PHP FFI extensionext-gmp— optional; required for arbitrary-precision integers in the tuple layerlibfdb_c— FoundationDB C client library (libfdb_c.soon Linux,libfdb_c.dylibon macOS, install guide)
composer require crazy-goat/fdb-phpMake sure libfdb_c.so is installed and accessible. On Ubuntu/Debian:
wget https://github.com/apple/foundationdb/releases/download/7.3.75/foundationdb-clients_7.3.75-1_amd64.deb
sudo dpkg -i foundationdb-clients_7.3.75-1_amd64.debBy default the library is loaded by its bare soname (libfdb_c.so on Linux,
libfdb_c.dylib on macOS), which is resolved through the dynamic linker
search path (LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, RUNPATH, …). A
malicious library earlier on that path would execute
arbitrary code inside the PHP process. To eliminate this attack surface, pin
the absolute path of the library:
export FDB_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libfdb_c.soThe path must be absolute; relative paths are rejected with an
InvalidArgumentException. When the variable is not set, the bare soname is
used as before.
use CrazyGoat\FoundationDB\FoundationDB as FDB;
use CrazyGoat\FoundationDB\Transaction;
FDB::apiVersion(730);
$db = FDB::open();
// Simple read/write (auto-transact with retry)
$db->set('hello', 'world');
echo $db->get('hello'); // "world"
// Transactional
$db->transact(function (Transaction $tr) {
$tr->set('key1', 'value1');
$tr->set('key2', 'value2');
$value = $tr->get('key1')->await();
});use CrazyGoat\FoundationDB\RangeOptions;
// Lazy iteration (Generator) — memory-efficient for large ranges
$db->transact(function (Transaction $tr) {
foreach ($tr->getRangeStartsWith('users/') as $kv) {
echo $kv->key . ' = ' . $kv->value . "\n";
}
});
// Eager fetching — all results in memory at once
$results = $db->getRangeAllStartsWith('users/');
// With options
$results = $db->getRangeStartsWith('users/', new RangeOptions(
limit: 100,
reverse: true,
));use CrazyGoat\FoundationDB\Tuple\Tuple;
$packed = Tuple::pack(['users', 42, 'name']);
$unpacked = Tuple::unpack($packed); // ['users', 42, 'name']
// Sort-order preserving — binary comparison matches logical order
assert(Tuple::pack([1]) < Tuple::pack([2]));
assert(Tuple::pack(['a']) < Tuple::pack(['b']));
// Compare tuples
Tuple::compare(['users', 1], ['users', 2]); // -1
// Get range boundaries for a tuple prefix
[$begin, $end] = Tuple::range(['users']);use CrazyGoat\FoundationDB\Subspace;
$users = new Subspace(['users']);
$db->transact(function (Transaction $tr) use ($users) {
$tr->set($users->pack([42, 'name']), 'Alice');
$tr->set($users->pack([42, 'email']), 'alice@example.com');
foreach ($tr->getRangeStartsWith($users->pack([42])) as $kv) {
$tuple = $users->unpack($kv->key);
// [42, 'name'] or [42, 'email']
}
});use CrazyGoat\FoundationDB\Directory\DirectoryLayer;
$dir = new DirectoryLayer();
$users = $dir->createOrOpen($db, ['app', 'users']);
$orders = $dir->createOrOpen($db, ['app', 'orders']);
// Each directory gets a unique short prefix — no key collisions
$db->set($users->pack([42, 'name']), 'Alice');
$db->set($orders->pack([1001]), 'order data');
// List, move, remove
$subdirs = $dir->list($db, ['app']); // ['users', 'orders']
$dir->move($db, ['app', 'users'], ['app', 'customers']);
$dir->remove($db, ['app', 'orders']);// Integer atomic ops accept int directly — no pack() needed
$db->add('counter', 1);
$db->add('counter', 10);
$db->max('high_score', 999);
$db->min('response_time', 42);
// Bitwise operations
$db->bitOr('flags', 0b00001100);
$db->bitAnd('flags', 0b11110011);
$db->bitXor('flags', 0b00000001);
// Read integer values — no unpack() needed
$count = $db->getInt('counter'); // ?int
$flags = $db->getInt('flags'); // ?int
// Compare and clear (raw bytes)
$db->compareAndClear('temp', 'expected_value');// Read-only transaction with snapshot isolation
$value = $db->readTransact(function ($snap) {
return $snap->get('key')->await();
});
// Or use snapshot within a write transaction
$db->transact(function (Transaction $tr) {
// No conflict range added — won't cause transaction conflicts
$value = $tr->snapshot()->get('frequently-read-key')->await();
// Selectively add conflict for keys you care about
$tr->addReadConflictKey('important-key');
});// Watch a key — returns FutureVoid that resolves when value changes
$watch = $db->watch('config:version');
// Get current value AND set up watch atomically
[$value, $watch] = $db->getAndWatch('config:version');
echo "Current: {$value}\n";
// Set value AND watch for future changes
$watch = $db->setAndWatch('config:version', '2');
// Clear value AND watch
$watch = $db->clearAndWatch('config:version');// Create tenant via admin client
$db->admin()->createTenant('tenant-a');
// Open tenant — transactions are scoped to tenant's key space
$tenant = $db->openTenant('tenant-a');
$tr = $tenant->createTransaction();
$tr->set('key', 'value'); // isolated to tenant-a
$tr->commit()->await();
// Get tenant ID
$id = $tenant->getId();$admin = $db->admin();
// Tenant management
$admin->createTenant('new-tenant');
$admin->deleteTenant('old-tenant');
$tenants = $admin->listTenants();
// Cluster status
$status = $admin->getClusterStatus(); // array<string, mixed>
$isConsistent = $admin->consistencyCheck();
// Server management
$admin->excludeServer('10.0.0.1:4500');
$admin->includeServer('10.0.0.1:4500');
$admin->rebootWorker('10.0.0.1:4500');
// Configuration
$admin->configure('double ssd');// Network options (before opening database)
FDB::networkOptions()
->setTraceEnable('/var/log/fdb/')
->setTraceFormat('json');
// Database-level defaults
$db->options()
->setTransactionTimeout(10_000)
->setTransactionRetryLimit(5);
// Transaction options (fluent API)
$db->transact(function (Transaction $tr) {
$tr->options()
->setTimeout(5000)
->setRetryLimit(3)
->setPriorityBatch();
// ...
});use CrazyGoat\FoundationDB\FDBException;
try {
$db->transact(function (Transaction $tr) {
$tr->set('key', 'value');
});
} catch (FDBException $e) {
echo "FDB error {$e->fdbCode}: {$e->getMessage()}\n";
$e->isRetryable(); // safe to retry?
$e->isMaybeCommitted(); // may have committed?
$e->isRetryableNotCommitted(); // safe to retry, definitely not committed?
}use CrazyGoat\FoundationDB\KeyUtil;
$end = KeyUtil::strinc('prefix'); // increment last byte
$readable = KeyUtil::printable("\x00\x01\xFF"); // '\x00\x01\xff'
[$begin, $end] = KeyUtil::prefixRange('users/'); // ['users/', 'users0']$busyness = $db->getMainThreadBusyness(); // 0.0 to 1.0
$status = $db->getClientStatus(); // JSON string
$statusArray = $db->getClientStatus(asArray: true); // parsed array, consistent with AdminClient::getClusterStatus()use CrazyGoat\FoundationDB\Locality;
$boundaries = Locality::getBoundaryKeys($db, "\x00", "\xFF");$db = FDB::openWithConnectionString('my_cluster:abc123@127.0.0.1:4500');Detailed documentation is available in the docs/ directory:
| Guide | Description |
|---|---|
| Getting Started | Installation, configuration, first program |
| Transactions | Transaction lifecycle, retry loops, snapshot reads, conflict ranges |
| Tuple Layer | Binary encoding, types, comparison, versionstamps |
| Subspaces | Key prefix management, nesting, range queries |
| Directory Layer | Hierarchical namespaces, partitions, HighContentionAllocator |
| Range Reads | Lazy vs eager fetching, KeySelector, StreamingMode |
| Atomic Operations | Counters, bitwise ops, compare-and-clear |
| Watches | Key monitoring, getAndWatch, setAndWatch |
| Tenants | Multi-tenancy, isolated key spaces |
| Blob Granules | Blobbify, list, verify, flush and purge ranges |
| Admin Client | Cluster administration, tenant management, status |
| Options | Network, database, and transaction options |
| Error Handling | FDBException, error predicates, retry logic |
| Advanced | Locality, KeyUtil, monitoring, Futures, lifecycle |
| Binding Tester | Cross-binding conformance via the upstream bindingtester |
See also the examples/ directory for runnable PHP scripts.
FoundationDB (entry point)
├── NativeClient (FFI singleton: libfdb_c + libpthread + libdl)
├── Database (FDBDatabase*, retry loop, convenience methods)
│ ├── Transaction (full read/write/atomic/commit)
│ │ ├── ReadTransaction (read operations base)
│ │ └── Snapshot (conflict-free reads)
│ ├── Tenant (multi-tenancy, scoped transactions)
│ └── AdminClient (cluster administration via Special Keys)
├── Future hierarchy (8 types wrapping FDBFuture*)
├── Tuple layer (binary encoding, cross-language compatible)
├── Subspace (key prefix management)
├── Directory layer
│ ├── DirectoryLayer (create/open/move/remove/list/exists)
│ ├── DirectorySubspace (directory + subspace)
│ ├── DirectoryPartition (isolated sub-tree)
│ └── HighContentionAllocator (unique prefix allocation)
├── Locality (data distribution, boundary keys)
├── KeyUtil (strinc, printable, prefixRange)
├── Error handling (FDBException, error predicates)
└── Option wrappers (NetworkOptions, DatabaseOptions, TransactionOptions)
The development process and the project commands are described in AGENTS.md and docs/workflow.md.
- PHP 8.2+ with
ext-ffi ext-gmp— optional; required for arbitrary-precision integers in the tuple layer- Docker + Docker Compose (for integration tests)
- Composer
- shellcheck, hadolint and actionlint (lint; versions and checksums pinned in
.lint-tools.env)
bin/lint.sh needs composer install first and is supported on Linux x86_64 (as in CI) and
macOS. On macOS install the tools with brew install shellcheck hadolint actionlint; on other
platforms download the release asset for your architecture (CI pins the Linux x86_64 assets).
composer installcomposer lint # All checks (PHP-CS-Fixer, Rector, PHPStan, shellcheck, hadolint, actionlint)
composer lint:fix # Auto-fix code style
composer phpstan # PHPStan level 9
composer cs # PHP-CS-Fixer (dry-run)
composer cs-fix # Fix code style with PHP-CS-Fixer
composer rector # Rector dry-runcomposer test:unit # Unit tests (no FDB required)Integration tests require a running FoundationDB instance:
docker compose up -d
docker compose exec php vendor/bin/phpunit --testsuite=Integration
docker compose down -vThe five FDB services use restart: unless-stopped: RebootWorkerTest asks a storage
worker to suspend, then exit, and the container must come back for the cluster to stay
healthy. docker compose down still stops the stack.
The php service runs as HOST_UID:HOST_GID (default 1000:1000) so the caches and
dependencies it writes into the bind-mounted checkout belong to you. bin/worktree.sh
writes both values to .env.worktree; if your UID/GID is not 1000 and you run
docker compose up -d without loading that file, export HOST_UID=$(id -u) and
HOST_GID=$(id -g) first.
If you used the stack before this change, the checkout can still contain root-owned
caches or vendor/ that the container user cannot rewrite, and an existing
.env.worktree can lack HOST_UID/HOST_GID. Fix this once with
sudo chown -R "$(id -u):$(id -g)" . (or delete .php-cs-fixer.cache,
.phpunit.cache and vendor/), then add HOST_UID=$(id -u) and
HOST_GID=$(id -g) to .env.worktree. Worktrees created by bin/worktree.sh get
them automatically. This is a one-time upgrade step, not something to do on every
run.
Cross-binding conformance via the upstream binding tester (nightly CI):
make bindingtester # tuple + api + directory suitesSee docs/bindingtester.md for details.
MIT — see LICENSE.