gimucco / bluesky-php
Bluesky / AT Protocol API client for PHP — a typed wrapper over the lexicon API, built on gimucco/atproto-php for OAuth.
Requires
- php: >=8.2
- ext-fileinfo: *
- ext-json: *
- gimucco/atproto-php: ^0.2
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.50
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
README
A typed PHP client for the Bluesky / AT Protocol API — built on gimucco/atproto-php for OAuth 2.1 / DPoP authentication.
- 93 typed API methods + 28 auto-paginators generated from official Bluesky lexicons
- 250 generated value-object types with
fromArray()/toArray() - Convenience methods for posts, threads, all 5 embed types, engagement, social graph, moderation
- PHPStan level 10 with strict-rules — fully statically typed
- OAuth 2.1 + DPoP — no app-password flow (deprecated by Bluesky)
At a glance
use Gimucco\Bluesky\Client; use Gimucco\Bluesky\EmbeddedImage; $client = new Client($session); // session restored from atproto-php OAuth flow $client->post('Hello world'); $client->post('With image', images: [new EmbeddedImage($blob, alt: 'A sunset')]); $client->postVideo('Watch this', $videoBytes, alt: 'A clip'); // upload + await + post $client->thread('First', 'Second', 'Third'); $client->reply($parentUri, $parentCid, 'Great post!'); $client->like($postUri, $postCid); $client->follow('alice.bsky.social'); // handle auto-resolved to DID $client->block('did:plc:troll'); $me = $client->myProfile(); $post = $client->getPost($uri);
Cheat sheet
| Domain | Methods |
|---|---|
| Posting | post(), reply(), thread(), deletePost() |
| Embeds | EmbeddedImage, EmbeddedVideo, EmbeddedExternal, EmbeddedRecord, EmbeddedRecordWithMedia |
| Engagement | like() / unlike(), repost() / unrepost() |
| Social graph | follow() / unfollow(), block() / unblock(), mute() / unmute() |
| Reading | myProfile(), getPost() |
| Media uploads | uploadImage(), uploadVideo(), postVideo(), awaitVideo() |
| Pagination | feed->paginateTimeline(), graph->paginateFollowers(), … (28 auto-generated) + Pager::iterate() for custom shapes |
| Lexicon API | $client->actor, $client->feed, $client->graph, $client->notification, $client->repo, $client->identity, $client->server, $client->label, $client->video, $client->bookmark, $client->labeler |
| Identifiers | Did, Handle, AtUri (validated, Stringable) |
| Refs | PostRef, FollowRef, LikeRef, RepostRef, BlockRef (all Stringable to their uri) |
See examples/ for runnable code per use case.
Installation
composer require gimucco/bluesky-php
composer require guzzlehttp/guzzle # recommended HTTP client
Requirements: PHP 8.2+, ext-json, ext-fileinfo, ext-curl, ext-openssl, ext-sodium (curl/openssl/sodium come from gimucco/atproto-php).
Authentication: OAuth only
This library performs Bluesky/AT Protocol API calls. It does not handle authentication — that's gimucco/atproto-php, which implements the AT Protocol's mandatory OAuth 2.1 + DPoP + PAR profile.
There is no app-password / identifier+password flow. App passwords are deprecated; OAuth 2.1 is the only path. To use this library you set up the OAuth flow once (login redirect → callback → stored session), then restore the session by DID for subsequent API calls.
For long-running automation: log in once interactively, then reuse the persisted session indefinitely (tokens auto-refresh).
Quick start
The shortest possible example, assuming you already have a stored session:
use Gimucco\Bluesky\Client; $client = new Client($session); // see "OAuth setup" below for how to get $session $client->post('Hello from bluesky-php!');
OAuth setup (one-time)
- Generate an ES256 key
openssl ecparam -genkey -name prime256v1 -noout -out private.pem
- Host two static JSON files at HTTPS URLs (
client-metadata.jsonandjwks.json). Usebin/generate-metadatafromvendor/gimucco/atproto-phpto produce them. - Configure & build the OAuth client — see
examples/login.phpandexamples/callback.phpfor the complete browser flow, andexamples/bootstrap.phpfor the runtime restore pattern.
Restoring a session at runtime
use Gimucco\Atproto\ClientConfig; use Gimucco\Atproto\OAuthClient; use Gimucco\Atproto\Storage\FileSessionStore; use Gimucco\Atproto\Storage\FileStateStore; use Gimucco\Bluesky\Client; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\HttpFactory; $factory = new HttpFactory(); $oauth = new OAuthClient( config: new ClientConfig( clientId: 'https://your-app.com/atproto/client-metadata.json', redirectUri: 'https://your-app.com/atproto/callback', scope: 'atproto transition:generic', clientName: 'My Bluesky App', jwksUri: 'https://your-app.com/atproto/jwks.json', privateKey: file_get_contents('/secure/private.pem'), encryptionPassphrase: getenv('ATPROTO_PASSPHRASE'), ), sessionStore: new FileSessionStore('/var/app/sessions', getenv('ATPROTO_PASSPHRASE')), stateStore: new FileStateStore('/var/app/states', getenv('ATPROTO_PASSPHRASE')), httpClient: new GuzzleClient(['timeout' => 30]), requestFactory: $factory, streamFactory: $factory, ); $session = $oauth->restoreSession('did:plc:abc123'); if ($session === null) { throw new RuntimeException('No stored session — user must complete OAuth flow first'); } $client = new Client($session);
Posting
Plain text
$ref = $client->post('Hello world'); echo $ref->uri;
Reply
$ref = $client->reply( parentUri: $parentUri, parentCid: $parentCid, text: 'Great take!', );
For nested replies in a thread, also pass rootUri and rootCid of the thread root.
Thread
$refs = $client->thread( 'First post in the thread 🧵', 'Second post (auto-replies to the first)', 'Third post (auto-replies to the second, root remains the first)', );
For long threads, throttle to avoid burst rate limits:
$client->setDefaultThreadDelay(2)->thread('First', 'Second', ...);
Note: thread() is not transactional — if a mid-thread post fails, prior posts remain published. See the method's docblock for partial-failure semantics.
All 5 embed types
A post can carry at most one embed (the EmbeddedRecordWithMedia type combines a quote with images/video). Mismatch throws InvalidArgumentException.
use Gimucco\Bluesky\{ EmbeddedImage, EmbeddedVideo, EmbeddedExternal, EmbeddedRecord, EmbeddedRecordWithMedia, }; // Images (up to 4 — alt text strongly recommended for accessibility) $client->post('caption', images: [ new EmbeddedImage($blob, alt: 'A sunset over the ocean'), ]); // Video (after upload + awaitVideo) $client->post('Watch this', video: new EmbeddedVideo($videoBlob, alt: '...')); // Link card (with optional thumbnail blob) $client->post('Worth a read:', external: new EmbeddedExternal( uri: 'https://example.com/article', title: 'Article title', description: 'Card description', thumb: $thumbnailBlob, // optional )); // Quote post $client->post('Look at this 👇', quoting: new EmbeddedRecord($postUri, $postCid)); // Quote with media (images OR video) $client->post('My take, with proof:', quoting: new EmbeddedRecordWithMedia( record: new EmbeddedRecord($postUri, $postCid), images: [new EmbeddedImage($blob, alt: 'screenshot')], ));
EmbeddedExternal rejects non-http(s) URIs at construction time.
Media
Images
$blob = $client->uploadImage(file_get_contents('photo.jpg')); // MIME auto-detected $blob = $client->uploadImage($bytes, 'image/webp'); // explicit MIME
Empty bytes or empty MIME string throws InvalidArgumentException.
Video
Bluesky processes videos asynchronously, but the convenience methods hide the polling. Three levels of API, in order of decreasing convenience:
// One-shot: upload + await + post. $ref = $client->postVideo('Watch this', $bytes, alt: 'A clip'); // Returns a BlobRef — for reuse (same video on multiple posts, retries, recordWithMedia, etc.) $blob = $client->uploadVideo($bytes); $client->post('Watch this', video: new EmbeddedVideo($blob, alt: '...')); $client->reply($parent, $cid, 'see this', video: new EmbeddedVideo($blob)); // Lowest level: drive the upload + poll loop yourself. $job = $client->video->uploadVideo($bytes); $blob = $client->awaitVideo($job->jobStatus->jobId, timeoutSeconds: 60);
All three block the calling thread — fine for CLI / cron / queue, not appropriate inside a synchronous web request. The default await timeout is 120 s (exponential backoff capped at 10 s/poll); pass a smaller timeoutSeconds from a request with a max execution budget.
Video calls do not go to the user's PDS — Bluesky operates a dedicated video processing service at https://video.bsky.app. The library handles routing transparently: each call mints a short-lived service-auth JWT via the user's PDS (com.atproto.server.getServiceAuth) with the audience set to the user's PDS DID (did:web:<pdsHost>, derived from the live session) and sends it as a plain bearer token to the video service, which validates the issuer chain back to the user's PDS. Uploads use lxm=com.atproto.repo.uploadBlob (the video service treats them as generic blob uploads); status / limits use their lexicon-defined lxm values. Re-uploading the exact same bytes (content-hash dedupe) is recognized server-side and returned as a successful job — no 409 thrown. The HTTP path uses curl with explicit timeouts (10 s connect, 120 s total upload, 30 s status) and SSL verification on. To target a different video service host, pass videoServiceUrl: 'https://your-video-service' to new Client(...).
Engagement
$client->like($postUri, $postCid); // returns LikeRef $client->unlike($likeRef); // accepts string|AtUri|LikeRef (Stringable) $client->repost($postUri, $postCid); $client->unrepost($repostRef);
Social graph
follow() and block() accept handle, DID, Did, or Handle. Handles are auto-resolved to DIDs (one extra API call). mute() accepts both directly (no resolution needed).
$client->follow('alice.bsky.social'); // resolves handle → DID, then follows $client->follow('did:plc:abc'); // direct, no resolution call $client->follow(new Did('did:plc:abc')); // typed $client->unfollow($followRef); $client->block('did:plc:troll'); // returns BlockRef $client->unblock($blockRef); $client->mute('spammer.bsky.social'); $client->unmute('spammer.bsky.social');
Reading
$me = $client->myProfile(); // ProfileViewDetailed $them = $client->actor->getProfile('alice.bsky.social'); $post = $client->getPost('at://did:plc:.../app.bsky.feed.post/abc'); // throws NotFoundException if missing // Iterate the timeline (auto-pages) foreach ($client->feed->paginateTimeline(limit: 50, maxItems: 200) as $item) { echo $item->post->author->handle.': '.$item->post->record['text']."\n"; }
The full generated API surface is on $client->actor, $client->feed, $client->graph, etc. See examples/profile-and-fetch.php, examples/feed-walker.php, examples/notifications.php.
Pagination
34 of the 93 generated methods accept cursor and return {cursor, items}. Each gets a typed paginate* companion:
foreach ($client->feed->paginateTimeline() as $item) { /* ... */ } foreach ($client->graph->paginateFollowers('alice.bsky.social') as $follower) { /* ... */ } foreach ($client->notification->paginateNotifications() as $notif) { /* ... */ }
For custom shapes or non-generated endpoints, use Pager::iterate():
use Gimucco\Bluesky\Pager; $items = Pager::iterate( fetch: fn(?string $cursor) => [ ($r = $client->feed->getTimeline(cursor: $cursor))->feed, $r->cursor, ], maxItems: 500, );
Typed identifiers
use Gimucco\Bluesky\{Did, Handle, AtUri}; $did = new Did('did:plc:abc123'); $client->follow($did); $uri = new AtUri('at://did:plc:alice/app.bsky.feed.post/abc'); echo $uri->authority; // did:plc:alice echo $uri->collection; // app.bsky.feed.post echo $uri->rkey; // abc $h = new Handle('@alice.bsky.social'); // strips leading @ echo $h->value; // alice.bsky.social
All five Ref classes (PostRef, FollowRef, LikeRef, RepostRef, BlockRef) are Stringable to their uri — you can pass them directly to delete methods (unfollow($followRef), etc.).
Logging
use Monolog\Logger; use Monolog\Handler\StreamHandler; $logger = new Logger('bluesky'); $logger->pushHandler(new StreamHandler('php://stderr')); $client = new Client($session, $logger);
Convenience methods emit debug-level events on post/follow/like/etc. Heads up: log context includes subject DIDs and post URIs, so a shared log file becomes a who-blocks-whom audit trail. Configure your logger accordingly.
Error handling
use Gimucco\Bluesky\Exception\{ BlueskyException, // base class ApiException, // catch-all for HTTP errors NotFoundException, // 404 RateLimitException, // 429 — exposes ->retryAfter (DateTimeImmutable|null) ValidationException, // 400 from API AuthException, // 401, 403 ServerException, // 5xx LexiconException, // malformed response from server InvalidArgumentException, // bad input to this library (distinct from API 400) }; try { $client->getPost($uri); } catch (NotFoundException $e) { // handle "not found" } catch (RateLimitException $e) { sleep((int) ($e->retryAfter?->getTimestamp() - time() ?? 60)); // retry... } catch (ApiException $e) { // any other HTTP error error_log("Bluesky {$e->status}: {$e->error} — {$e->getMessage()}"); }
Code generation
The src/Generated/ directory is produced from AT Protocol lexicon JSONs (which are not committed — fetched on demand via composer sync-lexicons).
composer sync-lexicons # download latest lexicons from upstream composer generate # regenerate PHP from lexicons composer generate-check # verify committed output is current (used in CI)
Skipped namespaces (out of scope for this library): com.atproto.sync.*, chat.bsky.*, tools.ozone.*, com.atproto.admin.*, com.atproto.temp.*, com.atproto.moderation.*, app.bsky.unspecced.*.
Development
composer install composer test # PHPUnit composer phpstan # level 10 + strict-rules composer cs-check # PER-CS 2.0 composer cs-fix
For real-API smoke tests against your test account, see tests/manual/.
Project structure
src/
├── Client.php # Main facade
├── RichText.php # Facet parser (links, mentions, hashtags)
├── Pager.php # Closure-based pagination helper
├── RefTrait.php # Shared body for the 5 Ref classes
├── Did.php # Validated DID value object
├── Handle.php # Validated handle value object
├── AtUri.php # Parsed at-uri value object
├── BlobRef.php # Blob reference (uploaded blob)
├── PostRef.php, FollowRef.php, LikeRef.php, RepostRef.php, BlockRef.php
├── EmbeddedImage.php, EmbeddedVideo.php, EmbeddedExternal.php
├── EmbeddedRecord.php, EmbeddedRecordWithMedia.php
├── Exception/ # 9 typed exceptions
├── Internal/Cast.php # JSON narrowing helpers (used by generated code)
└── Generated/ # Auto-generated (do not edit)
├── Methods/ # 14 method classes (93 methods + 28 paginators)
└── Types/ # 250 value-object types
bin/
├── generate-lexicons # Code generator
├── sync-lexicons # Lexicon downloader
└── lib/filter.php # Shared scope/skip rules
examples/ # 13 runnable example scripts
tests/ # PHPUnit (Unit + Integration) + manual harness
License
GPL-2.0-or-later. See LICENSE.