···11+[](https://github.com/socialdept/atp-signals)
22+33+<h3 align="center">
44+ Type-safe AT Protocol HTTP client with OAuth 2.0 support for Laravel.
55+</h3>
66+77+<p align="center">
88+ <br>
99+ <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>
1010+ <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>
1111+ <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>
1212+ <a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/atp-client?style=flat-square"></a>
1313+</p>
1414+1515+---
1616+1717+## What is AtpClient?
1818+1919+**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.
2020+2121+Think of it as Laravel's HTTP client, but for the decentralized social web.
2222+2323+## Why use AtpClient?
2424+2525+- **Laravel-style code** - Familiar patterns you already know
2626+- **OAuth 2.0 support** - Full PKCE, PAR, and DPoP implementation
2727+- **App password support** - Simple authentication for scripts and bots
2828+- **Automatic token refresh** - Sessions stay alive without manual intervention
2929+- **Type-safe API** - Method chaining with IDE autocompletion
3030+- **Rich text builder** - Fluent API for mentions, links, and hashtags
3131+- **Full Bluesky coverage** - Posts, profiles, follows, likes, and feeds
3232+- **AT Protocol operations** - Low-level repository access when needed
3333+3434+## Quick Example
3535+3636+```php
3737+use SocialDept\AtpClient\Facades\Atp;
3838+3939+// Login with app password
4040+$client = Atp::login('yourhandle.bsky.social', 'your-app-password');
4141+4242+// Create a post
4343+$post = $client->bsky->post->create('Hello from Laravel!');
4444+4545+// Get your timeline
4646+$timeline = $client->bsky->feed->getTimeline(limit: 50);
4747+```
4848+4949+## Installation
5050+5151+```bash
5252+composer require socialdept/atp-client
5353+```
5454+5555+Optionally publish the configuration:
5656+5757+```bash
5858+php artisan vendor:publish --tag=atp-client-config
5959+```
6060+6161+## Getting Started
6262+6363+Once installed, you're three steps away from using the AT Protocol:
6464+6565+### 1. Choose Your Authentication Method
6666+6767+**App Password** (recommended for bots/scripts):
6868+```php
6969+$client = Atp::login('yourhandle.bsky.social', 'your-app-password');
7070+```
7171+7272+**OAuth 2.0** (recommended for user-facing apps):
7373+```php
7474+$auth = Atp::oauth()->authorize('user@bsky.social');
7575+return redirect($auth->url);
7676+```
7777+7878+### 2. Make API Calls
7979+8080+```php
8181+// Create posts
8282+$client->bsky->post->create('Hello world!');
8383+8484+// Get profiles
8585+$client->bsky->actor->getProfile('someone.bsky.social');
8686+8787+// Browse feeds
8888+$client->bsky->feed->getTimeline();
8989+```
9090+9191+### 3. Store Credentials (OAuth only)
9292+9393+Implement the `CredentialProvider` interface to persist tokens between requests.
9494+9595+## What can you build?
9696+9797+- **Bluesky integrations** - Connect your app to the AT Protocol
9898+- **Social media management** - Post and manage content programmatically
9999+- **Automated posting** - Schedule and automate content delivery
100100+- **Analytics dashboards** - Track engagement and activity
101101+- **Moderation tools** - Build bots for community moderation
102102+- **Cross-platform syndication** - Mirror content across networks
103103+104104+## Authentication
105105+106106+### App Password Flow
107107+108108+The simplest way to authenticate. Generate an app password in your Bluesky settings.
109109+110110+```php
111111+use SocialDept\AtpClient\Facades\Atp;
112112+113113+$client = Atp::login('yourhandle.bsky.social', 'your-app-password');
114114+115115+// Client is now authenticated and ready to use
116116+$profile = $client->bsky->actor->getProfile('yourhandle.bsky.social');
117117+```
118118+119119+### OAuth 2.0 Flow
120120+121121+For user-facing applications where users authenticate with their own accounts.
122122+123123+**Step 1: Initiate authorization**
124124+```php
125125+use SocialDept\AtpClient\Facades\Atp;
126126+127127+public function redirect()
128128+{
129129+ $auth = Atp::oauth()->authorize('user@bsky.social');
130130+131131+ // Store auth request in session for callback
132132+ session(['atp_auth' => $auth]);
133133+134134+ return redirect($auth->url);
135135+}
136136+```
137137+138138+**Step 2: Handle callback**
139139+```php
140140+public function callback(Request $request)
141141+{
142142+ $auth = session('atp_auth');
143143+144144+ $token = Atp::oauth()->callback(
145145+ code: $request->get('code'),
146146+ state: $request->get('state'),
147147+ request: $auth
148148+ );
149149+150150+ // Store credentials using your CredentialProvider
151151+ // $token contains: accessJwt, refreshJwt, did, handle, expiresAt
152152+}
153153+```
154154+155155+**Step 3: Use stored credentials**
156156+```php
157157+// After storing credentials, use them with Atp::as()
158158+$client = Atp::as('user@bsky.social');
159159+```
160160+161161+### Token Refresh
162162+163163+Sessions automatically refresh when tokens are about to expire (default: 5 minutes before expiration). Listen to events if you need to persist refreshed tokens:
164164+165165+```php
166166+use SocialDept\AtpClient\Events\TokenRefreshed;
167167+168168+Event::listen(TokenRefreshed::class, function ($event) {
169169+ // $event->identifier - the user identifier
170170+ // $event->token - the new AccessToken
171171+ // Update your credential storage here
172172+});
173173+```
174174+175175+## Working with Posts
176176+177177+### Create a Simple Post
178178+179179+```php
180180+$post = $client->bsky->post->create('Hello, Bluesky!');
181181+182182+// Returns StrongRef with uri and cid
183183+echo $post->uri; // at://did:plc:.../app.bsky.feed.post/...
184184+echo $post->cid; // bafyre...
185185+```
186186+187187+### Rich Text with Mentions, Links, and Hashtags
188188+189189+Use the `TextBuilder` for posts with rich text formatting:
190190+191191+```php
192192+use SocialDept\AtpClient\RichText\TextBuilder;
193193+194194+$content = TextBuilder::make()
195195+ ->text('Check out ')
196196+ ->mention('someone.bsky.social')
197197+ ->text(' and visit ')
198198+ ->link('our website', 'https://example.com')
199199+ ->text(' ')
200200+ ->tag('Laravel')
201201+ ->toArray();
202202+203203+$post = $client->bsky->post->create($content);
204204+```
205205+206206+Or use auto-detection on plain text:
207207+208208+```php
209209+// Facets are automatically detected
210210+$post = $client->bsky->post->create(
211211+ 'Hello @someone.bsky.social! Check out https://example.com #Bluesky'
212212+);
213213+```
214214+215215+### Reply to a Post
216216+217217+```php
218218+$parent = new StrongRef(uri: 'at://...', cid: 'bafyre...');
219219+$root = $parent; // Same as parent for direct replies
220220+221221+$reply = $client->bsky->post->reply(
222222+ parent: $parent,
223223+ root: $root,
224224+ content: 'This is a reply!'
225225+);
226226+```
227227+228228+### Quote Post
229229+230230+```php
231231+$quotedPost = new StrongRef(uri: 'at://...', cid: 'bafyre...');
232232+233233+$quote = $client->bsky->post->quote(
234234+ quotedPost: $quotedPost,
235235+ content: 'Interesting take!'
236236+);
237237+```
238238+239239+### Post with Images
240240+241241+```php
242242+// Upload from a Laravel request
243243+$blob = $client->atproto->repo->uploadBlob($request->file('image'));
244244+245245+// Or from a file path
246246+$blob = $client->atproto->repo->uploadBlob(new SplFileInfo('/path/to/image.jpg'));
247247+248248+// Or from raw binary data (mimeType required)
249249+$blob = $client->atproto->repo->uploadBlob(
250250+ file: file_get_contents('/path/to/image.jpg'),
251251+ mimeType: 'image/jpeg'
252252+);
253253+254254+$post = $client->bsky->post->withImages(
255255+ content: 'Check out this photo!',
256256+ images: [
257257+ [
258258+ 'image' => $blob->json('blob'),
259259+ 'alt' => 'Description of the image',
260260+ ],
261261+ ]
262262+);
263263+```
264264+265265+### Post with External Link Card
266266+267267+```php
268268+$post = $client->bsky->post->withLink(
269269+ content: 'Great article about Laravel',
270270+ uri: 'https://example.com/article',
271271+ title: 'Article Title',
272272+ description: 'A brief description of the article...'
273273+);
274274+```
275275+276276+### Delete a Post
277277+278278+```php
279279+// Extract rkey from the post URI
280280+$rkey = basename($post->uri);
281281+282282+$client->bsky->post->delete($rkey);
283283+```
284284+285285+## Working with Profiles
286286+287287+### Get a Profile
288288+289289+```php
290290+$profile = $client->bsky->actor->getProfile('someone.bsky.social');
291291+292292+echo $profile->json('displayName');
293293+echo $profile->json('description');
294294+echo $profile->json('followersCount');
295295+```
296296+297297+### Update Your Profile
298298+299299+```php
300300+// Update display name
301301+$client->bsky->profile->updateDisplayName('New Name');
302302+303303+// Update bio/description
304304+$client->bsky->profile->updateDescription('Laravel developer building on AT Protocol');
305305+306306+// Update multiple fields at once
307307+$client->bsky->profile->update([
308308+ 'displayName' => 'New Name',
309309+ 'description' => 'New bio here',
310310+]);
311311+```
312312+313313+### Update Avatar
314314+315315+```php
316316+$blob = $client->atproto->repo->uploadBlob(new SplFileInfo('/path/to/avatar.jpg'));
317317+318318+$client->bsky->profile->updateAvatar($blob->json('blob'));
319319+```
320320+321321+## Social Graph
322322+323323+### Follow a User
324324+325325+```php
326326+// Follow requires the user's DID
327327+$follow = $client->bsky->follow->create('did:plc:...');
328328+```
329329+330330+### Unfollow a User
331331+332332+```php
333333+// Get the rkey from the follow record URI
334334+$client->bsky->follow->delete($rkey);
335335+```
336336+337337+### Like a Post
338338+339339+```php
340340+$postRef = new StrongRef(uri: 'at://...', cid: 'bafyre...');
341341+342342+$like = $client->bsky->like->create($postRef);
343343+```
344344+345345+### Unlike a Post
346346+347347+```php
348348+$client->bsky->like->delete($rkey);
349349+```
350350+351351+## Feed Operations
352352+353353+### Get Your Timeline
354354+355355+```php
356356+$timeline = $client->bsky->feed->getTimeline(limit: 50);
357357+358358+foreach ($timeline->json('feed') as $item) {
359359+ $post = $item['post'];
360360+ echo $post['author']['handle'] . ': ' . $post['record']['text'];
361361+}
362362+```
363363+364364+### Pagination with Cursors
365365+366366+```php
367367+$cursor = null;
368368+369369+do {
370370+ $timeline = $client->bsky->feed->getTimeline(limit: 100, cursor: $cursor);
371371+372372+ foreach ($timeline->json('feed') as $item) {
373373+ // Process posts
374374+ }
375375+376376+ $cursor = $timeline->json('cursor');
377377+} while ($cursor);
378378+```
379379+380380+### Get Author Feed
381381+382382+```php
383383+$feed = $client->bsky->feed->getAuthorFeed(
384384+ actor: 'someone.bsky.social',
385385+ limit: 50
386386+);
387387+```
388388+389389+### Search Posts
390390+391391+```php
392392+$results = $client->bsky->feed->searchPosts(
393393+ q: 'laravel php',
394394+ limit: 25
395395+);
396396+```
397397+398398+### Get Post Thread
399399+400400+```php
401401+$thread = $client->bsky->feed->getPostThread(
402402+ uri: 'at://did:plc:.../app.bsky.feed.post/...',
403403+ depth: 6
404404+);
405405+```
406406+407407+### Get Likes on a Post
408408+409409+```php
410410+$likes = $client->bsky->feed->getLikes(uri: 'at://...');
411411+```
412412+413413+### Get Reposts
414414+415415+```php
416416+$reposts = $client->bsky->feed->getRepostedBy(uri: 'at://...');
417417+```
418418+419419+## Configuration
420420+421421+After publishing the config file, you can customize these options:
422422+423423+```php
424424+// config/client.php
425425+426426+return [
427427+ // OAuth client metadata
428428+ 'client' => [
429429+ 'name' => env('ATP_CLIENT_NAME', config('app.name')),
430430+ 'url' => env('ATP_CLIENT_URL', config('app.url')),
431431+ 'redirect_uris' => [
432432+ env('ATP_CLIENT_REDIRECT_URI', config('app.url').'/auth/atp/callback'),
433433+ ],
434434+ 'scopes' => ['atproto', 'transition:generic'],
435435+ ],
436436+437437+ // Credential storage provider
438438+ 'credential_provider' => \SocialDept\AtpClient\Providers\ArrayCredentialProvider::class,
439439+440440+ // Session behavior
441441+ 'session' => [
442442+ 'refresh_threshold' => 300, // Refresh if expires within 5 minutes
443443+ 'dpop_key_rotation' => 86400, // Rotate DPoP keys after 24 hours
444444+ ],
445445+446446+ // OAuth settings
447447+ 'oauth' => [
448448+ 'disabled' => false,
449449+ 'prefix' => '/atp/oauth/',
450450+ 'private_key' => env('ATP_OAUTH_PRIVATE_KEY'),
451451+ ],
452452+453453+ // HTTP client settings
454454+ 'http' => [
455455+ 'timeout' => 30,
456456+ 'retry' => [
457457+ 'times' => 3,
458458+ 'sleep' => 100,
459459+ ],
460460+ ],
461461+];
462462+```
463463+464464+### Environment Variables
465465+466466+```env
467467+ATP_CLIENT_NAME="My App"
468468+ATP_CLIENT_URL="https://myapp.com"
469469+ATP_CLIENT_REDIRECT_URI="https://myapp.com/auth/atp/callback"
470470+ATP_OAUTH_PRIVATE_KEY="base64-encoded-private-key"
471471+ATP_REFRESH_THRESHOLD=300
472472+ATP_HTTP_TIMEOUT=30
473473+```
474474+475475+## Credential Storage
476476+477477+The package uses a `CredentialProvider` interface for token storage. The default `ArrayCredentialProvider` stores credentials in memory (lost on request end).
478478+479479+### Implementing Custom Storage
480480+481481+```php
482482+use SocialDept\AtpClient\Contracts\CredentialProvider;
483483+use SocialDept\AtpClient\Data\AccessToken;
484484+use SocialDept\AtpClient\Data\Credentials;
485485+486486+class DatabaseCredentialProvider implements CredentialProvider
487487+{
488488+ public function getCredentials(string $identifier): ?Credentials
489489+ {
490490+ $record = AtpCredential::where('identifier', $identifier)->first();
491491+492492+ if (!$record) {
493493+ return null;
494494+ }
495495+496496+ return new Credentials(
497497+ identifier: $record->identifier,
498498+ did: $record->did,
499499+ accessToken: $record->access_token,
500500+ refreshToken: $record->refresh_token,
501501+ expiresAt: $record->expires_at,
502502+ );
503503+ }
504504+505505+ public function storeCredentials(string $identifier, AccessToken $token): void
506506+ {
507507+ AtpCredential::create([
508508+ 'identifier' => $identifier,
509509+ 'did' => $token->did,
510510+ 'access_token' => $token->accessJwt,
511511+ 'refresh_token' => $token->refreshJwt,
512512+ 'expires_at' => $token->expiresAt,
513513+ ]);
514514+ }
515515+516516+ public function updateCredentials(string $identifier, AccessToken $token): void
517517+ {
518518+ AtpCredential::where('identifier', $identifier)->update([
519519+ 'access_token' => $token->accessJwt,
520520+ 'refresh_token' => $token->refreshJwt,
521521+ 'expires_at' => $token->expiresAt,
522522+ ]);
523523+ }
524524+525525+ public function removeCredentials(string $identifier): void
526526+ {
527527+ AtpCredential::where('identifier', $identifier)->delete();
528528+ }
529529+}
530530+```
531531+532532+Register your provider in the config:
533533+534534+```php
535535+'credential_provider' => App\Providers\DatabaseCredentialProvider::class,
536536+```
537537+538538+## Events
539539+540540+The package dispatches events you can listen to:
541541+542542+```php
543543+use SocialDept\AtpClient\Events\TokenRefreshing;
544544+use SocialDept\AtpClient\Events\TokenRefreshed;
545545+546546+// Before token refresh
547547+Event::listen(TokenRefreshing::class, function ($event) {
548548+ Log::info('Refreshing token for: ' . $event->identifier);
549549+});
550550+551551+// After token refresh
552552+Event::listen(TokenRefreshed::class, function ($event) {
553553+ // Update your stored credentials
554554+ $this->credentialProvider->updateCredentials(
555555+ $event->identifier,
556556+ $event->token
557557+ );
558558+});
559559+```
560560+561561+## Available Commands
562562+563563+```bash
564564+# Generate OAuth private key
565565+php artisan atp-client:generate-key
566566+```
567567+568568+## Requirements
569569+570570+- PHP 8.2+
571571+- Laravel 11 or 12
572572+- [socialdept/atp-schema](https://github.com/socialdept/atp-schema) ^0.2
573573+- [socialdept/atp-resolver](https://github.com/socialdept/atp-resolver) ^1.0
574574+575575+## Testing
576576+577577+```bash
578578+composer test
579579+```
580580+581581+## Resources
582582+583583+- [AT Protocol Documentation](https://atproto.com/)
584584+- [Bluesky API Docs](https://docs.bsky.app/)
585585+- [CRYPTO.md](CRYPTO.md) - Cryptographic implementation details
586586+587587+## Support & Contributing
588588+589589+Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/atp-client/issues).
590590+591591+Want to contribute? Check out the [contribution guidelines](contributing.md).
592592+593593+## Changelog
594594+595595+Please see [changelog](changelog.md) for recent changes.
596596+597597+## Credits
598598+599599+- [Miguel Batres](https://batres.co) - founder & lead maintainer
600600+- [All contributors](https://github.com/socialdept/atp-client/graphs/contributors)
601601+602602+## License
603603+604604+AtpClient is open-source software licensed under the [MIT license](license.md).
605605+606606+---
607607+608608+**Built for the Federation** - By Social Dept.
header.png
This is a binary file and will not be displayed.
-57
readme.md
···11-# AtpClient
22-33-[![Latest Version on Packagist][ico-version]][link-packagist]
44-[![Total Downloads][ico-downloads]][link-downloads]
55-[![Build Status][ico-travis]][link-travis]
66-[![StyleCI][ico-styleci]][link-styleci]
77-88-This is where your description should go. Take a look at [contributing.md](contributing.md) to see a to do list.
99-1010-## Installation
1111-1212-Via Composer
1313-1414-```bash
1515-composer require social-dept/atp-client
1616-```
1717-1818-## Usage
1919-2020-## Change log
2121-2222-Please see the [changelog](changelog.md) for more information on what has changed recently.
2323-2424-## Testing
2525-2626-```bash
2727-composer test
2828-```
2929-3030-## Contributing
3131-3232-Please see [contributing.md](contributing.md) for details and a todolist.
3333-3434-## Security
3535-3636-If you discover any security related issues, please email author@email.com instead of using the issue tracker.
3737-3838-## Credits
3939-4040-- [Author Name][link-author]
4141-- [All Contributors][link-contributors]
4242-4343-## License
4444-4545-MIT. Please see the [license file](license.md) for more information.
4646-4747-[ico-version]: https://img.shields.io/packagist/v/social-dept/atp-client.svg?style=flat-square
4848-[ico-downloads]: https://img.shields.io/packagist/dt/social-dept/atp-client.svg?style=flat-square
4949-[ico-travis]: https://img.shields.io/travis/social-dept/atp-client/master.svg?style=flat-square
5050-[ico-styleci]: https://styleci.io/repos/12345678/shield
5151-5252-[link-packagist]: https://packagist.org/packages/social-dept/atp-client
5353-[link-downloads]: https://packagist.org/packages/social-dept/atp-client
5454-[link-travis]: https://travis-ci.org/social-dept/atp-client
5555-[link-styleci]: https://styleci.io/repos/12345678
5656-[link-author]: https://github.com/social-dept
5757-[link-contributors]: ../../contributors