socialdept / atp-client
Type-safe AT Protocol HTTP client with OAuth 2.0 support for Laravel
Requires
- php: ^8.3
- ext-fileinfo: *
- firebase/php-jwt: ^7.0
- guzzlehttp/guzzle: ^7.0|^8.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- phpseclib/phpseclib: ^3.0
- socialdept/atp-schema: ^0.4.9
- socialdept/atp-support: ^0.3.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.89
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.3.13
- v0.3.12
- v0.3.11
- v0.3.10
- v0.3.9
- v0.3.8
- v0.3.7
- v0.3.6
- v0.3.5
- v0.3.4
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.4
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- v0.0.60
- v0.0.59
- v0.0.58
- v0.0.57
- v0.0.56
- v0.0.55
- v0.0.54
- v0.0.53
- v0.0.52
- v0.0.51
- v0.0.50
- v0.0.49
- v0.0.48
- v0.0.47
- v0.0.46
- v0.0.45
- v0.0.44
- v0.0.43
- v0.0.42
- v0.0.41
- v0.0.40
- v0.0.39
- v0.0.38
- v0.0.37
- v0.0.36
- v0.0.35
- v0.0.34
- v0.0.33
- v0.0.32
- v0.0.31
- v0.0.30
- v0.0.29
- v0.0.28
- v0.0.27
- v0.0.26
- v0.0.25
- v0.0.24
- v0.0.23
- v0.0.22
- v0.0.21
- v0.0.20
- v0.0.19
- v0.0.18
- v0.0.17
- v0.0.16
- v0.0.15
- v0.0.14
- v0.0.13
- v0.0.12
- v0.0.11
- v0.0.10
- v0.0.9
- v0.0.8
- v0.0.7
- v0.0.6
- v0.0.5
- v0.0.4
- v0.0.3
- v0.0.2
- v0.0.1
- dev-bug/guzzle-8
- dev-feature/lexicon-publishing
- dev-bug/fake-repo-writes
- dev-bug/oauth-callback-account-check
- dev-bug/service-auth-refetch-throttle
- dev-bug/forward-query-string
- dev-feature/permission-sets
- dev-feature/apply-writes
- dev-feature/xrpc-passthrough
- dev-feature/get-service-auth
- dev-feature/service-auth-hardening
- dev-feature/dev-client-command
This package is auto-updated.
Last update: 2026-10-18 04:16:23 UTC
README
Type-safe AT Protocol HTTP client with OAuth 2.0 support for Laravel.
What is AtpClient?
AtpClient is a Laravel package for interacting with Bluesky and the AT Protocol. It provides a fluent, type-safe API for authentication, posting, profiles, follows, likes, and feeds. Supports both OAuth 2.0 (with PKCE, PAR, and DPoP) and app passwords.
Think of it as Laravel's HTTP client, but for the decentralized social web.
Why use AtpClient?
- Laravel-style code - Familiar patterns you already know
- OAuth 2.0 support - Full PKCE, PAR, and DPoP implementation
- App password support - Simple authentication for scripts and bots
- Automatic token refresh - Sessions stay alive without manual intervention
- Type-safe API - Method chaining with IDE autocompletion
- Rich text builder - Fluent API for mentions, links, and hashtags
- Full Bluesky coverage - Posts, profiles, follows, likes, and feeds
- AT Protocol operations - Low-level repository access when needed
Quick Example
use SocialDept\AtpClient\Facades\Atp; // Login with app password $client = Atp::login('yourhandle.bsky.social', 'your-app-password'); // Create a post $post = $client->bsky->post->create('Hello from Laravel!'); // Get your timeline $timeline = $client->bsky->feed->getTimeline(limit: 50);
Installation
composer require socialdept/atp-client
Optionally publish the configuration:
php artisan vendor:publish --tag=atp-client-config
Getting Started
Once installed, you're three steps away from using the AT Protocol:
1. Choose Your Authentication Method
App Password (recommended for bots/scripts):
$client = Atp::login('yourhandle.bsky.social', 'your-app-password');
OAuth 2.0 (recommended for user-facing apps):
$auth = Atp::oauth()->authorize('user@bsky.social'); return redirect($auth->url);
2. Make API Calls
// Create posts $client->bsky->post->create('Hello world!'); // Get profiles $client->bsky->actor->getProfile('someone.bsky.social'); // Browse feeds $client->bsky->feed->getTimeline();
3. Store Credentials (OAuth only)
Implement the CredentialProvider interface to persist tokens between requests.
What can you build?
- Bluesky integrations - Connect your app to the AT Protocol
- Social media management - Post and manage content programmatically
- Automated posting - Schedule and automate content delivery
- Analytics dashboards - Track engagement and activity
- Moderation tools - Build bots for community moderation
- Cross-platform syndication - Mirror content across networks
Authentication
App Password Flow
The simplest way to authenticate. Generate an app password in your Bluesky settings.
use SocialDept\AtpClient\Facades\Atp; $client = Atp::login('yourhandle.bsky.social', 'your-app-password'); // Client is now authenticated and ready to use $profile = $client->bsky->actor->getProfile('yourhandle.bsky.social');
OAuth 2.0 Flow
For user-facing applications where users authenticate with their own accounts.
Step 1: Initiate authorization
use SocialDept\AtpClient\Facades\Atp; public function redirect() { $auth = Atp::oauth()->authorize('user@bsky.social'); // Store auth request in session for callback session(['atp_auth' => $auth]); return redirect($auth->url); }
Step 2: Handle callback
public function callback(Request $request) { $auth = session('atp_auth'); $token = Atp::oauth()->callback( code: $request->get('code'), state: $request->get('state'), request: $auth, issuer: $request->get('iss'), ); // Store credentials using your CredentialProvider // $token contains: accessJwt, refreshJwt, did, handle, expiresAt }
Pass iss from the redirect: a server naming a different issuer than the flow started at is refused before the code is exchanged (RFC 9207).
callback() also checks the account before storing anything. The token response names the account, but any authorization server could name any DID, so the DID's own PDS must use the issuing server, and a flow started from a handle must land on that handle. A failure throws AuthenticationException. If you exchange the code yourself after callbackWithoutExchange(), call Atp::oauth()->verifyAccount($did, $issuer, $handle) before trusting the account.
Starting at a host instead of a handle
For a "Log in with" button, start at the host's authorization server without naming an account. The server asks who is signing in, and the callback checks the account it returns belongs there.
$auth = Atp::oauth()->authorizeAt('https://bsky.social'); // Open on account creation, where the server supports it $auth = Atp::oauth()->authorizeAt('https://bsky.social', prompt: 'create');
authorize($handle) sends the handle as login_hint, so the account's server opens with it selected, and takes prompt too.
Step 3: Use stored credentials
// After storing credentials, use them with Atp::as() $client = Atp::as('user@bsky.social');
Token Refresh
Sessions automatically refresh when tokens are about to expire (default: 5 minutes before expiration). Listen to events if you need to persist refreshed tokens:
use SocialDept\AtpClient\Events\SessionUpdated; Event::listen(SessionUpdated::class, function ($event) { // $event->session - the Session being refreshed // $event->token - the new AccessToken // Update your credential storage here // Check auth type if needed if ($event->session->isLegacy()) { // App password session } });
Working with Posts
Create a Simple Post
$post = $client->bsky->post->create('Hello, Bluesky!'); // Returns StrongRef with uri and cid echo $post->uri; // at://did:plc:.../app.bsky.feed.post/... echo $post->cid; // bafyre...
Rich Text with Mentions, Links, and Hashtags
Use the TextBuilder for posts with rich text formatting:
use SocialDept\AtpClient\RichText\TextBuilder; $content = TextBuilder::make() ->text('Check out ') ->mention('someone.bsky.social') ->text(' and visit ') ->link('our website', 'https://example.com') ->text(' ') ->tag('Laravel') ->toArray(); $post = $client->bsky->post->create($content);
Or use auto-detection on plain text:
// Facets are automatically detected $post = $client->bsky->post->create( 'Hello @someone.bsky.social! Check out https://example.com #Bluesky' );
Reply to a Post
$parent = new StrongRef(uri: 'at://...', cid: 'bafyre...'); $root = $parent; // Same as parent for direct replies $reply = $client->bsky->post->reply( parent: $parent, root: $root, content: 'This is a reply!' );
Quote Post
$quotedPost = new StrongRef(uri: 'at://...', cid: 'bafyre...'); $quote = $client->bsky->post->quote( quotedPost: $quotedPost, content: 'Interesting take!' );
Post with Images
// Upload from a Laravel request $blob = $client->atproto->repo->uploadBlob($request->file('image')); // Or from a file path $blob = $client->atproto->repo->uploadBlob(new SplFileInfo('/path/to/image.jpg')); // Or from raw binary data (mimeType required) $blob = $client->atproto->repo->uploadBlob( file: file_get_contents('/path/to/image.jpg'), mimeType: 'image/jpeg' ); $post = $client->bsky->post->withImages( content: 'Check out this photo!', images: [ [ 'image' => $blob->json('blob'), 'alt' => 'Description of the image', ], ] );
Post with External Link Card
$post = $client->bsky->post->withLink( content: 'Great article about Laravel', uri: 'https://example.com/article', title: 'Article Title', description: 'A brief description of the article...' );
Delete a Post
// Extract rkey from the post URI $rkey = basename($post->uri); $client->bsky->post->delete($rkey);
Working with Profiles
Get a Profile
$profile = $client->bsky->actor->getProfile('someone.bsky.social'); echo $profile->json('displayName'); echo $profile->json('description'); echo $profile->json('followersCount');
Update Your Profile
// Update display name $client->bsky->profile->updateDisplayName('New Name'); // Update bio/description $client->bsky->profile->updateDescription('Laravel developer building on AT Protocol'); // Update multiple fields at once $client->bsky->profile->update([ 'displayName' => 'New Name', 'description' => 'New bio here', ]);
Update Avatar
$blob = $client->atproto->repo->uploadBlob(new SplFileInfo('/path/to/avatar.jpg')); $client->bsky->profile->updateAvatar($blob->json('blob'));
Social Graph
Follow a User
// Follow requires the user's DID $follow = $client->bsky->follow->create('did:plc:...');
Unfollow a User
// Get the rkey from the follow record URI $client->bsky->follow->delete($rkey);
Like a Post
$postRef = new StrongRef(uri: 'at://...', cid: 'bafyre...'); $like = $client->bsky->like->create($postRef);
Unlike a Post
$client->bsky->like->delete($rkey);
Feed Operations
Get Your Timeline
$timeline = $client->bsky->feed->getTimeline(limit: 50); foreach ($timeline->json('feed') as $item) { $post = $item['post']; echo $post['author']['handle'] . ': ' . $post['record']['text']; }
Pagination with Cursors
$cursor = null; do { $timeline = $client->bsky->feed->getTimeline(limit: 100, cursor: $cursor); foreach ($timeline->json('feed') as $item) { // Process posts } $cursor = $timeline->json('cursor'); } while ($cursor);
Get Author Feed
$feed = $client->bsky->feed->getAuthorFeed( actor: 'someone.bsky.social', limit: 50 );
Search Posts
$results = $client->bsky->feed->searchPosts( q: 'laravel php', limit: 25 );
Get Post Thread
$thread = $client->bsky->feed->getPostThread( uri: 'at://did:plc:.../app.bsky.feed.post/...', depth: 6 );
Get Likes on a Post
$likes = $client->bsky->feed->getLikes(uri: 'at://...');
Get Reposts
$reposts = $client->bsky->feed->getRepostedBy(uri: 'at://...');
Batch Writes
applyWrites() creates, updates and deletes several records in one commit. Either every write lands or none does:
use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\ApplyWrites\Create; use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\ApplyWrites\Delete; use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\ApplyWrites\Update; $response = $client->atproto->repo->applyWrites([ new Create(collection: 'app.bsky.feed.post', value: ['$type' => 'app.bsky.feed.post', 'text' => 'Hello', 'createdAt' => now()->toIso8601String()]), new Update(collection: 'app.bsky.actor.profile', rkey: 'self', value: $profile), new Delete(collection: 'app.bsky.feed.like', rkey: $likeRkey), ]); $response->commit->rev; // the single commit all three share $response->results; // one CreateResult, UpdateResult or DeleteResult per write, in order
A record value can be an array or a generated record object, which is serialized with its $type. Each write is checked against the session's scopes for its collection and action. validate is left out by default, so the PDS validates only lexicons it knows. Pass validate: true to require it or false to skip it, and swapCommit to apply the writes only on top of a known commit.
Publishing Lexicons
Lexicons you author are published as com.atproto.lexicon.schema records, keyed by the NSID, in the repo of the account the NSID's _lexicon DNS record names. These commands publish the files atp-schema manages in resources/lexicons (see its schema:new, schema:lint and schema:check-dns):
php artisan lexicon:status --did=did:plc:abc # local files against the network php artisan lexicon:publish --as=you.example.com # with ATP_LEXICON_APP_PASSWORD set php artisan lexicon:publish com.example.thing --as=you.example.com --update php artisan lexicon:unpublish com.example.old --as=you.example.com
--as signs in with an app password (--password, or ATP_LEXICON_APP_PASSWORD), or with a session the app already holds for that account. Publishing is careful by default:
- A lexicon with lint errors is not published.
- DNS must already name the publishing account, since others find the schema through it.
--skip-dns-checkpublishes anyway. - An identical published schema is left alone.
- Replacing a published schema needs
--update, and is refused when atp-schema's breaking-change check finds a breaking change, unless you pass--allow-breaking. The write swaps on the published version, so a concurrent change is not overwritten. --dry-runreports without writing.
Configuration
After publishing the config file, you can customize these options:
// config/client.php return [ // OAuth client metadata 'client' => [ 'name' => env('ATP_CLIENT_NAME', config('app.name')), 'url' => env('ATP_CLIENT_URL', config('app.url')), 'redirect_uris' => [ env('ATP_CLIENT_REDIRECT_URI', config('app.url').'/auth/atp/callback'), ], 'scopes' => ['atproto', 'transition:generic'], ], // Credential storage provider 'credential_provider' => \SocialDept\AtpClient\Providers\ArrayCredentialProvider::class, // Session behavior 'session' => [ 'refresh_threshold' => 300, // Refresh if expires within 5 minutes 'dpop_key_rotation' => 86400, // Rotate DPoP keys after 24 hours ], // OAuth settings 'oauth' => [ 'disabled' => false, 'client_metadata_path' => '/oauth-client-metadata.json', // AT Protocol standard 'jwks_path' => '/oauth-jwks.json', 'private_key' => env('ATP_OAUTH_PRIVATE_KEY'), 'kid' => env('ATP_OAUTH_KID', 'atp-client-key'), ], // HTTP client settings 'http' => [ 'timeout' => 30, 'retry' => [ 'times' => 3, 'sleep' => 100, ], ], ];
Environment Variables
ATP_CLIENT_NAME="My App" ATP_CLIENT_URL="https://myapp.com" ATP_CLIENT_REDIRECT_URI="https://myapp.com/auth/atp/callback" ATP_OAUTH_PRIVATE_KEY="base64-encoded-private-key" ATP_OAUTH_KID="atp-client-key" ATP_REFRESH_THRESHOLD=300 ATP_HTTP_TIMEOUT=30 # Optional: Override client_id and jwks_uri for external OAuth brokers ATP_CLIENT_ID="https://broker.example.com/clients/your-client-id" ATP_CLIENT_JWKS_URI="https://broker.example.com/clients/your-client-id/jwks" # Optional: Customize endpoint paths (defaults to AT Protocol standard) ATP_OAUTH_CLIENT_METADATA_PATH="/oauth-client-metadata.json" ATP_OAUTH_JWKS_PATH="/oauth-jwks.json"
The ATP_OAUTH_KID is the Key ID used in your JWKS endpoint. Some developers may require this to match a specific value. The default is atp-client-key.
When using an external OAuth broker, set ATP_CLIENT_ID and ATP_CLIENT_JWKS_URI to override the auto-generated URLs.
Local Development
Authorization servers fetch client metadata from a public HTTPS URL and refuse redirects to local domains such as .test, so a local app cannot host its own client. A stateless client metadata broker such as proto.broker hosts it for you:
php artisan atp-client:dev-client
This builds a confidential client whose public key, callback and scopes are encoded in the client id itself, then writes ATP_CLIENT_ID, ATP_CLIENT_REDIRECT_URI, ATP_OAUTH_SCOPE, ATP_OAUTH_PRIVATE_KEY and ATP_OAUTH_KID to .env. Nothing is registered anywhere, and the private key never leaves your machine.
- The callback defaults to the
oauth.callbackroute, orAPP_URL/oauth/callback. Override it with--callback. - Re-running reuses the key in
.env, so the client id stays the same. Pass--freshfor a new key. --publiccreates a public client with no signing key.--brokerpoints at a self-hosted broker.--dry-runprints the values instead of writing them.- The command refuses to run in production unless you pass
--force.
Credential Storage
The package uses a CredentialProvider interface for token storage. The default ArrayCredentialProvider stores credentials in memory (lost on request end). For production applications, you need to implement persistent storage.
Why You Need a Credential Provider
AT Protocol OAuth uses single-use refresh tokens. When a token is refreshed:
- The old refresh token is immediately invalidated
- A new refresh token is issued
- You must store the new token before using it again
If you lose the refresh token, the user must re-authenticate. The CredentialProvider ensures tokens are safely persisted.
How Handle Resolution Works
When you call Atp::as('user.bsky.social') or Atp::login('user.bsky.social', $password), the package automatically resolves the handle to a DID (Decentralized Identifier). The DID is then used as the storage key for credentials. This ensures consistency even if a user changes their handle.
If resolution fails (invalid handle, network error, etc.), a HandleResolutionException is thrown.
The CredentialProvider Interface
interface CredentialProvider { // Get stored credentials by DID public function getCredentials(string $did): ?Credentials; // Store credentials after initial OAuth or app password login public function storeCredentials(string $did, AccessToken $token): void; // Update credentials after token refresh (CRITICAL: refresh tokens are single-use!) public function updateCredentials(string $did, AccessToken $token): void; // Remove credentials (logout) public function removeCredentials(string $did): void; }
Built-in Credential Providers
The package includes several credential providers for different use cases:
| Provider | Persistence | Setup | Best For |
|---|---|---|---|
ArrayCredentialProvider |
None (memory) | None | Testing, single requests |
CacheCredentialProvider |
Cache driver | None | Quick prototyping, APIs |
SessionCredentialProvider |
Session lifetime | None | Web apps with user sessions |
FileCredentialProvider |
Permanent (disk) | None | CLI tools, bots |
CacheCredentialProvider - Uses Laravel's cache system (file cache by default):
// config/client.php 'credential_provider' => \SocialDept\AtpClient\Providers\CacheCredentialProvider::class,
SessionCredentialProvider - Credentials cleared when session expires or user logs out:
// config/client.php 'credential_provider' => \SocialDept\AtpClient\Providers\SessionCredentialProvider::class,
FileCredentialProvider - Stores credentials in storage/app/atp-credentials/:
// config/client.php 'credential_provider' => \SocialDept\AtpClient\Providers\FileCredentialProvider::class,
For production applications with multiple users, implement a database-backed provider as shown below.
Database Migration
Create a migration for storing credentials:
php artisan make:migration create_atp_credentials_table
Schema::create('atp_credentials', function (Blueprint $table) { $table->id(); $table->string('did')->unique(); // Decentralized identifier (primary key) $table->string('handle')->nullable(); // User's handle (e.g., user.bsky.social) $table->string('issuer')->nullable(); // PDS endpoint URL $table->text('access_token'); // JWT access token $table->text('refresh_token'); // Single-use refresh token $table->timestamp('expires_at'); // Token expiration time $table->json('scope')->nullable(); // Granted OAuth scopes $table->string('auth_type')->default('oauth'); // 'oauth' or 'legacy' $table->timestamps(); });
Implementing a Database Provider
<?php namespace App\Providers; use App\Models\AtpCredential; use SocialDept\AtpClient\Contracts\CredentialProvider; use SocialDept\AtpClient\Data\AccessToken; use SocialDept\AtpClient\Data\Credentials; use SocialDept\AtpClient\Enums\AuthType; class DatabaseCredentialProvider implements CredentialProvider { public function getCredentials(string $did): ?Credentials { $record = AtpCredential::where('did', $did)->first(); if (! $record) { return null; } return new Credentials( did: $record->did, accessToken: $record->access_token, refreshToken: $record->refresh_token, expiresAt: $record->expires_at, handle: $record->handle, issuer: $record->issuer, scope: $record->scope ?? [], authType: AuthType::from($record->auth_type), ); } public function storeCredentials(string $did, AccessToken $token): void { AtpCredential::updateOrCreate( ['did' => $did], [ 'handle' => $token->handle, 'issuer' => $token->issuer, 'access_token' => $token->accessJwt, 'refresh_token' => $token->refreshJwt, 'expires_at' => $token->expiresAt, 'scope' => $token->scope, 'auth_type' => $token->authType->value, ] ); } public function updateCredentials(string $did, AccessToken $token): void { AtpCredential::where('did', $did)->update([ 'access_token' => $token->accessJwt, 'refresh_token' => $token->refreshJwt, 'expires_at' => $token->expiresAt, 'handle' => $token->handle, 'issuer' => $token->issuer, 'scope' => $token->scope, 'auth_type' => $token->authType->value, ]); } public function removeCredentials(string $did): void { AtpCredential::where('did', $did)->delete(); } }
The AtpCredential Model
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class AtpCredential extends Model { protected $fillable = [ 'did', 'handle', 'issuer', 'access_token', 'refresh_token', 'expires_at', 'scope', 'auth_type', ]; protected $casts = [ 'expires_at' => 'datetime', 'scope' => 'array', ]; protected $hidden = [ 'access_token', 'refresh_token', ]; }
Register Your Provider
Update your config file:
// config/client.php 'credential_provider' => App\Providers\DatabaseCredentialProvider::class,
Or bind it in a service provider:
// app/Providers/AppServiceProvider.php use SocialDept\AtpClient\Contracts\CredentialProvider; use App\Providers\DatabaseCredentialProvider; public function register(): void { $this->app->singleton(CredentialProvider::class, DatabaseCredentialProvider::class); }
Linking to Your User Model
If you want to associate ATP credentials with your application's users:
// Migration Schema::table('atp_credentials', function (Blueprint $table) { $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); }); // AtpCredential model public function user() { return $this->belongsTo(User::class); } // User model public function atpCredential() { return $this->hasOne(AtpCredential::class); }
Then update your provider to work with the authenticated user:
public function storeCredentials(string $did, AccessToken $token): void { AtpCredential::updateOrCreate( ['did' => $did], [ 'user_id' => auth()->id(), // Link to current user 'handle' => $token->handle, 'issuer' => $token->issuer, 'access_token' => $token->accessJwt, 'refresh_token' => $token->refreshJwt, 'expires_at' => $token->expiresAt, ] ); }
Understanding the Credential Fields
| Field | Description |
|---|---|
did |
Decentralized Identifier - the stable, permanent user ID (e.g., did:plc:abc123...) |
handle |
User's handle (e.g., user.bsky.social) - can change |
issuer |
The user's PDS endpoint URL (avoids repeated lookups) |
accessToken |
JWT for API authentication (short-lived) |
refreshToken |
Token to get new access tokens (single-use!) |
expiresAt |
When the access token expires |
scope |
Array of granted scopes (e.g., ['atproto', 'transition:generic']) |
authType |
Authentication method: AuthType::OAuth or AuthType::Legacy |
Handling Token Refresh Events
When tokens are automatically refreshed, you can listen for events:
use SocialDept\AtpClient\Events\SessionUpdated; // In EventServiceProvider or via Event::listen() Event::listen(SessionUpdated::class, function (SessionUpdated $event) { // The CredentialProvider.updateCredentials() is already called, // but you can do additional logging or notifications here Log::info("Token refreshed for: {$event->session->did()}"); // Check if this is a legacy (app password) session if ($event->session->isLegacy()) { // Handle legacy sessions differently if needed } });
Events
The package dispatches events you can listen to:
OAuthUserAuthenticated
Fired after a successful OAuth callback. Use this to create or update users in your application:
use SocialDept\AtpClient\Events\OAuthUserAuthenticated; use SocialDept\AtpClient\Facades\Atp; Event::listen(OAuthUserAuthenticated::class, function (OAuthUserAuthenticated $event) { // $event->token contains: did, accessJwt, refreshJwt, handle, issuer, expiresAt, scope // Check granted scopes if (in_array('atproto', $event->token->scope)) { // User granted AT Protocol access } // Fetch the user's profile $client = Atp::as($event->token->did); $profile = $client->bsky->actor->getProfile($event->token->did); // Create or update user in your database $user = User::updateOrCreate( ['did' => $event->token->did], [ 'handle' => $event->token->handle, 'name' => $profile->json('displayName'), 'avatar' => $profile->json('avatar'), ] ); // Log them in Auth::login($user); });
SessionRefreshing / SessionUpdated
Fired before and after automatic token refresh for both OAuth and legacy sessions. Use SessionRefreshing to invalidate your stored refresh token before it's used (refresh tokens are single-use):
use SocialDept\AtpClient\Events\SessionRefreshing; use SocialDept\AtpClient\Events\SessionUpdated; // Before token refresh - invalidate old refresh token Event::listen(SessionRefreshing::class, function (SessionRefreshing $event) { // $event->session gives access to did(), handle(), authType(), isLegacy(), etc. Log::info('Refreshing token for: ' . $event->session->did()); }); // After token refresh - new tokens available Event::listen(SessionUpdated::class, function (SessionUpdated $event) { // $event->session - the session being refreshed // $event->token - the new AccessToken with fresh tokens // CredentialProvider.updateCredentials() is already called automatically Log::info('Token refreshed for: ' . $event->session->did()); // Check auth type if needed if ($event->session->isLegacy()) { // Legacy (app password) session } });
SessionRefreshFailed
Fired when a refresh fails. Carries a legacy reason string (backwards compatible) and a typed failureReason (RefreshFailureReason) so you can flag a session only on a terminal failure and leave transient ones (nonce/5xx/network) alone:
use SocialDept\AtpClient\Events\SessionRefreshFailed; Event::listen(SessionRefreshFailed::class, function (SessionRefreshFailed $event) { if ($event->failureReason?->isTerminal()) { // Dead grant / account gone — prompt the user to reconnect. markSessionInvalid($event->session->did()); } // Transient failures are retried automatically on the next call/keep-alive. });
Liveness probe & keep-alive
Use probe() for a cheap authenticated health check and RefreshFailureReason/SessionHealth to decide terminal-vs-transient. See Sessions & Keep-Alive for the full lifecycle, the single-use lock requirement, and a copy-paste scheduled keep-alive command.
$health = Atp::as($did)->probe(); if ($health->isTerminal()) { // account gone / dead grant — reconnect required } elseif ($health->needsRefresh()) { Atp::as($did)->ensureValid($did); }
Scope Authorization
The package provides Laravel-native authorization features for checking ATP OAuth scopes, similar to Laravel's Gate/Policy system.
Setup
Have your User model implement the HasAtpSession interface:
use SocialDept\AtpClient\Contracts\HasAtpSession; class User extends Authenticatable implements HasAtpSession { public function getAtpDid(): ?string { return $this->atp_did; // or however you store the DID } }
Route Middleware
Protect routes by requiring specific scopes. Uses AND logic (all listed scopes required):
use Illuminate\Support\Facades\Route; // Requires transition:generic scope Route::post('/posts', [PostController::class, 'store']) ->middleware('atp.scope:transition:generic'); // Requires BOTH scopes Route::post('/dm', [MessageController::class, 'store']) ->middleware('atp.scope:transition:generic,transition:chat.bsky');
AtpScope Facade
Use the AtpScope facade for programmatic scope checks:
use SocialDept\AtpClient\Facades\AtpScope; // Check if user has a scope if (AtpScope::can('transition:generic')) { // ... } // Check if user has any of the scopes if (AtpScope::canAny(['transition:generic', 'transition:chat.bsky'])) { // ... } // Check if user has all scopes if (AtpScope::canAll(['atproto', 'transition:generic'])) { // ... } // Authorize or fail (throws/aborts based on config) AtpScope::authorize('transition:generic'); // Check for a specific user AtpScope::forUser($did)->authorize('transition:generic'); // Get all granted scopes $scopes = AtpScope::granted();
Session Helper Methods
The Session class also has convenience methods:
$session = Atp::as($did)->session(); $session->can('transition:generic'); $session->canAny(['transition:generic', 'transition:chat.bsky']); $session->canAll(['atproto', 'transition:generic']); $session->cannot('transition:chat.bsky');
Configuration
Configure authorization failure behavior in config/client.php:
'scope_authorization' => [ // What happens when scope check fails: 'abort', 'redirect', or 'exception' 'failure_action' => ScopeAuthorizationFailure::Abort, // Redirect URL when failure_action is 'redirect' 'redirect_to' => '/login', ],
Or via environment variables:
ATP_SCOPE_FAILURE_ACTION=abort ATP_SCOPE_REDIRECT=/login
Service Auth
OAuth authenticates a user to your app. Service auth authenticates another AT Protocol service to yours: a PDS proxying an XRPC call, an authority asking your app whether to admit a user. The caller signs a short-lived JWT with its repo key; AtpClient resolves the caller's DID document and verifies the signature against the key published there. No registration, no shared secret.
Protect an inbound XRPC route with the middleware, naming the method it accepts tokens for:
Route::get('/xrpc/com.example.doThing', DoThingController::class) ->middleware('atp.service-auth:com.example.doThing');
ATP_SERVICE_AUTH_AUDIENCE=did:web:example.com#forum
The verified token is attached to the request:
use SocialDept\AtpClient\Http\Middleware\VerifyServiceAuthMiddleware; $token = $request->attributes->get(VerifyServiceAuthMiddleware::ATTRIBUTE); $token->did(); // who is calling $token->method; // the NSID the token is bound to
Always pass the NSID to the middleware. It is what stops a token minted for one endpoint being replayed at another. See Service Auth for everything that gets verified, the error responses, key rotation, and minting.
Forwarding XRPC Calls
forward() relays an XRPC call to the user's PDS with the session your application holds, so a browser holding only your session cookie can make ordinary XRPC requests, atproto-proxy included:
$response = Atp::as($did)->client->forward('GET', 'app.bsky.feed.getTimeline', $request->server->get('QUERY_STRING'), null, $request->headers->all()); return ForwardedResponse::toSymfony($response);
Only an allowlist of headers is forwarded, errors from the PDS are returned rather than thrown, and bodies stream in both directions. See Forwarding.
Extending the Client
Add custom functionality to AtpClient by registering your own domain clients or request clients. Extensions are lazily instantiated on first access.
Register Extensions
Register extensions in your service provider's boot() method:
use SocialDept\AtpClient\AtpClient; // Add a new domain client: $client->analytics AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp)); // Add to an existing domain: $client->bsky->metrics AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new MetricsClient($bsky));
Usage
$client = Atp::as('user.bsky.social'); $client->analytics->trackEvent('post_created'); $client->bsky->metrics->getEngagement();
For complete documentation including creating custom clients, testing, and advanced patterns, see docs/extensions.md.
Testing
AtpClient ships with a first-party fake that replaces all HTTP calls with in-memory stubs — no network requests, no tokens, no PDS needed.
Basic Usage
use SocialDept\AtpClient\Facades\Atp; use SocialDept\AtpClient\Enums\Nsid\BskyActor; use SocialDept\AtpClient\Testing\FakeResponse; it('fetches a profile', function () { $fake = Atp::fake(); $fake->stub(BskyActor::GetProfile, FakeResponse::profile([ 'handle' => 'alice.bsky.social', 'displayName' => 'Alice', ])); $profile = Atp::public()->bsky->actor->getProfile('alice.bsky.social'); expect($profile->handle)->toBe('alice.bsky.social'); $fake->assertCalled(BskyActor::GetProfile); });
Stubbing with Enums
Use the NSID enum classes for autocomplete and type safety. All stub(), sequence(), and assertion methods accept enums:
use SocialDept\AtpClient\Enums\Nsid\BskyFeed; use SocialDept\AtpClient\Enums\Nsid\AtprotoRepo; $fake = Atp::fake(); $fake->stub(BskyFeed::GetTimeline, FakeResponse::timeline(5)); $fake->stub(AtprotoRepo::CreateRecord, FakeResponse::createRecord()); // Assertions with enums $fake->assertCalled(BskyFeed::GetTimeline); $fake->assertCalledTimes(AtprotoRepo::CreateRecord, 1);
Response Factories
FakeResponse provides factories for all common AT Protocol response shapes — profiles, feeds, posts, records, sessions, graph, and notifications — plus composable builders for any endpoint:
// Named factories FakeResponse::profile(['handle' => 'alice.bsky.social']); FakeResponse::timeline(5, cursor: 'abc'); FakeResponse::createRecord(); FakeResponse::getPostThread(); // Composable builders for uncovered endpoints FakeResponse::feedList(10, cursor: 'next'); FakeResponse::profileList(5, key: 'followers'); FakeResponse::postList(3, key: 'quotes'); // Error responses FakeResponse::expiredToken(); FakeResponse::rateLimited(60); FakeResponse::invalidSwap();
Assertions
$fake->assertCalled(BskyActor::GetProfile); $fake->assertNotCalled(BskyFeed::GetTimeline); $fake->assertCalledTimes(AtprotoRepo::CreateRecord, 2); $fake->assertCalledPublicly(BskyActor::GetProfile); $fake->assertCalledAuthenticated(BskyFeed::GetTimeline, 'did:plc:test123'); $fake->assertNothingCalled(); // With callback inspection $fake->assertCalledWith(BskyActor::GetProfile, function ($request) { return $request->hasParam('actor', 'alice.bsky.social'); });
Sequences, Errors, and More
// Response sequences $fake->sequence(BskyActor::GetProfile) ->push(FakeResponse::ok(FakeResponse::profile(['handle' => 'first']))) ->push(FakeResponse::ok(FakeResponse::profile(['handle' => 'second']))); // Simulate errors $fake->stub(AtprotoRepo::CreateRecord, FakeResponse::invalidSwap()); // Prevent unstubbed requests $fake->preventStrayRequests();
For the complete testing guide including authenticated mode, OAuth faking, wildcard stubs, callable stubs, and all available factories, see docs/testing.md.
Available Commands
# Generate OAuth private key php artisan atp-client:generate-key # Create a domain client extension php artisan make:atp-client AnalyticsClient # Create a request client extension for an existing domain php artisan make:atp-request MetricsClient --domain=bsky
Requirements
- PHP 8.2+
- Laravel 11 or 12
- socialdept/atp-schema ^0.2
- socialdept/atp-resolver ^1.0
Running Tests
composer test
Resources
- AT Protocol Documentation
- Bluesky API Docs
- CRYPTO.md - Cryptographic implementation details
- docs/extensions.md - Client extensions guide
- docs/testing.md - Testing guide
- docs/service-auth.md - Inter-service auth guide
- docs/forwarding.md - Forwarding XRPC calls to the user's PDS
- docs/sessions.md - Sessions & keep-alive guide
- docs/scopes.md - OAuth scopes guide
Support & Contributing
Found a bug or have a feature request? Open an issue.
Want to contribute? Check out the contribution guidelines.
Changelog
Please see changelog for recent changes.
Credits
- Miguel Batres - founder & lead maintainer
- All contributors
License
AtpClient is open-source software licensed under the MIT license.
Built for the Atmosphere - By Social Dept.
