Search by

wotnak / atproto

wotnak

Framework-independent PHP client library for the AT Protocol.

0.1.0 2026-08-09 12:52 UTC

This package is auto-updated.

Last update: 2026-08-30 11:52:02 UTC


README

Framework-independent PHP client library for the AT Protocol, built on PSR-18 and PSR-17.

Features

  • making XRPC queries and procedures through an application-provided PSR-18 transport, with structured responses and exceptions
  • calling arbitrary AT Protocol queries and procedures, including JSON and raw binary inputs and outputs
  • authenticating requests with app-password sessions or the atproto OAuth public web-client flow, including PKCE, PAR, and DPoP
  • resolving handles through DNS TXT and HTTPS well-known endpoints according to the handle specification, and resolving DID documents
  • parsing and normalizing handles, DIDs, NSIDs, record keys, and AT URIs
  • parsing Lexicon schemas and validating records against their constraints and the AT Protocol data model
  • resolving authoritative published Lexicons and executing schema-validated XRPC calls with declared defaults and content types
  • parsing and serializing granular repository and RPC permission scopes

Installation

composer require wotnak/atproto

Requirements

  • PHP 8.4 or newer
  • the Ctype PHP extension
  • the OpenSSL PHP extension
  • a PSR-18 HTTP client
  • PSR-17 request and stream factories

The package depends on the standard PSR interfaces rather than a particular HTTP implementation. Applications must supply compatible implementations when constructing a client. A PSR-16 cache implementation is optional and enables identity-resolution caching when supplied.

Quick Start

Create clients with Wotnak\Atproto\AtprotoClientFactory. The factory accepts the PSR-18 and PSR-17 implementations supplied by the consuming application:

use Wotnak\Atproto\AtprotoClientFactory;

$factory = new AtprotoClientFactory(
  httpClient: $psr18Client,
  requestFactory: $psr17RequestFactory,
  streamFactory: $psr17StreamFactory,
);

$client = $factory->create('https://eurosky.social');
$repo = $client->get('com.atproto.repo.describeRepo', [
  'repo' => 'alice.example.com',
]);

The variables named for PSR interfaces above are application-provided implementations. AtprotoClient exposes generic XRPC methods for calls whose validation is owned by the application:

$profile = $client->get('app.bsky.actor.getProfile', [
  'actor' => 'alice.example.com',
]);

Identity and Lexicon services are available from the same client and factory:

$client->handles();
$client->dids();
$client->lexicons($lexiconRepository);
$factory->createLexiconRepository();

The package does not hand-maintain endpoint-specific com.atproto.* clients. Use Lexicon-aware execution for schema validation or the generic methods as an explicit low-level escape hatch.

Repository Writes

Repository writes can be sent through generic or Lexicon-aware XRPC execution. Fields such as validate, swapRecord, and swapCommit are part of the canonical endpoint input, while com.atproto.repo.applyWrites performs atomic batch writes. Writes require an authenticated client.

XRPC Options and Response Limits

The service URL passed to create() becomes the default XRPC service URL. It must be an origin consisting only of scheme, host, and optional port, such as https://pds.example.com; paths, credentials, query strings, and fragments are rejected. XRPC endpoints are always constructed at the top-level /xrpc/{method} path, and every method must be a valid NSID. Individual calls can still override the service URL through XrpcOptions when needed.

JSON and raw XRPC responses default to an 8 MiB in-memory limit. Set an explicit limit for expected larger responses, especially repository CAR exports:

use Wotnak\Atproto\Client\XrpcOptions;

$repo = $client->requestRaw(
  'GET',
  'com.atproto.sync.getRepo',
  ['did' => 'did:plc:...'],
  new XrpcOptions(
    headers: ['Accept' => 'application/vnd.ipld.car'],
    maxResponseBytes: 64 * 1024 * 1024,
  ),
);

Choose the smallest practical limit for the deployment and the trust level of the remote service. Raw request bodies may be supplied as PSR-7 streams; raw responses remain bounded and buffered, and the package does not parse CAR/MST data.

Service URLs and Transport Security

The default outbound URL policy requires HTTPS, rejects credentials and local hostnames, and rejects private or reserved IP address literals. It deliberately does not resolve hostnames: DNS, redirects, proxies, and the actual connection destination are responsibilities of the configured PSR-18 transport.

When a service, identity, or OAuth URL may be influenced by untrusted input, configure the PSR-18 client to prevent connections to private and reserved networks and to validate every redirect destination. PSR-18 and PSR-7 do not provide a portable way for this library to pin a connection or inspect its peer address.

Applications can implement OutboundUrlPolicyInterface to add deterministic restrictions such as a host allowlist. Isolated local test environments can opt an exact host into HTTP/private-network XRPC access without weakening identity or OAuth URL validation:

use Wotnak\Atproto\Security\OutboundUrlPolicy;

$client = $factory->create(
  'http://localhost:2583',
  urlPolicy: new OutboundUrlPolicy(
    allowedDevelopmentHosts: ['localhost'],
  ),
);

Never populate allowedDevelopmentHosts from untrusted input or use it in production. This allowlist changes URL-policy validation only; it does not configure the PSR-18 transport.

Authenticated Clients

Authentication is applied at the request-sender layer. Authenticated and anonymous clients expose the same XRPC execution methods:

$client = $factory->createAuthenticated($authenticatedSession);

$session = $client->get('com.atproto.server.getSession');
$profile = $client->get('app.bsky.actor.getProfile', [
  'actor' => $authenticatedSession->getSubjectDid(),
]);

createAuthenticated() accepts an implementation of Wotnak\Atproto\Client\AuthenticatedSessionInterface. The authenticated session is responsible for adding the correct authorization headers and request signing. Pass a service URL as the second argument only when the session does not provide one or when you want to override it.

Authenticated clients are immutable per session. Do not mutate a shared client with a different user session; create a new authenticated client for each actor.

If the authenticated session implements RefreshableAuthenticatedSessionInterface, the request sender refreshes the session and retries once when a response indicates an expired or invalid token. Retries are attempted only when the request body can be replayed safely.

The provided AtprotoAuthenticatedSession and OAuthAuthenticatedSession classes do not implement automatic token refresh. Applications using those classes remain responsible for refreshing and persisting sessions. To opt into the automatic retry behavior, provide an application session implementation of RefreshableAuthenticatedSessionInterface; its refresh() method must update the credentials subsequently returned and used by that same object.

App Password Sessions

For app-password sessions returned by com.atproto.server.createSession, wrap the session value object with AtprotoAuthenticatedSession:

use Wotnak\Atproto\Auth\AtprotoAuthenticatedSession;
use Wotnak\Atproto\Auth\AtprotoSession;

$anonymous = $factory->create('https://eurosky.social');
$session = AtprotoSession::fromCreateSessionResponse(
  $anonymous->post('com.atproto.server.createSession', [
    'identifier' => 'alice.example.com',
    'password' => 'xxxx-xxxx-xxxx-xxxx',
  ]),
  'https://eurosky.social',
);

$client = $factory->createAuthenticated(
  new AtprotoAuthenticatedSession($session),
  $session->pdsUrl,
);

$current = $client->get('com.atproto.server.getSession');

OAuth Sessions

The included OAuth client implements the atproto OAuth public web-client flow with PKCE, PAR, mandatory DPoP nonces, resource/authorization-server discovery, and subject-authority verification. Native-client metadata, confidential-client private_key_jwt authentication, framework routes, and persistence are outside this implementation.

Client Metadata

Publish the generated client metadata as an HTTP 200 application/json response at the exact HTTPS URL used as clientId:

use Wotnak\Atproto\Auth\OAuth\ClientMetadataBuilder;
use Wotnak\Atproto\Auth\OAuth\OAuthConfig;

$oauthConfig = new OAuthConfig(
  clientId: 'https://client.example.com/oauth/client-metadata.json',
  redirectUris: ['https://client.example.com/oauth/callback'],
  scopes: ['atproto'],
  clientName: 'Example client',
);

$metadata = ClientMetadataBuilder::build($oauthConfig);

The atproto scope is sufficient for identity authentication. Add only the granular repository, RPC, blob, or permission-set scopes required by the application; all requested scopes must also appear in this metadata document.

OAuthConfig describes a public web client. Client IDs must be HTTPS metadata document URLs without a port, and redirect URIs must be HTTPS web URLs. Native client callbacks and the localhost native-client exception are not supported by this builder.

Starting Authorization

The AT Protocol OAuth specification recommends identity-resolution cache lifetimes of less than ten minutes for authorization flows. If the factory was constructed with a PSR-16 cache, create resolvers with a short flow-specific TTL; without a cache, identity resolution is performed on demand:

use Wotnak\Atproto\Auth\OAuth\OAuthClient;

$oauth = new OAuthClient(
  httpClient: $psr18Client,
  requestFactory: $psr17RequestFactory,
  streamFactory: $psr17StreamFactory,
  didResolver: $factory->createDidResolver(identityCacheTtl: 300),
  handleResolver: $factory->createHandleResolver(identityCacheTtl: 300),
);

$authorization = $oauth->createAuthorizationRequest(
  config: $oauthConfig,
  redirectUri: 'https://client.example.com/oauth/callback',
  identifier: 'alice.example.com',
);

$stateStore->set($authorization->state);
// Redirect the browser to $authorization->authorizationUrl.

Handling the Callback

On the callback, look up the returned state, compare it with hash_equals(), reject missing or expired state, and delete it before exchanging the one-time code. The following is framework-neutral pseudocode: $requestQuery is the validated request query, while $stateStore and $sessionStore are application implementations of StateStoreInterface and OAuthSessionStoreInterface:

$callbackState = is_string($requestQuery['state'] ?? NULL) ? $requestQuery['state'] : '';
$code = is_string($requestQuery['code'] ?? NULL) ? $requestQuery['code'] : '';
$issuer = is_string($requestQuery['iss'] ?? NULL) ? $requestQuery['iss'] : '';
if ($callbackState === '' || $code === '' || $issuer === '') {
  throw new RuntimeException('Missing OAuth callback parameter.');
}

$state = $stateStore->get($callbackState);
$now = time();
if ($state === NULL
  || !hash_equals($state->state, $callbackState)
  || $state->createdAt === NULL
  || $state->createdAt > $now
  || $now - $state->createdAt > 600) {
  throw new RuntimeException('Invalid or expired OAuth state.');
}
$stateStore->delete($callbackState);

$session = $oauth->exchangeAuthorizationCode(
  state: $state,
  code: $code,
  issuer: $issuer,
);
$sessionStore->set($session);

$client = $factory->createAuthenticated(
  $session->authenticatedSession(),
  $session->pdsUrl,
);

Treat serialized authorization state and sessions as secrets: both contain DPoP private key material, and sessions also contain access and refresh tokens. Store them encrypted and restrict access.

Refreshing Sessions

Replace the stored session after every refresh:

$session = $oauth->refreshSession($oauthConfig, $session);
$sessionStore->set($session);

Call this explicitly before the access token expires or after handling an invalid-token response. Refresh tokens are generally rotated, so concurrent refreshes for one session must be serialized and the replacement session must be stored atomically.

OAuthSession::authenticatedSession() builds an authenticated session from the session's stored DPoP key material. The authenticated XRPC sender retries once when a resource server responds with use_dpop_nonce and a DPoP-Nonce header, signing the replayed request with that nonce.

The authorization client also preserves authorization-server nonces returned by pushed authorization and token endpoints for the next DPoP-signed request.

Integrations are responsible for callback state validation, one-time state consumption, expiry policy, encrypted session/DPoP key storage, and atomic refresh-token updates. The package keeps those concerns outside the reusable protocol layer so frameworks can use their own key storage and persistence systems.

For OAuth login flows started with a server URL rather than a handle or DID, the callback exchange resolves the returned subject DID and verifies that the subject's PDS resolves back to the callback issuer before accepting the session.

Error Handling

XRPC responses with non-2xx status codes throw structured exceptions from Wotnak\Atproto\Exception, including response status, decoded error data when available, and response headers. Empty successful responses are returned as an empty array.

Catch XrpcHttpException when the HTTP status and decoded XRPC error are relevant to application behavior:

use Wotnak\Atproto\Exception\XrpcHttpException;

try {
  $profile = $client->get('app.bsky.actor.getProfile', [
    'actor' => 'alice.example.com',
  ]);
}
catch (XrpcHttpException $exception) {
  throw new RuntimeException(
    sprintf(
      '%s (HTTP %d): %s',
      $exception->xrpcError ?? 'XRPC request failed',
      $exception->statusCode,
      $exception->xrpcMessage ?? $exception->getMessage(),
    ),
    previous: $exception,
  );
}

Catch the base XrpcException for transport failures, response-size violations, and malformed successful responses that do not have an XRPC HTTP error payload. OAuth transport and protocol failures similarly extend OAuthException, while non-success OAuth endpoint responses use OAuthHttpException.

Lexicon Validation

Generic XRPC calls remain available when the application owns validation. Validate records at application boundaries with RecordValidator:

use Wotnak\Atproto\Lexicon\RecordValidator;

$schema = [
  'type' => 'object',
  'required' => ['text', 'createdAt'],
  'properties' => [
    'text' => ['type' => 'string', 'maxGraphemes' => 300],
    'createdAt' => ['type' => 'string', 'format' => 'datetime'],
  ],
];

$result = (new RecordValidator())->validateRecord(
  collection: 'app.example.feed.post',
  record: [
    '$type' => 'app.example.feed.post',
    'text' => 'Hello from PHP',
    'createdAt' => '2026-08-02T12:00:00.000Z',
  ],
  schema: $schema,
);

if (!$result->isValid()) {
  throw new InvalidArgumentException(implode("\n", $result->messages()));
}

LexiconParser performs structural checks for the Lexicon v1 document subset supported by the runtime compiler. RecordValidator enforces declared constraints and also validates undeclared forward-compatible fields against the AT Protocol data model, including rejection of non-integral numbers and canonical CID, bytes, and blob structures. External refs can be supplied through a LexiconRepositoryInterface or resolver callback.

Authoritative Lexicon Resolution

The factory can build a repository that follows the publication chain from the NSID authority's _lexicon DNS TXT record through its DID document and PDS:

$lexicons = $factory->createLexiconRepository();
$postLexicon = $lexicons->getDocument('app.example.feed.post');

Published records are accepted only when the DNS publisher is unambiguous, its DID resolves to a valid PDS endpoint, the returned AT URI has the expected publisher, collection, and record key, and the parsed Lexicon ID matches the requested NSID. Successful documents use the factory's PSR-16 cache.

Lexicon-Aware XRPC

Create a schema-driven client from any LexiconRepositoryInterface:

$typedXrpc = $client->lexicons($lexicons);
$response = $typedXrpc->call(
  'app.example.feed.getTimeline',
  parameters: ['limit' => 25],
);

The client validates parameters, JSON inputs, and JSON outputs; inserts declared parameter defaults; sends declared input and accept content types; and returns RawXrpcResponse for non-JSON output. Invalid values throw LexiconValidationException before or after transport as appropriate.

Granular Permissions

Typed permission values produce canonical OAuth scope strings and permission set declarations:

use Wotnak\Atproto\Auth\Permission\PermissionScopeParser;
use Wotnak\Atproto\Auth\Permission\RepoPermission;
use Wotnak\Atproto\Auth\Permission\RpcPermission;

$permissions = [
  new RepoPermission(['app.example.feed.post'], ['create', 'delete']),
  new RpcPermission(['app.example.feed.getTimeline'], '*'),
];

$scope = PermissionScopeParser::serializeScopeString($permissions);
$parsed = PermissionScopeParser::parseScopeString($scope);

PermissionSet::fromDocument() converts a parsed permission-set Lexicon into typed RepoPermission and RpcPermission values and builds include: scopes.