Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)

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:

  1. Parses the URI to extract the DID, collection, and rkey
  2. Resolves the DID to find the user's PDS endpoint (via atp-resolver)
  3. Creates a public client for that PDS
  4. Fetches the record
  5. 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,
]);