Skip to content

Eloquent-like ORM for AT Protocol remote records in Laravel

License

Notifications You must be signed in to change notification settings

socialdept/atp-orm

Repository files navigation

ORM Header

Eloquent-like ORM for AT Protocol remote records in Laravel.



What is ORM?

ORM is a Laravel package that brings an Eloquent-like interface to AT Protocol remote records. Query Bluesky posts, likes, follows, and any other AT Protocol collection as if they were local database models — with built-in caching, pagination, dirty tracking, and write support.

Think of it as Eloquent, but for the AT Protocol.

Why use ORM?

  • Familiar API - Query remote records with the same patterns you use for Eloquent models
  • Built-in caching - Configurable TTLs with automatic cache invalidation via firehose
  • Pagination - Cursor-based pagination that works out of the box
  • Type-safe - Backed by atp-schema generated DTOs with full property access
  • Read & write - Fetch, create, update, and delete records with authentication
  • Dirty tracking - Track attribute changes just like Eloquent
  • Events - Laravel events for record lifecycle hooks
  • Zero config - Works out of the box with sensible defaults

Quick Example

use App\Remote\Post;

// List a user's posts
$posts = Post::for('alice.bsky.social')->limit(10)->get();

foreach ($posts as $post) {
    echo $post->text;
    echo $post->createdAt;
}

// Paginate through all posts
while ($posts->hasMorePages()) {
    $posts = $posts->nextPage();
}

// Find a specific post
$post = Post::for('did:plc:ewvi7nxzyoun6zhxrhs64oiz')->find('3mdtrzs7kts2p');
echo $post->text;

// Find by AT-URI
$post = Post::for('alice.bsky.social')
    ->findByUri('at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3mdtrzs7kts2p');

Installation

composer require socialdept/atp-orm

ORM will auto-register with Laravel. Optionally publish the config:

php artisan vendor:publish --tag=atp-orm-config

Defining Remote Records

Create a model class that extends RemoteRecord:

php artisan make:remote-record Post --collection=app.bsky.feed.post

This generates:

namespace App\Remote;

use SocialDept\AtpOrm\RemoteRecord;
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostData;

class Post extends RemoteRecord
{
    protected string $collection = 'app.bsky.feed.post';
    protected string $recordClass = PostData::class;
    protected int $cacheTtl = 300;
}
Property Description
$collection The AT Protocol collection NSID
$recordClass The atp-schema DTO class for type-safe hydration
$cacheTtl Cache duration in seconds (0 = use config default)

Querying Records

Listing Records

use App\Remote\Post;

// Basic listing
$posts = Post::for('alice.bsky.social')->get();

// With options
$posts = Post::for('did:plc:ewvi7nxzyoun6zhxrhs64oiz')
    ->limit(25)
    ->reverse()
    ->get();

Finding a Single Record

// By record key
$post = Post::for('alice.bsky.social')->find('3mdtrzs7kts2p');

// Throws RecordNotFoundException if not found
$post = Post::for('alice.bsky.social')->findOrFail('3mdtrzs7kts2p');

// By full AT-URI
$post = Post::for('alice.bsky.social')
    ->findByUri('at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3mdtrzs7kts2p');

Pagination

ORM uses cursor-based pagination, matching the AT Protocol's native pattern:

$posts = Post::for($did)->limit(50)->get();

echo $posts->cursor(); // Pagination cursor

while ($posts->hasMorePages()) {
    $posts = $posts->nextPage();

    foreach ($posts as $post) {
        // Process each page...
    }
}

You can also paginate manually with after():

$firstPage = Post::for($did)->limit(50)->get();
$secondPage = Post::for($did)->limit(50)->after($firstPage->cursor())->get();

Accessing Attributes

Records support property access, array access, and method access:

$post = Post::for($did)->find($rkey);

// Property access
$post->text;
$post->createdAt;

// Array access
$post['text'];

// Method access
$post->getAttribute('text');

// Record metadata
$post->getUri();    // "at://did:plc:.../app.bsky.feed.post/..."
$post->getRkey();   // "3mdtrzs7kts2p"
$post->getCid();    // "bafyreic3..."
$post->getDid();    // "did:plc:..."

// Convert to atp-schema DTO
$dto = $post->toDto();

// Convert to array
$data = $post->toArray();

Caching

ORM caches query results automatically with configurable TTLs.

Cache TTL Resolution

TTLs are resolved in order of specificity:

  1. Query-level - ->remember($ttl) on the builder
  2. Model-level - $cacheTtl property on the RemoteRecord
  3. Collection-level - Per-collection overrides in config
  4. Global - cache.default_ttl in config
// Use model's default TTL
$posts = Post::for($did)->get();

// Custom TTL for this query (seconds)
$posts = Post::for($did)->remember(600)->get();

// Bypass cache entirely
$posts = Post::for($did)->fresh()->get();

// Reload a single record from remote
$post = $post->fresh();

Manual Invalidation

// Invalidate all cached data for a scope
Post::for($did)->invalidate();

Automatic Invalidation

When paired with atp-signals, ORM can automatically invalidate cache entries when records change on the network:

// config/atp-orm.php
'cache' => [
    'invalidation' => [
        'enabled' => true,
        'collections' => null, // null = all collections
        'dids' => null,        // null = all DIDs
    ],
],

Cache Providers

ORM ships with three cache providers:

Provider Use Case
LaravelCacheProvider Production (default) - uses Laravel's cache system
FileCacheProvider Standalone file-based caching
ArrayCacheProvider Testing - in-memory, non-persistent

Write Operations

Write operations require an authenticated context via as():

Creating Records

$post = Post::as($authenticatedDid)->create([
    'text' => 'Hello from ORM!',
    'createdAt' => now()->toIso8601String(),
]);

echo $post->getUri(); // "at://did:plc:.../app.bsky.feed.post/..."

Updating Records

$post = Post::as($did)->for($did)->find($rkey);

$post->text = 'Updated text';
$post->save();

// Or in one call
$post->update(['text' => 'Updated text']);

Deleting Records

$post = Post::as($did)->for($did)->find($rkey);
$post->delete();

Dirty Tracking

ORM tracks attribute changes like Eloquent:

$post = Post::for($did)->find($rkey);

$post->isDirty();        // false
$post->text = 'New text';
$post->isDirty();        // true
$post->isDirty('text');  // true
$post->getDirty();       // ['text' => 'New text']
$post->getOriginal('text'); // Original value

Bulk Loading with CAR Export

When you need to load an entire collection efficiently, use fromRepo() to fetch via CAR export instead of paginating through listRecords:

// Requires socialdept/atp-signals
$allPosts = Post::for($did)->fromRepo()->get();

This uses com.atproto.sync.getRepo to fetch the repository as a CAR file and extract records locally — significantly faster for large collections.

Events

ORM fires Laravel events for record lifecycle changes:

Event Fired When
RecordCreated A new record is created
RecordUpdated An existing record is updated
RecordDeleted A record is deleted
RecordFetched A record is fetched from remote
use SocialDept\AtpOrm\Events\RecordCreated;

Event::listen(RecordCreated::class, function (RecordCreated $event) {
    logger()->info('Record created', [
        'uri' => $event->record->getUri(),
    ]);
});

Events can be disabled in config:

'events' => [
    'enabled' => false,
],

AT-URI Helper

ORM includes an AtUri helper for parsing and building AT Protocol URIs:

use SocialDept\AtpOrm\Support\AtUri;

$uri = AtUri::parse('at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3mdtrzs7kts2p');

$uri->did;        // "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
$uri->collection; // "app.bsky.feed.post"
$uri->rkey;       // "3mdtrzs7kts2p"

// Build a URI
$uri = AtUri::make($did, 'app.bsky.feed.post', $rkey);
echo (string) $uri; // "at://did/app.bsky.feed.post/rkey"

RemoteCollection

Query results are returned as RemoteCollection instances with a familiar collection API:

$posts = Post::for($did)->get();

$posts->count();
$posts->isEmpty();
$posts->isNotEmpty();
$posts->first();
$posts->last();
$posts->pluck('text');
$posts->filter(fn ($post) => str_contains($post->text, 'hello'));
$posts->map(fn ($post) => $post->text);
$posts->each(fn ($post) => logger()->info($post->text));
$posts->toArray();
$posts->toCollection(); // Convert to Laravel Collection

Configuration

Customize behavior in config/atp-orm.php:

return [
    // Cache provider class (LaravelCacheProvider, FileCacheProvider, or ArrayCacheProvider)
    'cache_provider' => \SocialDept\AtpOrm\Providers\LaravelCacheProvider::class,

    'cache' => [
        'default_ttl' => 300,      // 5 minutes (0 = no caching)
        'prefix' => 'atp-orm',
        'store' => null,           // Laravel cache store (null = default)
        'file_path' => storage_path('app/atp-orm-cache'), // FileCacheProvider storage path

        // Per-collection TTL overrides
        'ttls' => [
            'app.bsky.feed.post' => 600,
            'app.bsky.graph.follow' => 3600,
        ],

        // Automatic invalidation via firehose (requires atp-signals)
        'invalidation' => [
            'enabled' => false,
            'collections' => null,  // null = auto from registered models
            'dids' => null,         // null = all
        ],
    ],

    'query' => [
        'default_limit' => 50,
        'max_limit' => 100,
    ],

    'events' => [
        'enabled' => true,
    ],

    'pds' => [
        'public_service' => 'https://public.api.bsky.app',
    ],

    'generators' => [
        'path' => 'app/Remote',
    ],
];

Error Handling

ORM throws descriptive exceptions:

use SocialDept\AtpOrm\Exceptions\ReadOnlyException;
use SocialDept\AtpOrm\Exceptions\RecordNotFoundException;

try {
    $post = Post::for($did)->findOrFail('nonexistent');
} catch (RecordNotFoundException $e) {
    // "Record not found: at://did/app.bsky.feed.post/nonexistent"
}

try {
    // Attempting write without ::as()
    Post::for($did)->create(['text' => 'Hello']);
} catch (ReadOnlyException $e) {
    // "Cannot write without an authenticated DID. Use ::as($did) for write operations."
}

Testing

Run the test suite:

vendor/bin/phpunit

Use the ArrayCacheProvider in tests for fast, isolated caching:

// config/atp-orm.php (in testing environment)
'cache_provider' => \SocialDept\AtpOrm\Providers\ArrayCacheProvider::class,

Requirements

Optional

Resources

Support & Contributing

Found a bug or have a feature request? Open an issue.

Want to contribute? We'd love your help! Check out the contribution guidelines.

Credits

License

ORM is open-source software licensed under the MIT license.


Built for the Atmosphere • By Social Dept.

About

Eloquent-like ORM for AT Protocol remote records in Laravel

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published

Languages