PHPackages                             gimucco/bluesky-php - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. gimucco/bluesky-php

ActiveLibrary[API Development](/categories/api)

gimucco/bluesky-php
===================

Bluesky / AT Protocol API client for PHP — a typed wrapper over the lexicon API, built on gimucco/atproto-php for OAuth.

v0.2.2(1mo ago)16GPL-2.0-or-laterPHPPHP &gt;=8.2CI passing

Since May 1Pushed 1mo agoCompare

[ Source](https://github.com/gimucco/bluesky-php)[ Packagist](https://packagist.org/packages/gimucco/bluesky-php)[ Docs](https://github.com/gimucco/bluesky-php)[ RSS](/packages/gimucco-bluesky-php/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (4)Dependencies (9)Versions (5)Used By (0)

bluesky-php
===========

[](#bluesky-php)

[![Latest Version](https://camo.githubusercontent.com/071532cb67baed02fc69083ee37ac2ab8f1d571b94b6fb6b52fe70a92c55e2d5/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f67696d7563636f2f626c7565736b792d7068702e737667)](https://packagist.org/packages/gimucco/bluesky-php)[![PHP Version](https://camo.githubusercontent.com/82c82d5311c99846bb56b99f6789b647f5ee7eda9c114d8b65d9322fa19b8d5e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f67696d7563636f2f626c7565736b792d7068702e737667)](https://packagist.org/packages/gimucco/bluesky-php)[![CI](https://github.com/gimucco/bluesky-php/actions/workflows/ci.yml/badge.svg)](https://github.com/gimucco/bluesky-php/actions/workflows/ci.yml)[![Static Analysis](https://github.com/gimucco/bluesky-php/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/gimucco/bluesky-php/actions/workflows/static-analysis.yml)[![License](https://camo.githubusercontent.com/0c11dabb4174f86dec3bf7436a1fa612429d30c271bf9390f44d98a9306f500c/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f67696d7563636f2f626c7565736b792d7068702e737667)](https://github.com/gimucco/bluesky-php/blob/main/LICENSE)

A typed PHP client for the [Bluesky](https://bsky.app) / [AT Protocol](https://atproto.com) API — built on [gimucco/atproto-php](https://github.com/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
-----------

[](#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
-----------

[](#cheat-sheet)

DomainMethods**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
------------

[](#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
--------------------------

[](#authentication-oauth-only)

This library performs Bluesky/AT Protocol API calls. **It does not handle authentication** — that's [`gimucco/atproto-php`](https://github.com/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
-----------

[](#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)

[](#oauth-setup-one-time)

1. **Generate an ES256 key**```
    openssl ecparam -genkey -name prime256v1 -noout -out private.pem
    ```
2. **Host two static JSON files** at HTTPS URLs (`client-metadata.json` and `jwks.json`). Use `bin/generate-metadata` from `vendor/gimucco/atproto-php` to produce them.
3. **Configure &amp; build the OAuth client** — see [`examples/login.php`](examples/login.php) and [`examples/callback.php`](examples/callback.php) for the complete browser flow, and [`examples/bootstrap.php`](examples/bootstrap.php) for the runtime restore pattern.

### Restoring a session at runtime

[](#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
-------

[](#posting)

### Plain text

[](#plain-text)

```
$ref = $client->post('Hello world');
echo $ref->uri;
```

### Reply

[](#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

[](#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

[](#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
-----

[](#media)

### Images

[](#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

[](#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:`, 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
----------

[](#engagement)

```
$client->like($postUri, $postCid);          // returns LikeRef
$client->unlike($likeRef);                  // accepts string|AtUri|LikeRef (Stringable)

$client->repost($postUri, $postCid);
$client->unrepost($repostRef);
```

Social graph
------------

[](#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
-------

[](#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/profile-and-fetch.php), [`examples/feed-walker.php`](examples/feed-walker.php), [`examples/notifications.php`](examples/notifications.php).

Pagination
----------

[](#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
-----------------

[](#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
-------

[](#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
--------------

[](#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
---------------

[](#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
-----------

[](#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/`](tests/manual/).

Project structure
-----------------

[](#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
-------

[](#license)

GPL-2.0-or-later. See [LICENSE](LICENSE).

###  Health Score

38

—

LowBetter than 83% of packages

Maintenance92

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity39

Early-stage or recently created project

 Bus Factor1

Top contributor holds 80% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

39d ago

PHP version history (2 changes)v0.1.0PHP &gt;=8.1

v0.2.0PHP &gt;=8.2

### Community

Maintainers

![](https://www.gravatar.com/avatar/21fcc5ef8debc55ef7e12afd6b4989832af61994a7d9412bdcd2fd939d512e59?d=identicon)[andreaolivato](/maintainers/andreaolivato)

---

Top Contributors

[![andreaolivato](https://avatars.githubusercontent.com/u/100728?v=4)](https://github.com/andreaolivato "andreaolivato (4 commits)")[![gimucco](https://avatars.githubusercontent.com/u/183583055?v=4)](https://github.com/gimucco "gimucco (1 commits)")

---

Tags

socialapi clientblueskyatprotoat protocol

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/gimucco-bluesky-php/health.svg)

```
[![Health](https://phpackages.com/badges/gimucco-bluesky-php/health.svg)](https://phpackages.com/packages/gimucco-bluesky-php)
```

###  Alternatives

[symfony/symfony

The Symfony PHP framework

31.4k86.9M2.2k](/packages/symfony-symfony)[algolia/algoliasearch-client-php

API powering the features of Algolia.

69534.4M144](/packages/algolia-algoliasearch-client-php)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k5.8M710](/packages/sylius-sylius)[tempest/framework

The PHP framework that gets out of your way.

2.2k31.1k11](/packages/tempest-framework)[theodo-group/llphant

LLPhant is a library to help you build Generative AI applications.

1.7k371.6k5](/packages/theodo-group-llphant)[telnyx/telnyx-php

Official Telnyx PHP SDK — APIs for Voice, SMS, MMS, WhatsApp, Fax, SIP Trunking, Wireless IoT, Call Control, and more. Build global communications on Telnyx's private carrier-grade network.

35729.6k2](/packages/telnyx-telnyx-php)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
