Laravel AT Protocol Client (alpha & unstable)
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v0.0.23 841 lines 22 kB view raw view rendered
1[![Resolver Header](./header.png)](https://github.com/socialdept/atp-signals) 2 3<h3 align="center"> 4 Type-safe AT Protocol HTTP client with OAuth 2.0 support for Laravel. 5</h3> 6 7<p align="center"> 8 <br> 9 <a href="https://packagist.org/packages/socialdept/atp-client" title="Latest Version on Packagist"><img src="https://img.shields.io/packagist/v/socialdept/atp-client.svg?style=flat-square"></a> 10 <a href="https://packagist.org/packages/socialdept/atp-client" title="Total Downloads"><img src="https://img.shields.io/packagist/dt/socialdept/atp-client.svg?style=flat-square"></a> 11 <a href="https://github.com/socialdept/atp-client/actions/workflows/tests.yml" title="GitHub Tests Action Status"><img src="https://img.shields.io/github/actions/workflow/status/socialdept/atp-client/tests.yml?branch=main&label=tests&style=flat-square"></a> 12 <a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/atp-client?style=flat-square"></a> 13</p> 14 15--- 16 17## What is AtpClient? 18 19**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. 20 21Think of it as Laravel's HTTP client, but for the decentralized social web. 22 23## Why use AtpClient? 24 25- **Laravel-style code** - Familiar patterns you already know 26- **OAuth 2.0 support** - Full PKCE, PAR, and DPoP implementation 27- **App password support** - Simple authentication for scripts and bots 28- **Automatic token refresh** - Sessions stay alive without manual intervention 29- **Type-safe API** - Method chaining with IDE autocompletion 30- **Rich text builder** - Fluent API for mentions, links, and hashtags 31- **Full Bluesky coverage** - Posts, profiles, follows, likes, and feeds 32- **AT Protocol operations** - Low-level repository access when needed 33 34## Quick Example 35 36```php 37use SocialDept\AtpClient\Facades\Atp; 38 39// Login with app password 40$client = Atp::login('yourhandle.bsky.social', 'your-app-password'); 41 42// Create a post 43$post = $client->bsky->post->create('Hello from Laravel!'); 44 45// Get your timeline 46$timeline = $client->bsky->feed->getTimeline(limit: 50); 47``` 48 49## Installation 50 51```bash 52composer require socialdept/atp-client 53``` 54 55Optionally publish the configuration: 56 57```bash 58php artisan vendor:publish --tag=atp-client-config 59``` 60 61## Getting Started 62 63Once installed, you're three steps away from using the AT Protocol: 64 65### 1. Choose Your Authentication Method 66 67**App Password** (recommended for bots/scripts): 68```php 69$client = Atp::login('yourhandle.bsky.social', 'your-app-password'); 70``` 71 72**OAuth 2.0** (recommended for user-facing apps): 73```php 74$auth = Atp::oauth()->authorize('user@bsky.social'); 75return redirect($auth->url); 76``` 77 78### 2. Make API Calls 79 80```php 81// Create posts 82$client->bsky->post->create('Hello world!'); 83 84// Get profiles 85$client->bsky->actor->getProfile('someone.bsky.social'); 86 87// Browse feeds 88$client->bsky->feed->getTimeline(); 89``` 90 91### 3. Store Credentials (OAuth only) 92 93Implement the `CredentialProvider` interface to persist tokens between requests. 94 95## What can you build? 96 97- **Bluesky integrations** - Connect your app to the AT Protocol 98- **Social media management** - Post and manage content programmatically 99- **Automated posting** - Schedule and automate content delivery 100- **Analytics dashboards** - Track engagement and activity 101- **Moderation tools** - Build bots for community moderation 102- **Cross-platform syndication** - Mirror content across networks 103 104## Authentication 105 106### App Password Flow 107 108The simplest way to authenticate. Generate an app password in your Bluesky settings. 109 110```php 111use SocialDept\AtpClient\Facades\Atp; 112 113$client = Atp::login('yourhandle.bsky.social', 'your-app-password'); 114 115// Client is now authenticated and ready to use 116$profile = $client->bsky->actor->getProfile('yourhandle.bsky.social'); 117``` 118 119### OAuth 2.0 Flow 120 121For user-facing applications where users authenticate with their own accounts. 122 123**Step 1: Initiate authorization** 124```php 125use SocialDept\AtpClient\Facades\Atp; 126 127public function redirect() 128{ 129 $auth = Atp::oauth()->authorize('user@bsky.social'); 130 131 // Store auth request in session for callback 132 session(['atp_auth' => $auth]); 133 134 return redirect($auth->url); 135} 136``` 137 138**Step 2: Handle callback** 139```php 140public function callback(Request $request) 141{ 142 $auth = session('atp_auth'); 143 144 $token = Atp::oauth()->callback( 145 code: $request->get('code'), 146 state: $request->get('state'), 147 request: $auth 148 ); 149 150 // Store credentials using your CredentialProvider 151 // $token contains: accessJwt, refreshJwt, did, handle, expiresAt 152} 153``` 154 155**Step 3: Use stored credentials** 156```php 157// After storing credentials, use them with Atp::as() 158$client = Atp::as('user@bsky.social'); 159``` 160 161### Token Refresh 162 163Sessions automatically refresh when tokens are about to expire (default: 5 minutes before expiration). Listen to events if you need to persist refreshed tokens: 164 165```php 166use SocialDept\AtpClient\Events\OAuthTokenRefreshed; 167 168Event::listen(OAuthTokenRefreshed::class, function ($event) { 169 // $event->did - the user's DID (e.g., did:plc:abc123...) 170 // $event->token - the new AccessToken 171 // Update your credential storage here 172}); 173``` 174 175## Working with Posts 176 177### Create a Simple Post 178 179```php 180$post = $client->bsky->post->create('Hello, Bluesky!'); 181 182// Returns StrongRef with uri and cid 183echo $post->uri; // at://did:plc:.../app.bsky.feed.post/... 184echo $post->cid; // bafyre... 185``` 186 187### Rich Text with Mentions, Links, and Hashtags 188 189Use the `TextBuilder` for posts with rich text formatting: 190 191```php 192use SocialDept\AtpClient\RichText\TextBuilder; 193 194$content = TextBuilder::make() 195 ->text('Check out ') 196 ->mention('someone.bsky.social') 197 ->text(' and visit ') 198 ->link('our website', 'https://example.com') 199 ->text(' ') 200 ->tag('Laravel') 201 ->toArray(); 202 203$post = $client->bsky->post->create($content); 204``` 205 206Or use auto-detection on plain text: 207 208```php 209// Facets are automatically detected 210$post = $client->bsky->post->create( 211 'Hello @someone.bsky.social! Check out https://example.com #Bluesky' 212); 213``` 214 215### Reply to a Post 216 217```php 218$parent = new StrongRef(uri: 'at://...', cid: 'bafyre...'); 219$root = $parent; // Same as parent for direct replies 220 221$reply = $client->bsky->post->reply( 222 parent: $parent, 223 root: $root, 224 content: 'This is a reply!' 225); 226``` 227 228### Quote Post 229 230```php 231$quotedPost = new StrongRef(uri: 'at://...', cid: 'bafyre...'); 232 233$quote = $client->bsky->post->quote( 234 quotedPost: $quotedPost, 235 content: 'Interesting take!' 236); 237``` 238 239### Post with Images 240 241```php 242// Upload from a Laravel request 243$blob = $client->atproto->repo->uploadBlob($request->file('image')); 244 245// Or from a file path 246$blob = $client->atproto->repo->uploadBlob(new SplFileInfo('/path/to/image.jpg')); 247 248// Or from raw binary data (mimeType required) 249$blob = $client->atproto->repo->uploadBlob( 250 file: file_get_contents('/path/to/image.jpg'), 251 mimeType: 'image/jpeg' 252); 253 254$post = $client->bsky->post->withImages( 255 content: 'Check out this photo!', 256 images: [ 257 [ 258 'image' => $blob->json('blob'), 259 'alt' => 'Description of the image', 260 ], 261 ] 262); 263``` 264 265### Post with External Link Card 266 267```php 268$post = $client->bsky->post->withLink( 269 content: 'Great article about Laravel', 270 uri: 'https://example.com/article', 271 title: 'Article Title', 272 description: 'A brief description of the article...' 273); 274``` 275 276### Delete a Post 277 278```php 279// Extract rkey from the post URI 280$rkey = basename($post->uri); 281 282$client->bsky->post->delete($rkey); 283``` 284 285## Working with Profiles 286 287### Get a Profile 288 289```php 290$profile = $client->bsky->actor->getProfile('someone.bsky.social'); 291 292echo $profile->json('displayName'); 293echo $profile->json('description'); 294echo $profile->json('followersCount'); 295``` 296 297### Update Your Profile 298 299```php 300// Update display name 301$client->bsky->profile->updateDisplayName('New Name'); 302 303// Update bio/description 304$client->bsky->profile->updateDescription('Laravel developer building on AT Protocol'); 305 306// Update multiple fields at once 307$client->bsky->profile->update([ 308 'displayName' => 'New Name', 309 'description' => 'New bio here', 310]); 311``` 312 313### Update Avatar 314 315```php 316$blob = $client->atproto->repo->uploadBlob(new SplFileInfo('/path/to/avatar.jpg')); 317 318$client->bsky->profile->updateAvatar($blob->json('blob')); 319``` 320 321## Social Graph 322 323### Follow a User 324 325```php 326// Follow requires the user's DID 327$follow = $client->bsky->follow->create('did:plc:...'); 328``` 329 330### Unfollow a User 331 332```php 333// Get the rkey from the follow record URI 334$client->bsky->follow->delete($rkey); 335``` 336 337### Like a Post 338 339```php 340$postRef = new StrongRef(uri: 'at://...', cid: 'bafyre...'); 341 342$like = $client->bsky->like->create($postRef); 343``` 344 345### Unlike a Post 346 347```php 348$client->bsky->like->delete($rkey); 349``` 350 351## Feed Operations 352 353### Get Your Timeline 354 355```php 356$timeline = $client->bsky->feed->getTimeline(limit: 50); 357 358foreach ($timeline->json('feed') as $item) { 359 $post = $item['post']; 360 echo $post['author']['handle'] . ': ' . $post['record']['text']; 361} 362``` 363 364### Pagination with Cursors 365 366```php 367$cursor = null; 368 369do { 370 $timeline = $client->bsky->feed->getTimeline(limit: 100, cursor: $cursor); 371 372 foreach ($timeline->json('feed') as $item) { 373 // Process posts 374 } 375 376 $cursor = $timeline->json('cursor'); 377} while ($cursor); 378``` 379 380### Get Author Feed 381 382```php 383$feed = $client->bsky->feed->getAuthorFeed( 384 actor: 'someone.bsky.social', 385 limit: 50 386); 387``` 388 389### Search Posts 390 391```php 392$results = $client->bsky->feed->searchPosts( 393 q: 'laravel php', 394 limit: 25 395); 396``` 397 398### Get Post Thread 399 400```php 401$thread = $client->bsky->feed->getPostThread( 402 uri: 'at://did:plc:.../app.bsky.feed.post/...', 403 depth: 6 404); 405``` 406 407### Get Likes on a Post 408 409```php 410$likes = $client->bsky->feed->getLikes(uri: 'at://...'); 411``` 412 413### Get Reposts 414 415```php 416$reposts = $client->bsky->feed->getRepostedBy(uri: 'at://...'); 417``` 418 419## Configuration 420 421After publishing the config file, you can customize these options: 422 423```php 424// config/client.php 425 426return [ 427 // OAuth client metadata 428 'client' => [ 429 'name' => env('ATP_CLIENT_NAME', config('app.name')), 430 'url' => env('ATP_CLIENT_URL', config('app.url')), 431 'redirect_uris' => [ 432 env('ATP_CLIENT_REDIRECT_URI', config('app.url').'/auth/atp/callback'), 433 ], 434 'scopes' => ['atproto', 'transition:generic'], 435 ], 436 437 // Credential storage provider 438 'credential_provider' => \SocialDept\AtpClient\Providers\ArrayCredentialProvider::class, 439 440 // Session behavior 441 'session' => [ 442 'refresh_threshold' => 300, // Refresh if expires within 5 minutes 443 'dpop_key_rotation' => 86400, // Rotate DPoP keys after 24 hours 444 ], 445 446 // OAuth settings 447 'oauth' => [ 448 'disabled' => false, 449 'prefix' => '/atp/oauth/', 450 'private_key' => env('ATP_OAUTH_PRIVATE_KEY'), 451 'kid' => env('ATP_OAUTH_KID', 'atp-client-key'), 452 ], 453 454 // HTTP client settings 455 'http' => [ 456 'timeout' => 30, 457 'retry' => [ 458 'times' => 3, 459 'sleep' => 100, 460 ], 461 ], 462]; 463``` 464 465### Environment Variables 466 467```env 468ATP_CLIENT_NAME="My App" 469ATP_CLIENT_URL="https://myapp.com" 470ATP_CLIENT_REDIRECT_URI="https://myapp.com/auth/atp/callback" 471ATP_OAUTH_PRIVATE_KEY="base64-encoded-private-key" 472ATP_OAUTH_KID="atp-client-key" 473ATP_REFRESH_THRESHOLD=300 474ATP_HTTP_TIMEOUT=30 475``` 476 477The `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`. 478 479## Credential Storage 480 481The 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. 482 483### Why You Need a Credential Provider 484 485AT Protocol OAuth uses **single-use refresh tokens**. When a token is refreshed: 4861. The old refresh token is immediately invalidated 4872. A new refresh token is issued 4883. You must store the new token before using it again 489 490If you lose the refresh token, the user must re-authenticate. The `CredentialProvider` ensures tokens are safely persisted. 491 492### How Handle Resolution Works 493 494When 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. 495 496If resolution fails (invalid handle, network error, etc.), a `HandleResolutionException` is thrown. 497 498### The CredentialProvider Interface 499 500```php 501interface CredentialProvider 502{ 503 // Get stored credentials by DID 504 public function getCredentials(string $did): ?Credentials; 505 506 // Store credentials after initial OAuth or app password login 507 public function storeCredentials(string $did, AccessToken $token): void; 508 509 // Update credentials after token refresh (CRITICAL: refresh tokens are single-use!) 510 public function updateCredentials(string $did, AccessToken $token): void; 511 512 // Remove credentials (logout) 513 public function removeCredentials(string $did): void; 514} 515``` 516 517### Database Migration 518 519Create a migration for storing credentials: 520 521```bash 522php artisan make:migration create_atp_credentials_table 523``` 524 525```php 526Schema::create('atp_credentials', function (Blueprint $table) { 527 $table->id(); 528 $table->string('did')->unique(); // Decentralized identifier (primary key) 529 $table->string('handle')->nullable(); // User's handle (e.g., user.bsky.social) 530 $table->string('issuer')->nullable(); // PDS endpoint URL 531 $table->text('access_token'); // JWT access token 532 $table->text('refresh_token'); // Single-use refresh token 533 $table->timestamp('expires_at'); // Token expiration time 534 $table->json('scope')->nullable(); // Granted OAuth scopes 535 $table->timestamps(); 536}); 537``` 538 539### Implementing a Database Provider 540 541```php 542<?php 543 544namespace App\Providers; 545 546use App\Models\AtpCredential; 547use SocialDept\AtpClient\Contracts\CredentialProvider; 548use SocialDept\AtpClient\Data\AccessToken; 549use SocialDept\AtpClient\Data\Credentials; 550 551class DatabaseCredentialProvider implements CredentialProvider 552{ 553 public function getCredentials(string $did): ?Credentials 554 { 555 $record = AtpCredential::where('did', $did)->first(); 556 557 if (! $record) { 558 return null; 559 } 560 561 return new Credentials( 562 did: $record->did, 563 accessToken: $record->access_token, 564 refreshToken: $record->refresh_token, 565 expiresAt: $record->expires_at, 566 handle: $record->handle, 567 issuer: $record->issuer, 568 scope: $record->scope ?? [], 569 ); 570 } 571 572 public function storeCredentials(string $did, AccessToken $token): void 573 { 574 AtpCredential::updateOrCreate( 575 ['did' => $did], 576 [ 577 'handle' => $token->handle, 578 'issuer' => $token->issuer, 579 'access_token' => $token->accessJwt, 580 'refresh_token' => $token->refreshJwt, 581 'expires_at' => $token->expiresAt, 582 'scope' => $token->scope, 583 ] 584 ); 585 } 586 587 public function updateCredentials(string $did, AccessToken $token): void 588 { 589 AtpCredential::where('did', $did)->update([ 590 'access_token' => $token->accessJwt, 591 'refresh_token' => $token->refreshJwt, 592 'expires_at' => $token->expiresAt, 593 'handle' => $token->handle, 594 'issuer' => $token->issuer, 595 'scope' => $token->scope, 596 ]); 597 } 598 599 public function removeCredentials(string $did): void 600 { 601 AtpCredential::where('did', $did)->delete(); 602 } 603} 604``` 605 606### The AtpCredential Model 607 608```php 609<?php 610 611namespace App\Models; 612 613use Illuminate\Database\Eloquent\Model; 614 615class AtpCredential extends Model 616{ 617 protected $fillable = [ 618 'did', 619 'handle', 620 'issuer', 621 'access_token', 622 'refresh_token', 623 'expires_at', 624 'scope', 625 ]; 626 627 protected $casts = [ 628 'expires_at' => 'datetime', 629 'scope' => 'array', 630 ]; 631 632 protected $hidden = [ 633 'access_token', 634 'refresh_token', 635 ]; 636} 637``` 638 639### Register Your Provider 640 641Update your config file: 642 643```php 644// config/client.php 645 646'credential_provider' => App\Providers\DatabaseCredentialProvider::class, 647``` 648 649Or bind it in a service provider: 650 651```php 652// app/Providers/AppServiceProvider.php 653 654use SocialDept\AtpClient\Contracts\CredentialProvider; 655use App\Providers\DatabaseCredentialProvider; 656 657public function register(): void 658{ 659 $this->app->singleton(CredentialProvider::class, DatabaseCredentialProvider::class); 660} 661``` 662 663### Linking to Your User Model 664 665If you want to associate ATP credentials with your application's users: 666 667```php 668// Migration 669Schema::table('atp_credentials', function (Blueprint $table) { 670 $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); 671}); 672 673// AtpCredential model 674public function user() 675{ 676 return $this->belongsTo(User::class); 677} 678 679// User model 680public function atpCredential() 681{ 682 return $this->hasOne(AtpCredential::class); 683} 684``` 685 686Then update your provider to work with the authenticated user: 687 688```php 689public function storeCredentials(string $did, AccessToken $token): void 690{ 691 AtpCredential::updateOrCreate( 692 ['did' => $did], 693 [ 694 'user_id' => auth()->id(), // Link to current user 695 'handle' => $token->handle, 696 'issuer' => $token->issuer, 697 'access_token' => $token->accessJwt, 698 'refresh_token' => $token->refreshJwt, 699 'expires_at' => $token->expiresAt, 700 ] 701 ); 702} 703``` 704 705### Understanding the Credential Fields 706 707| Field | Description | 708|-------|-------------| 709| `did` | Decentralized Identifier - the stable, permanent user ID (e.g., `did:plc:abc123...`) | 710| `handle` | User's handle (e.g., `user.bsky.social`) - can change | 711| `issuer` | The user's PDS endpoint URL (avoids repeated lookups) | 712| `accessToken` | JWT for API authentication (short-lived) | 713| `refreshToken` | Token to get new access tokens (single-use!) | 714| `expiresAt` | When the access token expires | 715| `scope` | Array of granted scopes (e.g., `['atproto', 'transition:generic']`) | 716 717### Handling Token Refresh Events 718 719When tokens are automatically refreshed, you can listen for events: 720 721```php 722use SocialDept\AtpClient\Events\OAuthTokenRefreshed; 723 724// In EventServiceProvider or via Event::listen() 725Event::listen(OAuthTokenRefreshed::class, function (OAuthTokenRefreshed $event) { 726 // The CredentialProvider.updateCredentials() is already called, 727 // but you can do additional logging or notifications here 728 Log::info("Token refreshed for: {$event->session->did()}"); 729}); 730``` 731 732## Events 733 734The package dispatches events you can listen to: 735 736### OAuthUserAuthenticated 737 738Fired after a successful OAuth callback. Use this to create or update users in your application: 739 740```php 741use SocialDept\AtpClient\Events\OAuthUserAuthenticated; 742use SocialDept\AtpClient\Facades\Atp; 743 744Event::listen(OAuthUserAuthenticated::class, function (OAuthUserAuthenticated $event) { 745 // $event->token contains: did, accessJwt, refreshJwt, handle, issuer, expiresAt, scope 746 747 // Check granted scopes 748 if (in_array('atproto', $event->token->scope)) { 749 // User granted AT Protocol access 750 } 751 752 // Fetch the user's profile 753 $client = Atp::as($event->token->did); 754 $profile = $client->bsky->actor->getProfile($event->token->did); 755 756 // Create or update user in your database 757 $user = User::updateOrCreate( 758 ['did' => $event->token->did], 759 [ 760 'handle' => $event->token->handle, 761 'name' => $profile->json('displayName'), 762 'avatar' => $profile->json('avatar'), 763 ] 764 ); 765 766 // Log them in 767 Auth::login($user); 768}); 769``` 770 771### OAuthTokenRefreshing / OAuthTokenRefreshed 772 773Fired before and after automatic token refresh. Use `OAuthTokenRefreshing` to invalidate your stored refresh token before it's used (refresh tokens are single-use): 774 775```php 776use SocialDept\AtpClient\Events\OAuthTokenRefreshing; 777use SocialDept\AtpClient\Events\OAuthTokenRefreshed; 778 779// Before token refresh - invalidate old refresh token 780Event::listen(OAuthTokenRefreshing::class, function (OAuthTokenRefreshing $event) { 781 // $event->session gives access to did(), handle(), etc. 782 Log::info('Refreshing token for: ' . $event->session->did()); 783}); 784 785// After token refresh - new tokens available 786Event::listen(OAuthTokenRefreshed::class, function (OAuthTokenRefreshed $event) { 787 // $event->session - the session being refreshed 788 // $event->token - the new AccessToken with fresh tokens 789 // CredentialProvider.updateCredentials() is already called automatically 790 Log::info('Token refreshed for: ' . $event->session->did()); 791}); 792``` 793 794## Available Commands 795 796```bash 797# Generate OAuth private key 798php artisan atp-client:generate-key 799``` 800 801## Requirements 802 803- PHP 8.2+ 804- Laravel 11 or 12 805- [socialdept/atp-schema](https://github.com/socialdept/atp-schema) ^0.2 806- [socialdept/atp-resolver](https://github.com/socialdept/atp-resolver) ^1.0 807 808## Testing 809 810```bash 811composer test 812``` 813 814## Resources 815 816- [AT Protocol Documentation](https://atproto.com/) 817- [Bluesky API Docs](https://docs.bsky.app/) 818- [CRYPTO.md](CRYPTO.md) - Cryptographic implementation details 819 820## Support & Contributing 821 822Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/atp-client/issues). 823 824Want to contribute? Check out the [contribution guidelines](contributing.md). 825 826## Changelog 827 828Please see [changelog](changelog.md) for recent changes. 829 830## Credits 831 832- [Miguel Batres](https://batres.co) - founder & lead maintainer 833- [All contributors](https://github.com/socialdept/atp-client/graphs/contributors) 834 835## License 836 837AtpClient is open-source software licensed under the [MIT license](license.md). 838 839--- 840 841**Built for the Federation** - By Social Dept.