Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)
1# atp-client Integration
2
3Parity integrates with atp-client to fetch records from the AT Protocol network and convert them to Eloquent models. The `RecordHelper` class provides a simple interface for these operations.
4
5## RecordHelper
6
7The `RecordHelper` is registered as a singleton and available via the container:
8
9```php
10use SocialDept\AtpParity\Support\RecordHelper;
11
12$helper = app(RecordHelper::class);
13```
14
15### How It Works
16
17When you provide an AT Protocol URI, RecordHelper:
18
191. Parses the URI to extract the DID, collection, and rkey
202. Resolves the DID to find the user's PDS endpoint (via atp-resolver)
213. Creates a public client for that PDS
224. Fetches the record
235. Converts it using the registered mapper
24
25This means it works with any AT Protocol server, not just Bluesky.
26
27## Fetching Records
28
29### `fetch(string $uri, ?string $recordClass = null): mixed`
30
31Fetches a record and returns it as a typed DTO.
32
33```php
34use SocialDept\AtpParity\Support\RecordHelper;
35use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
36
37$helper = app(RecordHelper::class);
38
39// Auto-detect type from registered mapper
40$record = $helper->fetch('at://did:plc:abc123/app.bsky.feed.post/xyz789');
41
42// Or specify the class explicitly
43$record = $helper->fetch(
44 'at://did:plc:abc123/app.bsky.feed.post/xyz789',
45 Post::class
46);
47
48// Access typed properties
49echo $record->text;
50echo $record->createdAt;
51```
52
53### `fetchAsModel(string $uri): ?Model`
54
55Fetches a record and converts it to an Eloquent model (unsaved).
56
57```php
58$post = $helper->fetchAsModel('at://did:plc:abc123/app.bsky.feed.post/xyz789');
59
60if ($post) {
61 echo $post->content;
62 echo $post->atp_uri;
63 echo $post->atp_cid;
64
65 // Save if you want to persist it
66 $post->save();
67}
68```
69
70Returns `null` if no mapper is registered for the collection.
71
72### `sync(string $uri): ?Model`
73
74Fetches a record and upserts it to the database.
75
76```php
77// Creates or updates the model
78$post = $helper->sync('at://did:plc:abc123/app.bsky.feed.post/xyz789');
79
80// Model is saved automatically
81echo $post->id;
82echo $post->content;
83```
84
85This is the most common method for syncing remote records to your database.
86
87## Working with Responses
88
89### `hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed`
90
91If you already have a `GetRecordResponse` from atp-client, convert it to a typed DTO:
92
93```php
94use SocialDept\AtpClient\Facades\Atp;
95use SocialDept\AtpParity\Support\RecordHelper;
96
97$helper = app(RecordHelper::class);
98
99// Using atp-client directly
100$client = Atp::public();
101$response = $client->atproto->repo->getRecord(
102 'did:plc:abc123',
103 'app.bsky.feed.post',
104 'xyz789'
105);
106
107// Convert to typed DTO
108$record = $helper->hydrateRecord($response);
109```
110
111## Practical Examples
112
113### Syncing a Single Post
114
115```php
116$helper = app(RecordHelper::class);
117
118$uri = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k2yihcrp6f2c';
119$post = $helper->sync($uri);
120
121echo "Synced: {$post->content}";
122```
123
124### Syncing Multiple Posts
125
126```php
127$helper = app(RecordHelper::class);
128
129$uris = [
130 'at://did:plc:abc/app.bsky.feed.post/123',
131 'at://did:plc:def/app.bsky.feed.post/456',
132 'at://did:plc:ghi/app.bsky.feed.post/789',
133];
134
135foreach ($uris as $uri) {
136 try {
137 $post = $helper->sync($uri);
138 echo "Synced: {$post->id}\n";
139 } catch (\Exception $e) {
140 echo "Failed to sync {$uri}: {$e->getMessage()}\n";
141 }
142}
143```
144
145### Fetching for Preview (Without Saving)
146
147```php
148$helper = app(RecordHelper::class);
149
150// Get model without saving
151$post = $helper->fetchAsModel('at://did:plc:xxx/app.bsky.feed.post/abc');
152
153if ($post) {
154 return view('posts.preview', ['post' => $post]);
155}
156
157return abort(404);
158```
159
160### Checking if Record Exists Locally
161
162```php
163use App\Models\Post;
164use SocialDept\AtpParity\Support\RecordHelper;
165
166$uri = 'at://did:plc:xxx/app.bsky.feed.post/abc';
167
168// Check local database first
169$post = Post::whereAtpUri($uri)->first();
170
171if (!$post) {
172 // Not in database, fetch from network
173 $helper = app(RecordHelper::class);
174 $post = $helper->sync($uri);
175}
176
177return $post;
178```
179
180### Building a Post Importer
181
182```php
183namespace App\Services;
184
185use SocialDept\AtpParity\Support\RecordHelper;
186use SocialDept\AtpClient\Facades\Atp;
187
188class PostImporter
189{
190 public function __construct(
191 protected RecordHelper $helper
192 ) {}
193
194 /**
195 * Import all posts from a user.
196 */
197 public function importUserPosts(string $did, int $limit = 100): array
198 {
199 $imported = [];
200 $client = Atp::public();
201 $cursor = null;
202
203 do {
204 $response = $client->atproto->repo->listRecords(
205 repo: $did,
206 collection: 'app.bsky.feed.post',
207 limit: min($limit - count($imported), 100),
208 cursor: $cursor
209 );
210
211 foreach ($response->records as $record) {
212 $post = $this->helper->sync($record->uri);
213 $imported[] = $post;
214
215 if (count($imported) >= $limit) {
216 break 2;
217 }
218 }
219
220 $cursor = $response->cursor;
221 } while ($cursor && count($imported) < $limit);
222
223 return $imported;
224 }
225}
226```
227
228## Error Handling
229
230RecordHelper returns `null` for various failure conditions:
231
232```php
233$helper = app(RecordHelper::class);
234
235// Invalid URI format
236$result = $helper->fetch('not-a-valid-uri');
237// Returns null
238
239// No mapper registered for collection
240$result = $helper->fetchAsModel('at://did:plc:xxx/some.unknown.collection/abc');
241// Returns null
242
243// PDS resolution failed
244$result = $helper->fetch('at://did:plc:invalid/app.bsky.feed.post/abc');
245// Returns null (or throws exception depending on resolver config)
246```
247
248For more control, catch exceptions:
249
250```php
251use SocialDept\AtpResolver\Exceptions\DidResolutionException;
252
253try {
254 $post = $helper->sync($uri);
255} catch (DidResolutionException $e) {
256 // DID could not be resolved
257 Log::warning("Could not resolve DID for {$uri}");
258} catch (\Exception $e) {
259 // Network error, invalid response, etc.
260 Log::error("Failed to sync {$uri}: {$e->getMessage()}");
261}
262```
263
264## Performance Considerations
265
266### PDS Client Caching
267
268RecordHelper caches public clients by PDS endpoint:
269
270```php
271// First request to this PDS - creates client
272$helper->sync('at://did:plc:abc/app.bsky.feed.post/1');
273
274// Same PDS - reuses cached client
275$helper->sync('at://did:plc:abc/app.bsky.feed.post/2');
276
277// Different PDS - creates new client
278$helper->sync('at://did:plc:xyz/app.bsky.feed.post/1');
279```
280
281### DID Resolution Caching
282
283atp-resolver caches DID documents and PDS endpoints. Default TTL is 1 hour.
284
285### Batch Operations
286
287For bulk imports, consider using atp-client's `listRecords` directly and then batch-processing:
288
289```php
290use SocialDept\AtpClient\Facades\Atp;
291use SocialDept\AtpParity\MapperRegistry;
292
293$client = Atp::public($pdsEndpoint);
294$registry = app(MapperRegistry::class);
295$mapper = $registry->forLexicon('app.bsky.feed.post');
296
297$response = $client->atproto->repo->listRecords(
298 repo: $did,
299 collection: 'app.bsky.feed.post',
300 limit: 100
301);
302
303foreach ($response->records as $record) {
304 $recordClass = $mapper->recordClass();
305 $dto = $recordClass::fromArray($record->value);
306
307 $mapper->upsert($dto, [
308 'uri' => $record->uri,
309 'cid' => $record->cid,
310 ]);
311}
312```
313
314## Using with Authenticated Client
315
316While RecordHelper uses public clients, you can also use authenticated clients for records that require auth:
317
318```php
319use SocialDept\AtpClient\Facades\Atp;
320use SocialDept\AtpParity\MapperRegistry;
321
322// Authenticated client
323$client = Atp::as('user.bsky.social');
324
325// Fetch a record that requires auth
326$response = $client->atproto->repo->getRecord(
327 repo: $client->session()->did(),
328 collection: 'app.bsky.feed.post',
329 rkey: 'abc123'
330);
331
332// Convert using mapper
333$registry = app(MapperRegistry::class);
334$mapper = $registry->forLexicon('app.bsky.feed.post');
335
336$recordClass = $mapper->recordClass();
337$record = $recordClass::fromArray($response->value);
338
339$model = $mapper->upsert($record, [
340 'uri' => $response->uri,
341 'cid' => $response->cid,
342]);
343```