PHPackages                             socialdept/atp-orm - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. socialdept/atp-orm

ActiveLibrary[Database &amp; ORM](/categories/database)

socialdept/atp-orm
==================

Eloquent-like ORM for AT Protocol remote records in Laravel

v0.3.4(1mo ago)0345MITPHPPHP ^8.3

Since Feb 6Pushed 1mo agoCompare

[ Source](https://github.com/socialdept/atp-orm)[ Packagist](https://packagist.org/packages/socialdept/atp-orm)[ Docs](https://github.com/socialdept/atp-orm)[ RSS](/packages/socialdept-atp-orm/feed)WikiDiscussions main Synced 2w ago

READMEChangelogDependencies (20)Versions (16)Used By (0)

[![ORM Header](./header.png)](https://github.com/socialdept/atp-orm)

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

[](#----eloquent-like-orm-for-at-protocol-remote-records-in-laravel)

 [![](https://camo.githubusercontent.com/9bdc504b975797089dd801268073f1fff37f83e31a35fd26daa9fd866e422d47/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f736f6369616c646570742f6174702d6f726d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/socialdept/atp-orm "Latest Version on Packagist") [![](https://camo.githubusercontent.com/f95ae35b099e88ebc9eabdf78c8a6efe1ff0dc086da7387837fe0db1953cfebb/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f736f6369616c646570742f6174702d6f726d2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/socialdept/atp-orm "Total Downloads") [![](https://camo.githubusercontent.com/5b5c35094247262dc1f10dbb332f4ab5d765fde0d37c3d051a0f78af0ee14807/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f736f6369616c646570742f6174702d6f726d2f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/socialdept/atp-orm/actions/workflows/tests.yml "GitHub Tests Action Status") [![](https://camo.githubusercontent.com/1716c08eb7dfa2283bd2d79a237409d447d656374980d54ea5a9cd460052b1ef/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f736f6369616c646570742f6174702d6f726d3f7374796c653d666c61742d737175617265)](LICENSE "Software License")

---

What is ORM?
------------

[](#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?
------------

[](#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`](https://github.com/socialdept/atp-schema) generated DTOs with full property access
- **Read &amp; write** - Fetch, create, update, and delete records with authentication
- **Dirty tracking** - Track attribute changes just like Eloquent
- **Backlink discovery** - Find all records that link to a given record via [Microcosm](https://microcosm.blue)
- **Slingshot support** - Optionally fetch records from Slingshot cache instead of PDS
- **Events** - Laravel events for record lifecycle hooks
- **Zero config** - Works out of the box with sensible defaults

Quick Example
-------------

[](#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
------------

[](#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
-----------------------

[](#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;
}
```

PropertyDescription`$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
----------------

[](#querying-records)

### Listing 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

[](#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

[](#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

[](#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
-------

[](#caching)

ORM caches query results automatically with configurable TTLs.

### Cache TTL Resolution

[](#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

[](#manual-invalidation)

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

### Automatic Invalidation

[](#automatic-invalidation)

When paired with [`atp-signals`](https://github.com/socialdept/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

[](#cache-providers)

ORM ships with three cache providers:

ProviderUse Case`LaravelCacheProvider`Production (default) - uses Laravel's cache system`FileCacheProvider`Standalone file-based caching`ArrayCacheProvider`Testing - in-memory, non-persistentWrite Operations
----------------

[](#write-operations)

Write operations require an authenticated context via `as()`:

### Creating Records

[](#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

[](#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

[](#deleting-records)

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

### Dirty Tracking

[](#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
----------------------------

[](#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.

Backlink Queries
----------------

[](#backlink-queries)

ORM integrates with [Microcosm's Constellation](https://microcosm.blue) to discover all records that link to a given record across the entire AT Protocol network.

### Basic Usage

[](#basic-usage)

```
$post = Post::for('did:plc:abc')->find('rk1');

// Get all likes on this post
$likes = $post->backlinks()->likes();

echo $likes->total();  // 2852
echo $likes->count();  // Items in this page

foreach ($likes as $ref) {
    echo $ref->did;    // Who liked it
    echo $ref->uri();  // at://did/app.bsky.feed.like/rkey
}
```

### Convenience Methods

[](#convenience-methods)

Common Bluesky interaction types have built-in shortcuts:

```
$post->backlinks()->likes();      // app.bsky.feed.like -> subject.uri
$post->backlinks()->quotes();     // app.bsky.feed.post -> embed.record.uri
$post->backlinks()->replies();    // app.bsky.feed.post -> reply.parent.uri
$post->backlinks()->reposts();    // app.bsky.feed.repost -> subject.uri
$post->backlinks()->mentions();   // app.bsky.feed.post -> facets[...].features[...mention].did
$post->backlinks()->followers();  // app.bsky.graph.follow -> subject
```

### Custom Sources

[](#custom-sources)

Query any collection and field path using `source()`:

```
// Find all records in a custom collection that link to this post
$backlinks = $post->backlinks()
    ->source('com.example.bookmark', 'post.uri')
    ->limit(50)
    ->reverse()
    ->get();
```

The source format is `collection:path` where the path is the dot-notation location of the linking field within the record.

### Standalone Queries

[](#standalone-queries)

You don't need a `RemoteRecord` instance to query backlinks:

```
use SocialDept\AtpOrm\Backlinks\BacklinkQuery;

// Find followers of a DID
$followers = BacklinkQuery::for('did:plc:abc')
    ->source('app.bsky.graph.follow', 'subject')
    ->get();

// Get a count
$likeCount = BacklinkQuery::for('at://did:plc:abc/app.bsky.feed.post/rk1')
    ->source('app.bsky.feed.like', 'subject.uri')
    ->count();
```

### Link Summary

[](#link-summary)

Get a summary of all link types pointing at a target at once:

```
$summary = $post->backlinks()->all();

// Returns LinkSummary with nested structure:
// app.bsky.feed.like -> .subject.uri -> { records: 2852, distinct_dids: 2852 }
// app.bsky.feed.post -> .embed.record.uri -> { records: 1143, distinct_dids: 1123 }
// app.bsky.feed.repost -> .subject.uri -> { records: 320, distinct_dids: 320 }

$summary->total();                              // 7205
$summary->forCollection('app.bsky.feed.like');  // Filter to a single collection
```

### Pagination

[](#pagination-1)

Backlink queries support cursor-based pagination:

```
$likes = $post->backlinks()->likes();

while ($likes->hasMorePages()) {
    $likes = $likes->nextPage();
}
```

### Hydration

[](#hydration)

Hydrate backlink references into full `RemoteRecord` instances via Slingshot:

```
$hydrated = $post->backlinks()
    ->source('app.bsky.feed.like', 'subject.uri')
    ->hydrate(Like::class);

// Returns RemoteCollection of Like instances
foreach ($hydrated as $like) {
    echo $like->subject; // Full record data
}
```

### BacklinkCollection Helpers

[](#backlinkcollection-helpers)

```
$likes = $post->backlinks()->likes();

$likes->uris();     // Collection of AT-URIs
$likes->dids();     // Collection of unique DIDs
$likes->toArray();  // Array of {did, collection, rkey, uri}
$likes->filter(fn ($ref) => $ref->did === 'did:plc:abc');
```

Slingshot Record Source
-----------------------

[](#slingshot-record-source)

By default, ORM fetches records directly from the user's PDS. You can optionally route through [Slingshot](https://microcosm.blue) for faster cached reads:

```
// Per-query
$post = Post::for('did:plc:abc')->viaSlingshot()->find('rk1');
```

Or set it globally in config:

```
// config/atp-orm.php
'record_source' => env('ATP_ORM_RECORD_SOURCE', 'pds'), // 'pds' or 'slingshot'
```

Slingshot returns the same record data as the PDS, but from a globally distributed cache. Records may be slightly stale compared to direct PDS reads.

Events
------

[](#events)

ORM fires Laravel events for record lifecycle changes:

EventFired 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
-------------

[](#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
----------------

[](#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
-------------

[](#configuration)

Customize behavior in `config/atp-orm.php`:

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

    // Record source: 'pds' (default) or 'slingshot' (Microcosm cache)
    'record_source' => env('ATP_ORM_RECORD_SOURCE', 'pds'),

    '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
--------------

[](#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
-------

[](#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
------------

[](#requirements)

- PHP 8.2+
- Laravel 11+
- [socialdept/atp-support](https://github.com/socialdept/atp-support) - Identity resolution, Microcosm clients
- [socialdept/atp-client](https://github.com/socialdept/atp-client) - Authenticated AT Protocol HTTP client
- [socialdept/atp-schema](https://github.com/socialdept/atp-schema) - Lexicon parsing and DTO generation

### Optional

[](#optional)

- [socialdept/atp-signals](https://github.com/socialdept/atp-signals) - Automatic cache invalidation and CAR-based bulk loading

Resources
---------

[](#resources)

- [AT Protocol Documentation](https://atproto.com/)
- [Bluesky API Docs](https://docs.bsky.app/)
- [AT-URI Specification](https://atproto.com/specs/at-uri-scheme)
- [Lexicon Specification](https://atproto.com/specs/lexicon)

Support &amp; Contributing
--------------------------

[](#support--contributing)

Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/atp-orm/issues).

Want to contribute? We'd love your help! Check out the [contribution guidelines](CONTRIBUTING.md).

Credits
-------

[](#credits)

- [Miguel Batres](https://batres.co) - founder &amp; lead maintainer
- [All contributors](https://github.com/socialdept/atp-orm/graphs/contributors)

License
-------

[](#license)

ORM is open-source software licensed under the [MIT license](LICENSE).

---

**Built for the Atmosphere** • By Social Dept.

###  Health Score

42

—

FairBetter than 89% of packages

Maintenance89

Actively maintained with recent releases

Popularity15

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~6 days

Recently: every ~20 days

Total

14

Last Release

54d ago

PHP version history (2 changes)v0.1.0PHP ^8.2

v0.3.3PHP ^8.3

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/101910626?v=4)[Social Dept.](/maintainers/socialdept)[@socialdept](https://github.com/socialdept)

---

Top Contributors

[![btrsco](https://avatars.githubusercontent.com/u/1373528?v=4)](https://github.com/btrsco "btrsco (37 commits)")

---

Tags

laravelormObject Relational Mappingblueskyat protocolatp

###  Code Quality

TestsPHPUnit

Code StylePHP CS Fixer

### Embed Badge

![Health badge](/badges/socialdept-atp-orm/health.svg)

```
[![Health](https://phpackages.com/badges/socialdept-atp-orm/health.svg)](https://phpackages.com/packages/socialdept-atp-orm)
```

###  Alternatives

[illuminate/database

The Illuminate Database package.

2.8k54.1M11.1k](/packages/illuminate-database)[kirschbaum-development/eloquent-power-joins

The Laravel magic applied to joins.

1.6k29.9M42](/packages/kirschbaum-development-eloquent-power-joins)[laravel-doctrine/orm

An integration library for Laravel and Doctrine ORM

8385.5M96](/packages/laravel-doctrine-orm)[yajra/laravel-oci8

Oracle DB driver for Laravel via OCI8

8723.1M23](/packages/yajra-laravel-oci8)[glushkovds/phpclickhouse-laravel

Adapter of the most popular library https://github.com/smi2/phpClickHouse to Laravel

2051.4M2](/packages/glushkovds-phpclickhouse-laravel)[laravel-doctrine/extensions

Doctrine extensions for Laravel

493.6M19](/packages/laravel-doctrine-extensions)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
