revolution/laravel-bluesky
A Laravel package for integrating Bluesky and the AT Protocol into your app. Connect accounts, publish posts, and interact with the Bluesky API through a clean, framework-friendly wrapper with simple configuration and service provider support.
Install and let Laravel auto-discover the provider:
composer require revolution/laravel-bluesky
php artisan vendor:publish # pick the bluesky config tag from the list
Create an app password in Bluesky (Settings → App Passwords) — never use your main account password — and put the credentials in .env:
BLUESKY_USERNAME=yourhandle.bsky.social
BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx
Where to look first: config/bluesky.php (credentials, and any base/PDS URL option the version exposes), then the facade Revolution\Bluesky\Facades\Bluesky in vendor/revolution/. The facade is a thin layer over the revolution/bluesky client, and the vendor source is the real documentation for this package — the README is short and the API surface lives in the facade docblocks. As of 1.1.0 the package also registers at least one Laravel event (FirehoseSyncMessage); php artisan event:list is a quick way to confirm what the installed version emits.
Smoke test in Tinker before writing any code:
use Revolution\Bluesky\Facades\Bluesky;
Bluesky::login(config('bluesky.username'), config('bluesky.password'));
Bluesky::post('Hello from Laravel');
The ideal first use case is a webhook-style "publish to Bluesky" button on an existing model (blog post, release note, product announcement), or a scheduled job that posts from a queue. That single flow exercises everything you'll need: login/session, text post, and error handling. Only once that is stable should you look at the firehose/event-streaming features added in 1.1.0 — they are a different operating model (long-lived workers) and a distraction for a first integration.
Wrap the facade in one application-level service. Don't scatter Bluesky:: calls through controllers. Create something like App\Services\BlueskyPublisher with post(string $text, ?array $embed = null) that owns login, retries, and error normalisation. When the package's API shifts (see Gotchas), you change one file.
Persist the session, don't log in per request. Authentication returns access/refresh JWTs that expire in roughly two hours. Login on demand and store the session (the package supports caching/session storage of the agent state; if the version you pin doesn't persist automatically, wrap login() in a small token cache keyed by account). Logging in on every request costs points against the rate limit and adds latency to your users.
Queue everything that creates content. Bluesky calls are slow and rate-limited; a post should never block a request cycle.
class PublishToBluesky implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public array $backoff = [30, 120, 600];
public function __construct(public Post $post) {}
public function handle(BlueskyPublisher $bsky): void
{
$bsky->post($this->post->title."\n\n".url("/posts/{$this->post->slug}"));
}
public function uniqueId(): string
{
return 'bluesky-post-'.$this->post->id;
}
}
Dispatch from a model observer or a domain event (PostPublished) so the integration lives outside your write path, and add ShouldBeUnique to avoid double-posting on retries.
Reading the firehose (new in 1.1.0). The FirehoseSyncMessage event plus its sync handler gives you the subscribeRepos feed as Laravel events instead of a raw socket loop. Treat it as infrastructure, not request code:
// app/Listeners/HandleFirehosePost.php
public function handle(FirehoseSyncMessage $event): void
{
// Inspect the decoded commit/record; filter by collection and author
// before dispatching anything that costs an API call.
}
Practical rules: run the consumer as a supervised php artisan command or a dedicated queue connection, not inside your web container; persist the cursor/sequence number so a restart doesn't replay from the tip and silently miss commits; dedupe on (did, rkey, cid) because the feed is at-least-once from your process's point of view; and keep the listener cheap — anything expensive goes onto a queue. Most firehose traffic is likes and reposts, so filter by app.bsky.feed.post and a handle/author allowlist before you do real work.
Activity subscriptions (new in 1.1.0). AppBskyNotification now exposes the activity-subscription methods, which is the supported way to "follow" an account's posts/engagement without polling getAuthorFeed in a loop. Use it for monitoring/alerting flows; it composes well with the firehose listener above (subscribe for signal, firehose for detail). Note these are per-actor, per-viewer state on Bluesky's side — you are storing remote state, so make the call idempotent and record what you subscribed to.
Rich text needs facets. Links, mentions, and hashtags are not auto-detected by the server — they must be sent as app.bsky.richtext.facet objects with byte offsets into the UTF-8 text. Use the richtext/facet helper that ships with the underlying client rather than hand-computing offsets; if you must hand-roll it, strlen() on the source string, not mb_strlen().
Images and link cards are two-step. Upload first (uploadBlob), then post with an embed referencing the returned blob, always including alt text. Link previews require fetching the target's OG tags yourself and constructing an app.bsky.embed.external. Only one embed per post; quote-posts are a record embed, not text.
Testing: the client is built on Laravel's HTTP client, so Http::fake() with recorded XRPC payloads is the cheapest way to test your publisher without touching the network. Add Http::preventStrayRequests() in your test suite so a misconfigured base URL fails loudly. For the firehose handler, test the listener directly with a constructed FirehoseSyncMessage plus real recorded frames — don't try to fake the socket.
Multi-account: the config file and facade are single-identity. For an agency/multi-tenant setup, instantiate client objects per account from a bluesky_accounts table rather than relying on the singleton. DPoP (see Gotchas) is per-client-key, so each account needs its own client instance and its own stored key material.
identifier, but actor-scoped endpoints sometimes want a DID. Store the DID returned at login next to the handle.exp claim to the proof JWT. Two consequences: proofs are now short-lived, so a machine with meaningful clock skew against Bluesky will get auth failures that look like bad credentials — keep NTP running on workers; and the proof's htu must match the request URL exactly (scheme, host, path). If you point the client at a custom PDS, a trailing slash, an http vs https mismatch, or a proxy that rewrites the host will break signing. Verify the base URL in config/bluesky.php character-for-character against what you put in the browser.bsky.social, the base URL/PLC host must be configurable. Check whether the pinned version exposes it in config/bluesky.php; if not, override the client binding in a service provider. Re-test after every upgrade — the 1.1.0 DPoP changes are exactly the kind of thing that regresses non-default hosts first.transition:email scope (1.1.0) only matters for OAuth. It was added to BlueskyProvider, so it affects the OAuth authorization-code flow, not app-password sessions. Existing authorised users won't have the scope until they re-consent; if you rely on it, handle the "scope not granted" case rather than assuming it.strlen. Truncate with grapheme_substr() and leave headroom for the URL you append.{"error": "InvalidRequest", "message": "..."} with a 4xx status. Log $response->json('error') and message; a bare status check will send you hunting in the wrong place.429 with backoff. Reading the firehose costs nothing against these limits; reacting to it does — that asymmetry is what makes the new event worth using.ExpiredToken. Have your publisher catch that specific error, refresh, and retry once before failing the job. This applies doubly to a firehose consumer, which may run for days between restarts.composer.json, read the release notes, and keep the package behind your own service class. 1.1.0 is additive (no breaking changes noted), but the auth layer moved — upgrade in a branch, re-run your smoke test against real credentials, and watch for token-cache entries written by the old version.Http::dd() or a global Http::globalRequestMiddleware that logs the XRPC method and body is far more informative than the exception message. Run php artisan tinker against the same credentials before blaming your app config. For DPoP failures you will see a 401 with an invalid_dpop_proof-style error — that points at the URL/clock, not the password.FirehoseSyncMessage event, which you attach listeners to like any other Laravel event. Anything beyond those (e.g. reacting to your own successful publish) still needs your own domain event, so emit BlueskyPostPublished from your service if downstream code needs to react.How can I help you explore Laravel packages today?