PHPackages                             socialdept/atp-client - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. socialdept/atp-client

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

socialdept/atp-client
=====================

Type-safe AT Protocol HTTP client with OAuth 2.0 support for Laravel

v0.2.0(2mo ago)24332MITPHPPHP ^8.2

Since Nov 26Pushed 2mo agoCompare

[ Source](https://github.com/socialdept/atp-client)[ Packagist](https://packagist.org/packages/socialdept/atp-client)[ RSS](/packages/socialdept-atp-client/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependencies (22)Versions (70)Used By (2)

[![Resolver Header](./header.png)](https://github.com/socialdept/atp-signals)

###  Type-safe AT Protocol HTTP client with OAuth 2.0 support for Laravel.

[](#----type-safe-at-protocol-http-client-with-oauth-20-support-for-laravel)

 [![](https://camo.githubusercontent.com/0ed2e317a7e5b96ca30f274499245cb70b1bd366f93245951ef56b3a7356143e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f736f6369616c646570742f6174702d636c69656e742e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/socialdept/atp-client "Latest Version on Packagist") [![](https://camo.githubusercontent.com/b34551600eab10d0b7f09c75f93b7a061c98260c5800e60d7c0afb7477cb0cd2/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f736f6369616c646570742f6174702d636c69656e742e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/socialdept/atp-client "Total Downloads") [![](https://camo.githubusercontent.com/f814eed66923dceadd1a4e0d022cd2326e1193a688f8f3a55ad458462c6d70db/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f736f6369616c646570742f6174702d636c69656e742f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/socialdept/atp-client/actions/workflows/tests.yml "GitHub Tests Action Status") [![](https://camo.githubusercontent.com/ac9593288c5a760bd11d373962ee053d534e24410dd6c7b9072ec1047dc44fd8/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f736f6369616c646570742f6174702d636c69656e743f7374796c653d666c61742d737175617265)](LICENSE "Software License")

---

What is AtpClient?
------------------

[](#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?
------------------

[](#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
-------------

[](#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
------------

[](#installation)

```
composer require socialdept/atp-client
```

Optionally publish the configuration:

```
php artisan vendor:publish --tag=atp-client-config
```

Getting Started
---------------

[](#getting-started)

Once installed, you're three steps away from using the AT Protocol:

### 1. Choose Your Authentication Method

[](#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

[](#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)

[](#3-store-credentials-oauth-only)

Implement the `CredentialProvider` interface to persist tokens between requests.

What can you build?
-------------------

[](#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
--------------

[](#authentication)

### App Password Flow

[](#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

[](#oauth-20-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
    );

    // Store credentials using your CredentialProvider
    // $token contains: accessJwt, refreshJwt, did, handle, expiresAt
}
```

**Step 3: Use stored credentials**

```
// After storing credentials, use them with Atp::as()
$client = Atp::as('user@bsky.social');
```

### Token Refresh

[](#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\TokenRefreshed;

Event::listen(TokenRefreshed::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
------------------

[](#working-with-posts)

### Create a Simple Post

[](#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

[](#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

[](#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

[](#quote-post)

```
$quotedPost = new StrongRef(uri: 'at://...', cid: 'bafyre...');

$quote = $client->bsky->post->quote(
    quotedPost: $quotedPost,
    content: 'Interesting take!'
);
```

### Post with Images

[](#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-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

[](#delete-a-post)

```
// Extract rkey from the post URI
$rkey = basename($post->uri);

$client->bsky->post->delete($rkey);
```

Working with Profiles
---------------------

[](#working-with-profiles)

### Get a Profile

[](#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-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

[](#update-avatar)

```
$blob = $client->atproto->repo->uploadBlob(new SplFileInfo('/path/to/avatar.jpg'));

$client->bsky->profile->updateAvatar($blob->json('blob'));
```

Social Graph
------------

[](#social-graph)

### Follow a User

[](#follow-a-user)

```
// Follow requires the user's DID
$follow = $client->bsky->follow->create('did:plc:...');
```

### Unfollow a User

[](#unfollow-a-user)

```
// Get the rkey from the follow record URI
$client->bsky->follow->delete($rkey);
```

### Like a Post

[](#like-a-post)

```
$postRef = new StrongRef(uri: 'at://...', cid: 'bafyre...');

$like = $client->bsky->like->create($postRef);
```

### Unlike a Post

[](#unlike-a-post)

```
$client->bsky->like->delete($rkey);
```

Feed Operations
---------------

[](#feed-operations)

### Get Your Timeline

[](#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

[](#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

[](#get-author-feed)

```
$feed = $client->bsky->feed->getAuthorFeed(
    actor: 'someone.bsky.social',
    limit: 50
);
```

### Search Posts

[](#search-posts)

```
$results = $client->bsky->feed->searchPosts(
    q: 'laravel php',
    limit: 25
);
```

### Get Post Thread

[](#get-post-thread)

```
$thread = $client->bsky->feed->getPostThread(
    uri: 'at://did:plc:.../app.bsky.feed.post/...',
    depth: 6
);
```

### Get Likes on a Post

[](#get-likes-on-a-post)

```
$likes = $client->bsky->feed->getLikes(uri: 'at://...');
```

### Get Reposts

[](#get-reposts)

```
$reposts = $client->bsky->feed->getRepostedBy(uri: 'at://...');
```

Configuration
-------------

[](#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

[](#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.

Credential Storage
------------------

[](#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

[](#why-you-need-a-credential-provider)

AT Protocol OAuth uses **single-use refresh tokens**. When a token is refreshed:

1. The old refresh token is immediately invalidated
2. A new refresh token is issued
3. 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

[](#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

[](#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

[](#built-in-credential-providers)

The package includes several credential providers for different use cases:

ProviderPersistenceSetupBest For`ArrayCredentialProvider`None (memory)NoneTesting, single requests`CacheCredentialProvider`Cache driverNoneQuick prototyping, APIs`SessionCredentialProvider`Session lifetimeNoneWeb apps with user sessions`FileCredentialProvider`Permanent (disk)NoneCLI 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

[](#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

[](#implementing-a-database-provider)

```
