atp-client Integration#
Parity 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.
RecordHelper#
The RecordHelper is registered as a singleton and available via the container:
use SocialDept\AtpParity\Support\RecordHelper;
$helper = app(RecordHelper::class);
How It Works#
When you provide an AT Protocol URI, RecordHelper:
- Parses the URI to extract the DID, collection, and rkey
- Resolves the DID to find the user's PDS endpoint (via atp-resolver)
- Creates a public client for that PDS
- Fetches the record
- Converts it using the registered mapper
This means it works with any AT Protocol server, not just Bluesky.
Fetching Records#
fetch(string $uri, ?string $recordClass = null): mixed#
Fetches a record and returns it as a typed DTO.
use SocialDept\AtpParity\Support\RecordHelper;
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
$helper = app(RecordHelper::class);
// Auto-detect type from registered mapper
$record = $helper->fetch('at://did:plc:abc123/app.bsky.feed.post/xyz789');
// Or specify the class explicitly
$record = $helper->fetch(
'at://did:plc:abc123/app.bsky.feed.post/xyz789',
Post::class
);
// Access typed properties
echo $record->text;
echo $record->createdAt;
fetchAsModel(string $uri): ?Model#
Fetches a record and converts it to an Eloquent model (unsaved).
$post = $helper->fetchAsModel('at://did:plc:abc123/app.bsky.feed.post/xyz789');
if ($post) {
echo $post->content;
echo $post->atp_uri;
echo $post->atp_cid;
// Save if you want to persist it
$post->save();
}
Returns null if no mapper is registered for the collection.
sync(string $uri): ?Model#
Fetches a record and upserts it to the database.
// Creates or updates the model
$post = $helper->sync('at://did:plc:abc123/app.bsky.feed.post/xyz789');
// Model is saved automatically
echo $post->id;
echo $post->content;
This is the most common method for syncing remote records to your database.
Working with Responses#
hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed#
If you already have a GetRecordResponse from atp-client, convert it to a typed DTO:
use SocialDept\AtpClient\Facades\Atp;
use SocialDept\AtpParity\Support\RecordHelper;
$helper = app(RecordHelper::class);
// Using atp-client directly
$client = Atp::public();
$response = $client->atproto->repo->getRecord(
'did:plc:abc123',
'app.bsky.feed.post',
'xyz789'
);
// Convert to typed DTO
$record = $helper->hydrateRecord($response);
Practical Examples#
Syncing a Single Post#
$helper = app(RecordHelper::class);
$uri = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k2yihcrp6f2c';
$post = $helper->sync($uri);
echo "Synced: {$post->content}";
Syncing Multiple Posts#
$helper = app(RecordHelper::class);
$uris = [
'at://did:plc:abc/app.bsky.feed.post/123',
'at://did:plc:def/app.bsky.feed.post/456',
'at://did:plc:ghi/app.bsky.feed.post/789',
];
foreach ($uris as $uri) {
try {
$post = $helper->sync($uri);
echo "Synced: {$post->id}\n";
} catch (\Exception $e) {
echo "Failed to sync {$uri}: {$e->getMessage()}\n";
}
}
Fetching for Preview (Without Saving)#
$helper = app(RecordHelper::class);
// Get model without saving
$post = $helper->fetchAsModel('at://did:plc:xxx/app.bsky.feed.post/abc');
if ($post) {
return view('posts.preview', ['post' => $post]);
}
return abort(404);
Checking if Record Exists Locally#
use App\Models\Post;
use SocialDept\AtpParity\Support\RecordHelper;
$uri = 'at://did:plc:xxx/app.bsky.feed.post/abc';
// Check local database first
$post = Post::whereAtpUri($uri)->first();
if (!$post) {
// Not in database, fetch from network
$helper = app(RecordHelper::class);
$post = $helper->sync($uri);
}
return $post;
Building a Post Importer#
namespace App\Services;
use SocialDept\AtpParity\Support\RecordHelper;
use SocialDept\AtpClient\Facades\Atp;
class PostImporter
{
public function __construct(
protected RecordHelper $helper
) {}
/**
* Import all posts from a user.
*/
public function importUserPosts(string $did, int $limit = 100): array
{
$imported = [];
$client = Atp::public();
$cursor = null;
do {
$response = $client->atproto->repo->listRecords(
repo: $did,
collection: 'app.bsky.feed.post',
limit: min($limit - count($imported), 100),
cursor: $cursor
);
foreach ($response->records as $record) {
$post = $this->helper->sync($record->uri);
$imported[] = $post;
if (count($imported) >= $limit) {
break 2;
}
}
$cursor = $response->cursor;
} while ($cursor && count($imported) < $limit);
return $imported;
}
}
Error Handling#
RecordHelper returns null for various failure conditions:
$helper = app(RecordHelper::class);
// Invalid URI format
$result = $helper->fetch('not-a-valid-uri');
// Returns null
// No mapper registered for collection
$result = $helper->fetchAsModel('at://did:plc:xxx/some.unknown.collection/abc');
// Returns null
// PDS resolution failed
$result = $helper->fetch('at://did:plc:invalid/app.bsky.feed.post/abc');
// Returns null (or throws exception depending on resolver config)
For more control, catch exceptions:
use SocialDept\AtpResolver\Exceptions\DidResolutionException;
try {
$post = $helper->sync($uri);
} catch (DidResolutionException $e) {
// DID could not be resolved
Log::warning("Could not resolve DID for {$uri}");
} catch (\Exception $e) {
// Network error, invalid response, etc.
Log::error("Failed to sync {$uri}: {$e->getMessage()}");
}
Performance Considerations#
PDS Client Caching#
RecordHelper caches public clients by PDS endpoint:
// First request to this PDS - creates client
$helper->sync('at://did:plc:abc/app.bsky.feed.post/1');
// Same PDS - reuses cached client
$helper->sync('at://did:plc:abc/app.bsky.feed.post/2');
// Different PDS - creates new client
$helper->sync('at://did:plc:xyz/app.bsky.feed.post/1');
DID Resolution Caching#
atp-resolver caches DID documents and PDS endpoints. Default TTL is 1 hour.
Batch Operations#
For bulk imports, consider using atp-client's listRecords directly and then batch-processing:
use SocialDept\AtpClient\Facades\Atp;
use SocialDept\AtpParity\MapperRegistry;
$client = Atp::public($pdsEndpoint);
$registry = app(MapperRegistry::class);
$mapper = $registry->forLexicon('app.bsky.feed.post');
$response = $client->atproto->repo->listRecords(
repo: $did,
collection: 'app.bsky.feed.post',
limit: 100
);
foreach ($response->records as $record) {
$recordClass = $mapper->recordClass();
$dto = $recordClass::fromArray($record->value);
$mapper->upsert($dto, [
'uri' => $record->uri,
'cid' => $record->cid,
]);
}
Using with Authenticated Client#
While RecordHelper uses public clients, you can also use authenticated clients for records that require auth:
use SocialDept\AtpClient\Facades\Atp;
use SocialDept\AtpParity\MapperRegistry;
// Authenticated client
$client = Atp::as('user.bsky.social');
// Fetch a record that requires auth
$response = $client->atproto->repo->getRecord(
repo: $client->session()->did(),
collection: 'app.bsky.feed.post',
rkey: 'abc123'
);
// Convert using mapper
$registry = app(MapperRegistry::class);
$mapper = $registry->forLexicon('app.bsky.feed.post');
$recordClass = $mapper->recordClass();
$record = $recordClass::fromArray($response->value);
$model = $mapper->upsert($record, [
'uri' => $response->uri,
'cid' => $response->cid,
]);