socialdept / atp-testnet
Stand up a local AT Protocol testnet for integration testing in PHP
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.0
- socialdept/atp-cbor: ^0.2
- symfony/process: ^7.0
- textalk/websocket: ^1.5
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.89
- phpunit/phpunit: ^11.0|^12.0
README
Stand up a local AT Protocol testnet for integration testing in PHP.
What is ATP Testnet?
ATP Testnet spins up a complete local AT Protocol network using Docker Compose for integration testing. No mocks, no fakes — real PLC directory, real PDS, real relay with firehose.
Quick Example
use SocialDept\AtpTestnet\Testnet; // Boot the network $testnet = Testnet::start(); // Create an account $alice = $testnet->createAccount('alice'); echo $alice->did; // did:plc:abc123... echo $alice->handle; // alice.test // Create a record $testnet->pds()->createRecord( 'app.bsky.feed.post', ['$type' => 'app.bsky.feed.post', 'text' => 'Hello from testnet!', 'createdAt' => gmdate('c')], $alice->accessJwt, ); // Query the PLC directory $doc = $testnet->plc()->getDocument($alice->did); // Subscribe to the firehose $testnet->requestRelayCrawl(); $frames = $testnet->relay()->consumeFirehose(timeoutMs: 3000); // Tear down $testnet->stop();
Installation
composer require socialdept/atp-testnet --dev
Requirements
- PHP 8.3+
- Docker & Docker Compose
- ~2 GB disk for Docker images on first build
Building Docker Images
The PLC directory and relay are built from source since they don't publish official Docker images. Images are built automatically on the first Testnet::start() call, or you can pre-build them:
# Build missing images (skips if already built) composer testnet:build # Force rebuild all images composer testnet:rebuild # Or use the binary directly vendor/bin/testnet-build vendor/bin/testnet-build --rebuild
The first build clones the repos and compiles Go and Node — this takes a few minutes. After that, starts are instant.
The relay image is patched during build to work in Docker's internal network (SSRF bypass, hostname validation, domain ban checks, and account host matching are all disabled via the RELAY_DISABLE_SSRF environment variable).
The PLC image is patched to support disabling rate limits via the PLC_DISABLE_RATE_LIMIT environment variable (enabled by default in the testnet compose file). The production PLC enforces 10 ops/hour per DID — this patch bypasses that for local testing.
If you update the package and the build-time patches change, force a rebuild with composer testnet:rebuild or vendor/bin/testnet-build --rebuild.
Services
| Service | Image | Default Port | Purpose |
|---|---|---|---|
| PLC Directory | Built from did-method-plc | 7100 | DID registration and PLC operations |
| PDS | ghcr.io/bluesky-social/pds:0.4 |
7102 | Account creation, repos, XRPC |
| Relay | Built from indigo | 7101 | Firehose relay, crawls PDS |
Each service also runs its own Postgres instance internally.
Usage with PHPUnit / Pest
use SocialDept\AtpTestnet\Concerns\UsesTestnet; // PHPUnit class MyTest extends TestCase { use UsesTestnet; public function test_account_creation(): void { $account = $this->testnet->createAccount('bob'); $this->assertStringStartsWith('did:plc:', $account->did); } } // Pest uses(UsesTestnet::class); test('can create account', function () { $account = $this->testnet->createAccount('bob'); expect($account->did)->toStartWith('did:plc:'); });
The UsesTestnet trait boots the testnet once per test class and tears it down after all tests complete.
Test Isolation
The testnet accumulates state across tests (accounts, DIDs, relay subscriptions). Use the reset methods to start each test with a clean slate:
// Reset everything before each test beforeEach(function () { $this->testnet->resetAll(); }); // Or reset individual services beforeEach(function () { $this->testnet->resetPds(); // Clear accounts and repos only });
| Method | What it clears |
|---|---|
resetPds() |
All accounts, repos, sessions, invite codes (SQLite truncate) |
resetPlc() |
All DIDs and operations (Postgres truncate) |
resetRelay() |
All host subscriptions, account tracking, persisted data |
resetAll() |
All of the above |
Services stay running and healthy after reset — no container restarts needed.
Configuration
use SocialDept\AtpTestnet\TestnetConfig; use SocialDept\AtpTestnet\Testnet; $testnet = Testnet::start(new TestnetConfig( plcPort: 8100, pdsPort: 8102, relayPort: 8101, adminPassword: 'custom-admin', ));
Docker Compose Overrides
You can customize the testnet's Docker Compose configuration by publishing an override file to your project root:
cp vendor/socialdept/atp-testnet/stubs/docker-compose.testnet.yml docker-compose.testnet.yml
Edit the file to override any service configuration:
# docker-compose.testnet.yml services: plc: environment: PLC_DISABLE_RATE_LIMIT: "0" # Re-enable rate limits pds: environment: PDS_DEV_MODE: "0" PDS_HOSTNAME: custom.test
The testnet automatically detects docker-compose.testnet.yml in your working directory and merges it on top of the base configuration. Only specify values you want to change — everything else keeps its default.
No rebuild needed. Override files are applied at runtime via Docker Compose's
-fmerge. Changes take effect on the nextTestnet::start()— no image rebuild required. Image rebuilds are only needed when the package itself updates its build-time patches (PLC rate limit bypass, relay SSRF bypass).
You can also pass an explicit path via config:
$testnet = Testnet::start(new TestnetConfig( composeOverride: base_path('my-custom-overrides.yml'), ));
Environment Variables
| Variable | Service | Default | Description |
|---|---|---|---|
PLC_DISABLE_RATE_LIMIT |
PLC | 1 |
Disable the 10 ops/hour rate limit per DID |
RELAY_DISABLE_SSRF |
Relay | 1 |
Disable SSRF protection for Docker networking |
RELAY_ALLOW_INSECURE_HOSTS |
Relay | 1 |
Allow HTTP (non-TLS) host connections |
PDS_DEV_MODE |
PDS | 1 |
Allow HTTP hostnames, disable SSRF protection |
PDS_INVITE_REQUIRED |
PDS | false |
Require invite codes for account creation |
PDS_HOSTNAME |
PDS | pds.test |
PDS hostname for handle domains |
Disposable & Bring-Your-Own PDS
The shared testnet PDS uses fixed secrets and is reused across tests — fine for PLC/account/firehose assertions, but you cannot rotate or rebuild its container without breaking other tests. For tests that need a PDS they fully own (secret rotation, container rebuilds, multi-PDS migration), spawn a disposable one. Its DIDs still register in the shared testnet PLC.
use SocialDept\AtpTestnet\Data\PdsSpec; $spawned = $testnet->spawnPds(new PdsSpec(name: 'my-pds', hostPort: 7300)); $pds = $spawned->pds(); $account = $pds->createAccount($pds->handle('alice')); // alice.my-pds.test $doc = $testnet->plc()->getDocument($account->did); // resolves on the shared PLC $testnet->despawnPds($spawned); // or $testnet->stop() tears down all spawned PDSes
Only name + hostPort are required; secrets are generated when omitted so
the PDS is isolated. Supply dataPath for a host bind-mount (survives
container rebuilds), network to attach to $testnet->networkName(), or
explicit secrets/hostname so a consumer that rebuilds the container itself
can reproduce its exact configuration.
To instead run a PDS container entirely yourself against the shared testnet,
point its PDS_DID_PLC_URL at $testnet->plcUrlForContainers() and (optionally)
$testnet->requestRelayCrawl($yourPdsHostname) to have the relay index it.
API Reference
Testnet
// Lifecycle Testnet::start(?TestnetConfig $config = null): Testnet $testnet->stop(): void $testnet->isRunning(): bool // Accounts $testnet->createAccount(string $handle, ?string $email = null): TestAccount $testnet->createAccountWithSession(string $handle, ?string $email = null): TestAccount $testnet->authenticatedClient(TestAccount $account): \GuzzleHttp\Client // Services $testnet->plc(): PlcService $testnet->pds(): PdsService $testnet->relay(): RelayService // Relay $testnet->requestRelayCrawl(?string $pdsHostname = null): void // null = built-in testnet PDS // Bring-your-own / disposable PDS (shares the testnet PLC + relay) $testnet->plcUrlForContainers(): string // PLC URL reachable from a container you run yourself $testnet->networkName(): string // compose network to attach an external container to $testnet->spawnPds(PdsSpec $spec): SpawnedPds $testnet->despawnPds(SpawnedPds|string $pds): void // Reset (for test isolation) $testnet->resetPds(): void // Truncate all PDS accounts and repos $testnet->resetPlc(): void // Truncate all DIDs and operations $testnet->resetRelay(): void // Truncate relay subscriptions and data $testnet->resetAll(): void // Reset PDS + PLC + Relay // Config $testnet->config(): TestnetConfig $testnet->rotationKeypair(): Secp256k1Keypair
PLC Service
// DID documents $testnet->plc()->getDocument(string $did): array $testnet->plc()->getDocumentData(string $did): array $testnet->plc()->getLastOperation(string $did): array $testnet->plc()->getOperationLog(string $did): array $testnet->plc()->getAuditLog(string $did): array $testnet->plc()->submitOperation(string $did, array $operation): void // PLC operations (requires atp-cbor for signing) $testnet->plc()->updateServiceEndpoint(string $did, string $newEndpoint, Secp256k1Keypair $signer): void $testnet->plc()->updateHandle(string $did, string $newHandle, Secp256k1Keypair $signer): void $testnet->plc()->updateRotationKeys(string $did, array $newRotationKeys, Secp256k1Keypair $signer): void $testnet->plc()->isHealthy(): bool
PDS Service
// Handles — "local.{service handle domain}" $testnet->pds()->handle(string $local): string // built-in PDS: "local.test" $spawned->pds()->handle(string $local): string // spawned PDS: "local.{its hostname}" // Accounts $testnet->pds()->createAccount(string $handle, ?string $email = null, ?string $password = null): TestAccount $testnet->pds()->deleteAccount(string $did, string $password, string $token): array $testnet->pds()->deactivateAccount(string $accessJwt): array $testnet->pds()->activateAccount(string $accessJwt): array // Sessions $testnet->pds()->createSession(string $identifier, string $password): array $testnet->pds()->getSession(string $accessJwt): array $testnet->pds()->refreshSession(string $refreshJwt): array $testnet->pds()->deleteSession(string $refreshJwt): void // Records $testnet->pds()->createRecord(string $collection, array $record, string $accessJwt, ?string $repo = null): array $testnet->pds()->getRecord(string $repo, string $collection, string $rkey): array $testnet->pds()->deleteRecord(string $collection, string $rkey, string $accessJwt, ?string $repo = null): array $testnet->pds()->listRecords(string $repo, string $collection, int $limit = 50): array // Repos and blobs $testnet->pds()->describeRepo(string $repo): array $testnet->pds()->uploadBlob(string $data, string $mimeType, string $accessJwt): array $testnet->pds()->getRepo(string $did): string // Identity $testnet->pds()->resolveHandle(string $handle): array $testnet->pds()->updateHandle(string $handle, string $accessJwt): array // Admin $testnet->pds()->createInviteCode(int $useCount = 1, ?string $forAccount = null): array $testnet->pds()->getAccountInfo(string $did): array $testnet->pds()->adminUpdateHandle(string $did, string $handle): array $testnet->pds()->adminUpdateEmail(string $did, string $email): array $testnet->pds()->updateSubjectStatus(string $did, string $deactivated = 'false'): array $testnet->pds()->describeServer(): array $testnet->pds()->isHealthy(): bool
Relay Service
$testnet->relay()->requestCrawl(string $pdsHostname): void $testnet->relay()->listRepos(?int $limit = null, ?string $cursor = null): array $testnet->relay()->listHosts(?int $limit = null): array $testnet->relay()->getRepoStatus(string $did): array $testnet->relay()->getLatestCommit(string $did): array $testnet->relay()->getBlob(string $did, string $cid): string $testnet->relay()->consumeFirehose(int $timeoutMs = 3000, ?int $cursor = null): array $testnet->relay()->isHealthy(): bool
TestAccount
$account->did; // did:plc:abc123... $account->handle; // alice.test $account->email; // alice@test.invalid $account->password; // auto-generated $account->accessJwt; // session token $account->refreshJwt; // refresh token
Contributing
Please see CONTRIBUTING.md for details.
License
The MIT License (MIT). Please see License File for more information.
