Laravel AT Protocol Client (alpha & unstable)
1# Client Extensions
2
3AtpClient 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`).
4
5## Quick Reference
6
7### Available Methods
8
9| Method | Description |
10|--------|-------------|
11| `AtpClient::extend($name, $callback)` | Register a domain client extension |
12| `AtpClient::extendDomain($domain, $name, $callback)` | Register a request client on an existing domain |
13| `AtpClient::hasExtension($name)` | Check if a domain extension is registered |
14| `AtpClient::hasDomainExtension($domain, $name)` | Check if a request client extension is registered |
15| `AtpClient::flushExtensions()` | Clear all extensions (useful for testing) |
16
17### Extension Types
18
19| Type | Access Pattern | Use Case |
20|------|----------------|----------|
21| Domain Client | `$client->myDomain` | Group related functionality under a namespace |
22| Request Client | `$client->bsky->myFeature` | Add methods to an existing domain |
23
24### Generator Commands
25
26Quickly scaffold extension classes using artisan commands:
27
28```bash
29# Create a domain client extension
30php artisan make:atp-client AnalyticsClient
31
32# Create a request client extension for an existing domain
33php artisan make:atp-request MetricsClient --domain=bsky
34```
35
36The generated files are placed in configurable directories. You can customize these paths in `config/client.php`:
37
38```php
39'generators' => [
40 'client_path' => 'app/Services/Clients',
41 'request_path' => 'app/Services/Clients/Requests',
42],
43```
44
45## Understanding Extensions
46
47Extensions 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:
48
49```php
50// Registration - callback stored, not executed
51AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
52
53// First access - callback executed, instance cached
54$client->analytics->trackEvent('login');
55
56// Subsequent access - cached instance returned
57$client->analytics->trackEvent('post_created');
58```
59
60This ensures extensions don't add overhead unless they're actually used.
61
62## Creating a Domain Client
63
64A domain client adds a new namespace to AtpClient, accessible as a property.
65
66### Step 1: Create Your Client Class
67
68```php
69<?php
70
71namespace App\Atp;
72
73use SocialDept\AtpClient\AtpClient;
74
75class AnalyticsClient
76{
77 protected AtpClient $atp;
78
79 public function __construct(AtpClient $parent)
80 {
81 $this->atp = $parent;
82 }
83
84 public function trackEvent(string $event, array $properties = []): void
85 {
86 // Your analytics logic here
87 // You have full access to the authenticated client via $this->atp
88 }
89
90 public function getEngagementStats(string $actor): array
91 {
92 $profile = $this->atp->bsky->actor->getProfile($actor);
93
94 return [
95 'followers' => $profile->followersCount,
96 'following' => $profile->followsCount,
97 'posts' => $profile->postsCount,
98 ];
99 }
100}
101```
102
103### Step 2: Register the Extension
104
105In your `AppServiceProvider`:
106
107```php
108<?php
109
110namespace App\Providers;
111
112use App\Atp\AnalyticsClient;
113use Illuminate\Support\ServiceProvider;
114use SocialDept\AtpClient\AtpClient;
115
116class AppServiceProvider extends ServiceProvider
117{
118 public function boot(): void
119 {
120 AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp));
121 }
122}
123```
124
125### Step 3: Use Your Extension
126
127```php
128use SocialDept\AtpClient\Facades\Atp;
129
130$client = Atp::as('user.bsky.social');
131
132$client->analytics->trackEvent('page_view', ['page' => '/feed']);
133
134$stats = $client->analytics->getEngagementStats('someone.bsky.social');
135```
136
137## Creating a Request Client
138
139A 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.
140
141### Step 1: Create Your Request Client Class
142
143Extend the base `Request` class to get access to the parent AtpClient:
144
145```php
146<?php
147
148namespace App\Atp;
149
150use SocialDept\AtpClient\Client\Requests\Request;
151
152class BskyMetricsClient extends Request
153{
154 public function getPostEngagement(string $uri): array
155 {
156 $thread = $this->atp->bsky->feed->getPostThread($uri);
157 $post = $thread->thread['post'] ?? null;
158
159 if (! $post) {
160 return [];
161 }
162
163 return [
164 'likes' => $post['likeCount'] ?? 0,
165 'reposts' => $post['repostCount'] ?? 0,
166 'replies' => $post['replyCount'] ?? 0,
167 'quotes' => $post['quoteCount'] ?? 0,
168 ];
169 }
170
171 public function getAuthorMetrics(string $actor): array
172 {
173 $feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 100);
174 $posts = $feed->feed;
175
176 $totalLikes = 0;
177 $totalReposts = 0;
178
179 foreach ($posts as $item) {
180 $totalLikes += $item['post']['likeCount'] ?? 0;
181 $totalReposts += $item['post']['repostCount'] ?? 0;
182 }
183
184 return [
185 'posts_analyzed' => count($posts),
186 'total_likes' => $totalLikes,
187 'total_reposts' => $totalReposts,
188 'avg_likes' => count($posts) > 0 ? $totalLikes / count($posts) : 0,
189 ];
190 }
191}
192```
193
194### Step 2: Register the Extension
195
196```php
197use App\Atp\BskyMetricsClient;
198use SocialDept\AtpClient\AtpClient;
199
200public function boot(): void
201{
202 AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky));
203}
204```
205
206The callback receives the domain client instance (`BskyClient` in this case), which is passed to your request client's constructor.
207
208### Step 3: Use Your Extension
209
210```php
211$client = Atp::as('user.bsky.social');
212
213$engagement = $client->bsky->metrics->getPostEngagement('at://did:plc:.../app.bsky.feed.post/...');
214
215$authorMetrics = $client->bsky->metrics->getAuthorMetrics('someone.bsky.social');
216```
217
218## Public vs Authenticated Mode
219
220The `AtpClient` class works in both public and authenticated modes. Both `Atp::public()` and `Atp::as()` return the same `AtpClient` class:
221
222```php
223// Public mode - no authentication
224$publicClient = Atp::public('https://public.api.bsky.app');
225$publicClient->bsky->actor->getProfile('someone.bsky.social');
226
227// Authenticated mode - with session
228$authClient = Atp::as('did:plc:xxx');
229$authClient->bsky->actor->getProfile('someone.bsky.social');
230```
231
232Extensions registered on `AtpClient` work in both modes. The underlying HTTP layer automatically handles authentication based on whether a session is present.
233
234## Registering Multiple Extensions
235
236You can register multiple extensions in your service provider:
237
238```php
239public function boot(): void
240{
241 // Domain clients
242 AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
243 AtpClient::extend('moderation', fn($atp) => new ModerationClient($atp));
244
245 // Request clients
246 AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky));
247 AtpClient::extendDomain('bsky', 'lists', fn($bsky) => new BskyListsClient($bsky));
248 AtpClient::extendDomain('atproto', 'backup', fn($atproto) => new RepoBackupClient($atproto));
249}
250```
251
252## Conditional Registration
253
254Register extensions conditionally based on environment or configuration:
255
256```php
257public function boot(): void
258{
259 if (config('services.analytics.enabled')) {
260 AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
261 }
262
263 if (app()->environment('local')) {
264 AtpClient::extend('debug', fn($atp) => new DebugClient($atp));
265 }
266}
267```
268
269## Testing Extensions
270
271### Test Isolation
272
273Use `flushExtensions()` to clear registered extensions between tests:
274
275```php
276use SocialDept\AtpClient\AtpClient;
277use PHPUnit\Framework\TestCase;
278
279class MyExtensionTest extends TestCase
280{
281 protected function setUp(): void
282 {
283 parent::setUp();
284 AtpClient::flushExtensions();
285 }
286
287 protected function tearDown(): void
288 {
289 AtpClient::flushExtensions();
290 parent::tearDown();
291 }
292
293 public function test_extension_is_registered(): void
294 {
295 AtpClient::extend('test', fn($atp) => new TestClient($atp));
296
297 $this->assertTrue(AtpClient::hasExtension('test'));
298 }
299}
300```
301
302### Checking Registration
303
304Use the static methods to verify extensions are registered:
305
306```php
307// Check domain extension
308if (AtpClient::hasExtension('analytics')) {
309 $client->analytics->trackEvent('test');
310}
311
312// Check request client extension
313if (AtpClient::hasDomainExtension('bsky', 'metrics')) {
314 $metrics = $client->bsky->metrics->getAuthorMetrics($actor);
315}
316```
317
318## Advanced Patterns
319
320### Accessing the HTTP Client
321
322Domain client extensions can access the underlying HTTP client for custom API calls:
323
324```php
325class CustomApiClient
326{
327 protected AtpClient $atp;
328
329 public function __construct(AtpClient $parent)
330 {
331 $this->atp = $parent;
332 }
333
334 public function customEndpoint(array $params): array
335 {
336 // Access the authenticated HTTP client
337 $response = $this->atp->client->get('com.example.customEndpoint', $params);
338
339 return $response->json();
340 }
341
342 public function customProcedure(array $data): array
343 {
344 $response = $this->atp->client->post('com.example.customProcedure', $data);
345
346 return $response->json();
347 }
348}
349```
350
351### Using Typed Responses
352
353Return typed response objects for better IDE support:
354
355```php
356use SocialDept\AtpClient\Data\Responses\Response;
357
358class MetricsResponse extends Response
359{
360 public function __construct(
361 public readonly int $likes,
362 public readonly int $reposts,
363 public readonly int $replies,
364 ) {}
365
366 public static function fromArray(array $data): static
367 {
368 return new static(
369 likes: $data['likes'] ?? 0,
370 reposts: $data['reposts'] ?? 0,
371 replies: $data['replies'] ?? 0,
372 );
373 }
374}
375
376class BskyMetricsClient extends Request
377{
378 public function getPostMetrics(string $uri): MetricsResponse
379 {
380 $thread = $this->atp->bsky->feed->getPostThread($uri);
381 $post = $thread->thread['post'] ?? [];
382
383 return MetricsResponse::fromArray([
384 'likes' => $post['likeCount'] ?? 0,
385 'reposts' => $post['repostCount'] ?? 0,
386 'replies' => $post['replyCount'] ?? 0,
387 ]);
388 }
389}
390```
391
392### Composing Multiple Clients
393
394Extensions can use other extensions or built-in clients:
395
396```php
397class DashboardClient
398{
399 protected AtpClient $atp;
400
401 public function __construct(AtpClient $parent)
402 {
403 $this->atp = $parent;
404 }
405
406 public function getOverview(string $actor): array
407 {
408 // Use built-in clients
409 $profile = $this->atp->bsky->actor->getProfile($actor);
410 $feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 10);
411
412 // Use other extensions (if registered)
413 $metrics = AtpClient::hasDomainExtension('bsky', 'metrics')
414 ? $this->atp->bsky->metrics->getAuthorMetrics($actor)
415 : null;
416
417 return [
418 'profile' => $profile,
419 'recent_posts' => $feed->feed,
420 'metrics' => $metrics,
421 ];
422 }
423}
424```
425
426### Documenting Scope Requirements
427
428Use the `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes to document the authentication requirements of your extension methods:
429
430```php
431use SocialDept\AtpClient\Attributes\PublicEndpoint;
432use SocialDept\AtpClient\Attributes\ScopedEndpoint;
433use SocialDept\AtpClient\Client\Requests\Request;
434use SocialDept\AtpClient\Enums\Scope;
435
436class BskyMetricsClient extends Request
437{
438 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
439 public function getTimelineMetrics(): array
440 {
441 $timeline = $this->atp->bsky->feed->getTimeline();
442 // Process and return metrics...
443 }
444
445 #[PublicEndpoint]
446 public function getPublicPostMetrics(string $uri): array
447 {
448 $thread = $this->atp->bsky->feed->getPostThread($uri);
449 // Process and return metrics...
450 }
451}
452```
453
454> **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.
455
456Methods with `#[ScopedEndpoint]` indicate they require authentication, while methods with `#[PublicEndpoint]` work without authentication. See [scopes.md](scopes.md) for full documentation on scope handling.
457
458## Available Domains
459
460You can extend these built-in domains with `extendDomain()`:
461
462| Domain | Description |
463|--------|-------------|
464| `bsky` | Bluesky-specific operations (app.bsky.*) |
465| `atproto` | AT Protocol core operations (com.atproto.*) |
466| `chat` | Direct messaging operations (chat.bsky.*) |
467| `ozone` | Moderation tools (tools.ozone.*) |