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 |
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(): 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
// 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.
