Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Bluesky Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. 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
    
  2. 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
    
  3. 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.

  4. 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.

Implementation Patterns

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.

Gotchas and Tips

  • Main passwords and 2FA will not work. Only app passwords authenticate. This is the single most common first-run failure.
  • Handle vs DID. Most endpoints accept either in identifier, but actor-scoped endpoints sometimes want a DID. Store the DID returned at login next to the handle.
  • DPoP is now stricter (1.1.0). The release refines DPoP proof URL construction and adds an 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.
  • Custom PDS. If you're not on 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.
  • 300 graphemes, not characters. The post limit is grapheme clusters — emoji, flags, and combining marks count as one grapheme in Bluesky's counting but several in strlen. Truncate with grapheme_substr() and leave headroom for the URL you append.
  • HTTP 400 is not "bad request" — it's an application error. XRPC errors arrive as {"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.
  • Rate limits are opaque and real. Roughly 5,000 points/hour and 35,000/day, with post creation costing a few points each — in practice this caps you around 1,666 posts/day. Fine for real usage, fatal for a backfill script that re-posts history. Chunk bulk operations and respect 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.
  • Access tokens expire mid-queue. Long-running workers that cached a session can hit 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.
  • Facet offsets are byte offsets. Off-by-one here silently produces unclickable links rather than an error — assert them in a unit test with a Japanese or emoji-containing string.
  • 0 dependents and 44 stars means you are the QA team. Pin the version in 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.
  • Debugging tip: 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.
  • Extension points to know: the config file (credentials/base URL), the container binding for the client (swap for a fake, a multi-account factory, or a differently-configured client per PDS), Laravel's HTTP client middleware for global logging/retry, and — new in 1.1.0 — the 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console