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.
- 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-schemagenerated 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
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');composer require socialdept/atp-ormORM will auto-register with Laravel. Optionally publish the config:
php artisan vendor:publish --tag=atp-orm-configCreate a model class that extends RemoteRecord:
php artisan make:remote-record Post --collection=app.bsky.feed.postThis 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) |
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();// 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');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();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();ORM caches query results automatically with configurable TTLs.
TTLs are resolved in order of specificity:
- Query-level -
->remember($ttl)on the builder - Model-level -
$cacheTtlproperty on the RemoteRecord - Collection-level - Per-collection overrides in config
- Global -
cache.default_ttlin 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();// Invalidate all cached data for a scope
Post::for($did)->invalidate();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
],
],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 require an authenticated context via as():
$post = Post::as($authenticatedDid)->create([
'text' => 'Hello from ORM!',
'createdAt' => now()->toIso8601String(),
]);
echo $post->getUri(); // "at://did:plc:.../app.bsky.feed.post/..."$post = Post::as($did)->for($did)->find($rkey);
$post->text = 'Updated text';
$post->save();
// Or in one call
$post->update(['text' => 'Updated text']);$post = Post::as($did)->for($did)->find($rkey);
$post->delete();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 valueWhen 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.
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,
],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"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 CollectionCustomize 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',
],
];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."
}Run the test suite:
vendor/bin/phpunitUse the ArrayCacheProvider in tests for fast, isolated caching:
// config/atp-orm.php (in testing environment)
'cache_provider' => \SocialDept\AtpOrm\Providers\ArrayCacheProvider::class,- PHP 8.2+
- Laravel 11+
- socialdept/atp-client
- socialdept/atp-schema
- socialdept/atp-resolver
- socialdept/atp-signals - Automatic cache invalidation and CAR-based bulk loading
Found a bug or have a feature request? Open an issue.
Want to contribute? We'd love your help! Check out the contribution guidelines.
- Miguel Batres - founder & lead maintainer
- All contributors
ORM is open-source software licensed under the MIT license.
Built for the Atmosphere • By Social Dept.
