Client Extensions#
AtpClient provides an extension system that allows you to add custom functionality. You can register domain clients (like $client->myDomain) or request clients on existing domains (like $client->bsky->myFeature).
Quick Reference#
Available Methods#
| Method | Description |
|---|---|
AtpClient::extend($name, $callback) |
Register a domain client extension |
AtpClient::extendDomain($domain, $name, $callback) |
Register a request client on an existing domain |
AtpClient::hasExtension($name) |
Check if a domain extension is registered |
AtpClient::hasDomainExtension($domain, $name) |
Check if a request client extension is registered |
AtpClient::flushExtensions() |
Clear all extensions (useful for testing) |
Extension Types#
| Type | Access Pattern | Use Case |
|---|---|---|
| Domain Client | $client->myDomain |
Group related functionality under a namespace |
| Request Client | $client->bsky->myFeature |
Add methods to an existing domain |
Generator Commands#
Quickly scaffold extension classes using artisan commands:
# 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
The generated files are placed in configurable directories. You can customize these paths in config/client.php:
'generators' => [
'client_path' => 'app/Services/Clients',
'request_path' => 'app/Services/Clients/Requests',
],
Understanding Extensions#
Extensions follow a lazy-loading pattern. When you register an extension, the callback is stored but not executed. The extension is only instantiated when first accessed:
// Registration - callback stored, not executed
AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
// First access - callback executed, instance cached
$client->analytics->trackEvent('login');
// Subsequent access - cached instance returned
$client->analytics->trackEvent('post_created');
This ensures extensions don't add overhead unless they're actually used.
Creating a Domain Client#
A domain client adds a new namespace to AtpClient, accessible as a property.
Step 1: Create Your Client Class#
<?php
namespace App\Atp;
use SocialDept\AtpClient\AtpClient;
class AnalyticsClient
{
protected AtpClient $atp;
public function __construct(AtpClient $parent)
{
$this->atp = $parent;
}
public function trackEvent(string $event, array $properties = []): void
{
// Your analytics logic here
// You have full access to the authenticated client via $this->atp
}
public function getEngagementStats(string $actor): array
{
$profile = $this->atp->bsky->actor->getProfile($actor);
return [
'followers' => $profile->followersCount,
'following' => $profile->followsCount,
'posts' => $profile->postsCount,
];
}
}
Step 2: Register the Extension#
In your AppServiceProvider:
<?php
namespace App\Providers;
use App\Atp\AnalyticsClient;
use Illuminate\Support\ServiceProvider;
use SocialDept\AtpClient\AtpClient;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp));
}
}
Step 3: Use Your Extension#
use SocialDept\AtpClient\Facades\Atp;
$client = Atp::as('user.bsky.social');
$client->analytics->trackEvent('page_view', ['page' => '/feed']);
$stats = $client->analytics->getEngagementStats('someone.bsky.social');
Creating a Request Client#
A request client extends an existing domain (like bsky, atproto, chat, or ozone). This is useful when you want to add methods that logically belong alongside the built-in functionality.
Step 1: Create Your Request Client Class#
Extend the base Request class to get access to the parent AtpClient:
<?php
namespace App\Atp;
use SocialDept\AtpClient\Client\Requests\Request;
class BskyMetricsClient extends Request
{
public function getPostEngagement(string $uri): array
{
$thread = $this->atp->bsky->feed->getPostThread($uri);
$post = $thread->thread['post'] ?? null;
if (! $post) {
return [];
}
return [
'likes' => $post['likeCount'] ?? 0,
'reposts' => $post['repostCount'] ?? 0,
'replies' => $post['replyCount'] ?? 0,
'quotes' => $post['quoteCount'] ?? 0,
];
}
public function getAuthorMetrics(string $actor): array
{
$feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 100);
$posts = $feed->feed;
$totalLikes = 0;
$totalReposts = 0;
foreach ($posts as $item) {
$totalLikes += $item['post']['likeCount'] ?? 0;
$totalReposts += $item['post']['repostCount'] ?? 0;
}
return [
'posts_analyzed' => count($posts),
'total_likes' => $totalLikes,
'total_reposts' => $totalReposts,
'avg_likes' => count($posts) > 0 ? $totalLikes / count($posts) : 0,
];
}
}
Step 2: Register the Extension#
use App\Atp\BskyMetricsClient;
use SocialDept\AtpClient\AtpClient;
public function boot(): void
{
AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky));
}
The callback receives the domain client instance (BskyClient in this case), which is passed to your request client's constructor.
Step 3: Use Your Extension#
$client = Atp::as('user.bsky.social');
$engagement = $client->bsky->metrics->getPostEngagement('at://did:plc:.../app.bsky.feed.post/...');
$authorMetrics = $client->bsky->metrics->getAuthorMetrics('someone.bsky.social');
Public vs Authenticated Mode#
The AtpClient class works in both public and authenticated modes. Both Atp::public() and Atp::as() return the same AtpClient class:
// Public mode - no authentication
$publicClient = Atp::public('https://public.api.bsky.app');
$publicClient->bsky->actor->getProfile('someone.bsky.social');
// Authenticated mode - with session
$authClient = Atp::as('did:plc:xxx');
$authClient->bsky->actor->getProfile('someone.bsky.social');
Extensions registered on AtpClient work in both modes. The underlying HTTP layer automatically handles authentication based on whether a session is present.
Registering Multiple Extensions#
You can register multiple extensions in your service provider:
public function boot(): void
{
// Domain clients
AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
AtpClient::extend('moderation', fn($atp) => new ModerationClient($atp));
// Request clients
AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky));
AtpClient::extendDomain('bsky', 'lists', fn($bsky) => new BskyListsClient($bsky));
AtpClient::extendDomain('atproto', 'backup', fn($atproto) => new RepoBackupClient($atproto));
}
Conditional Registration#
Register extensions conditionally based on environment or configuration:
public function boot(): void
{
if (config('services.analytics.enabled')) {
AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
}
if (app()->environment('local')) {
AtpClient::extend('debug', fn($atp) => new DebugClient($atp));
}
}
Testing Extensions#
Test Isolation#
Use flushExtensions() to clear registered extensions between tests:
use SocialDept\AtpClient\AtpClient;
use PHPUnit\Framework\TestCase;
class MyExtensionTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
AtpClient::flushExtensions();
}
protected function tearDown(): void
{
AtpClient::flushExtensions();
parent::tearDown();
}
public function test_extension_is_registered(): void
{
AtpClient::extend('test', fn($atp) => new TestClient($atp));
$this->assertTrue(AtpClient::hasExtension('test'));
}
}
Checking Registration#
Use the static methods to verify extensions are registered:
// Check domain extension
if (AtpClient::hasExtension('analytics')) {
$client->analytics->trackEvent('test');
}
// Check request client extension
if (AtpClient::hasDomainExtension('bsky', 'metrics')) {
$metrics = $client->bsky->metrics->getAuthorMetrics($actor);
}
Advanced Patterns#
Accessing the HTTP Client#
Domain client extensions can access the underlying HTTP client for custom API calls:
class CustomApiClient
{
protected AtpClient $atp;
public function __construct(AtpClient $parent)
{
$this->atp = $parent;
}
public function customEndpoint(array $params): array
{
// Access the authenticated HTTP client
$response = $this->atp->client->get('com.example.customEndpoint', $params);
return $response->json();
}
public function customProcedure(array $data): array
{
$response = $this->atp->client->post('com.example.customProcedure', $data);
return $response->json();
}
}
Using Typed Responses#
Return typed response objects for better IDE support:
use SocialDept\AtpClient\Data\Responses\Response;
class MetricsResponse extends Response
{
public function __construct(
public readonly int $likes,
public readonly int $reposts,
public readonly int $replies,
) {}
public static function fromArray(array $data): static
{
return new static(
likes: $data['likes'] ?? 0,
reposts: $data['reposts'] ?? 0,
replies: $data['replies'] ?? 0,
);
}
}
class BskyMetricsClient extends Request
{
public function getPostMetrics(string $uri): MetricsResponse
{
$thread = $this->atp->bsky->feed->getPostThread($uri);
$post = $thread->thread['post'] ?? [];
return MetricsResponse::fromArray([
'likes' => $post['likeCount'] ?? 0,
'reposts' => $post['repostCount'] ?? 0,
'replies' => $post['replyCount'] ?? 0,
]);
}
}
Composing Multiple Clients#
Extensions can use other extensions or built-in clients:
class DashboardClient
{
protected AtpClient $atp;
public function __construct(AtpClient $parent)
{
$this->atp = $parent;
}
public function getOverview(string $actor): array
{
// Use built-in clients
$profile = $this->atp->bsky->actor->getProfile($actor);
$feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 10);
// Use other extensions (if registered)
$metrics = AtpClient::hasDomainExtension('bsky', 'metrics')
? $this->atp->bsky->metrics->getAuthorMetrics($actor)
: null;
return [
'profile' => $profile,
'recent_posts' => $feed->feed,
'metrics' => $metrics,
];
}
}
Documenting Scope Requirements#
Use the #[ScopedEndpoint] and #[PublicEndpoint] attributes to document the authentication requirements of your extension methods:
use SocialDept\AtpClient\Attributes\PublicEndpoint;
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
use SocialDept\AtpClient\Client\Requests\Request;
use SocialDept\AtpClient\Enums\Scope;
class BskyMetricsClient extends Request
{
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
public function getTimelineMetrics(): array
{
$timeline = $this->atp->bsky->feed->getTimeline();
// Process and return metrics...
}
#[PublicEndpoint]
public function getPublicPostMetrics(string $uri): array
{
$thread = $this->atp->bsky->feed->getPostThread($uri);
// Process and return metrics...
}
}
Note: These attributes currently serve as documentation only. Runtime scope enforcement will be implemented in a future release. Using them correctly now ensures forward compatibility.
Methods with #[ScopedEndpoint] indicate they require authentication, while methods with #[PublicEndpoint] work without authentication. See scopes.md for full documentation on scope handling.
Available Domains#
You can extend these built-in domains with extendDomain():
| Domain | Description |
|---|---|
bsky |
Bluesky-specific operations (app.bsky.*) |
atproto |
AT Protocol core operations (com.atproto.*) |
chat |
Direct messaging operations (chat.bsky.*) |
ozone |
Moderation tools (tools.ozone.*) |