socialdept / atp-orm
Eloquent-like ORM for AT Protocol remote records in Laravel
Installs: 6
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/socialdept/atp-orm
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0
- socialdept/atp-cbor: ^0.1
- socialdept/atp-client: ^0.1
- socialdept/atp-schema: ^0.4
- socialdept/atp-support: ^0.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.89
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
- socialdept/atp-signals: ^2.0
Suggests
- socialdept/atp-signals: Enables automatic cache invalidation via firehose/jetstream and CAR-based bulk loading via getRepo (^2.0)
README
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-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
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:
- 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();
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
- PHP 8.2+
- Laravel 11+
- socialdept/atp-client
- socialdept/atp-schema
- socialdept/atp-resolver
Optional
- socialdept/atp-signals - Automatic cache invalidation and CAR-based bulk loading
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
- Miguel Batres - founder & lead maintainer
- All contributors
License
ORM is open-source software licensed under the MIT license.
Built for the Atmosphere • By Social Dept.
