Laravel AT Protocol Client (alpha & unstable)
1[](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.