wotnak / atproto
Framework-independent PHP client library for the AT Protocol.
Requires
- php: ^8.4
- ext-ctype: *
- ext-openssl: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^1.1 || ^2.0
- psr/simple-cache: ^3.0
- symfony/polyfill-intl-grapheme: ^1.41
- web-token/jwt-library: ^4.1
Requires (Dev)
- carthage-software/mago: ^1.46
- drupal/coder: ^8.3.31
- guzzlehttp/guzzle: ^7.10
- guzzlehttp/psr7: ^2.13
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.2.8
- phpstan/phpstan-strict-rules: ^2.0.12
- phpunit/phpunit: ^11.5.56
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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.