Record Mappers#
Mappers are the core of atp-parity. They define bidirectional transformations between AT Protocol record DTOs and Eloquent models.
Creating a Mapper#
Extend the RecordMapper abstract class and implement the required methods:
<?php
namespace App\AtpMappers;
use App\Models\Post;
use Illuminate\Database\Eloquent\Model;
use SocialDept\AtpParity\RecordMapper;
use SocialDept\AtpSchema\Data\Data;
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostRecord;
/**
* @extends RecordMapper<PostRecord, Post>
*/
class PostMapper extends RecordMapper
{
/**
* The AT Protocol record class this mapper handles.
*/
public function recordClass(): string
{
return PostRecord::class;
}
/**
* The Eloquent model class this mapper handles.
*/
public function modelClass(): string
{
return Post::class;
}
/**
* Transform a record DTO into model attributes.
*/
protected function recordToAttributes(Data $record): array
{
/** @var PostRecord $record */
return [
'content' => $record->text,
'published_at' => $record->createdAt,
'langs' => $record->langs,
'facets' => $record->facets,
];
}
/**
* Transform a model into record data for creating/updating.
*/
protected function modelToRecordData(Model $model): array
{
/** @var Post $model */
return [
'text' => $model->content,
'createdAt' => $model->published_at->toIso8601String(),
'langs' => $model->langs ?? ['en'],
];
}
}
Required Methods#
recordClass(): string#
Returns the fully qualified class name of the AT Protocol record DTO. This can be:
- A generated class from atp-schema (e.g.,
SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post) - A custom class extending
SocialDept\AtpParity\Data\Record
modelClass(): string#
Returns the fully qualified class name of the Eloquent model.
recordToAttributes(Data $record): array#
Transforms an AT Protocol record into an array of Eloquent model attributes. This is used when:
- Creating a new model from a remote record
- Updating an existing model from a remote record
modelToRecordData(Model $model): array#
Transforms an Eloquent model into an array suitable for creating an AT Protocol record. This is used when:
- Publishing a local model to the AT Protocol network
- Comparing local and remote state
Inherited Methods#
The abstract RecordMapper class provides these methods:
lexicon(): string#
Returns the lexicon NSID (e.g., app.bsky.feed.post). Automatically derived from the record class's getLexicon() method.
toModel(Data $record, array $meta = []): Model#
Creates a new (unsaved) model instance from a record DTO.
$record = PostRecord::fromArray($data);
$model = $mapper->toModel($record, [
'uri' => 'at://did:plc:xxx/app.bsky.feed.post/abc123',
'cid' => 'bafyre...',
]);
toRecord(Model $model): Data#
Converts a model back to a record DTO.
$record = $mapper->toRecord($post);
// Use $record->toArray() to get data for API calls
updateModel(Model $model, Data $record, array $meta = []): Model#
Updates an existing model with data from a record. Does not save the model.
$mapper->updateModel($existingPost, $record, ['cid' => $newCid]);
$existingPost->save();
findByUri(string $uri): ?Model#
Finds a model by its AT Protocol URI.
$post = $mapper->findByUri('at://did:plc:xxx/app.bsky.feed.post/abc123');
upsert(Data $record, array $meta = []): Model#
Creates or updates a model based on the URI. This is the primary method used for syncing.
$post = $mapper->upsert($record, [
'uri' => $uri,
'cid' => $cid,
]);
deleteByUri(string $uri): bool#
Deletes a model by its AT Protocol URI.
$deleted = $mapper->deleteByUri('at://did:plc:xxx/app.bsky.feed.post/abc123');
Meta Fields#
The $meta array passed to toModel, updateModel, and upsert can contain:
| Key | Description |
|---|---|
uri |
The AT Protocol URI (e.g., at://did:plc:xxx/app.bsky.feed.post/abc123) |
cid |
The content identifier hash |
These are automatically mapped to your configured column names (default: atp_uri, atp_cid).
Customizing Column Names#
Override the column methods to use different database columns:
class PostMapper extends RecordMapper
{
protected function uriColumn(): string
{
return 'at_uri'; // Instead of default 'atp_uri'
}
protected function cidColumn(): string
{
return 'at_cid'; // Instead of default 'atp_cid'
}
// ... other methods
}
Or configure globally in config/parity.php:
'columns' => [
'uri' => 'at_uri',
'cid' => 'at_cid',
],
Registering Mappers#
Via Configuration#
Add your mapper classes to config/parity.php:
return [
'mappers' => [
App\AtpMappers\PostMapper::class,
App\AtpMappers\ProfileMapper::class,
App\AtpMappers\LikeMapper::class,
],
];
Programmatically#
Register mappers at runtime via the MapperRegistry:
use SocialDept\AtpParity\MapperRegistry;
$registry = app(MapperRegistry::class);
$registry->register(new PostMapper());
Using the Registry#
The MapperRegistry provides lookup methods:
use SocialDept\AtpParity\MapperRegistry;
$registry = app(MapperRegistry::class);
// Find mapper by record class
$mapper = $registry->forRecord(PostRecord::class);
// Find mapper by model class
$mapper = $registry->forModel(Post::class);
// Find mapper by lexicon NSID
$mapper = $registry->forLexicon('app.bsky.feed.post');
// Get all registered lexicons
$lexicons = $registry->lexicons();
// ['app.bsky.feed.post', 'app.bsky.actor.profile', ...]
SchemaMapper for Quick Setup#
For simple mappings, use SchemaMapper instead of creating a full class:
use SocialDept\AtpParity\Support\SchemaMapper;
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like;
$mapper = new SchemaMapper(
schemaClass: Like::class,
modelClass: \App\Models\Like::class,
toAttributes: fn(Like $like) => [
'subject_uri' => $like->subject->uri,
'subject_cid' => $like->subject->cid,
'liked_at' => $like->createdAt,
],
toRecordData: fn($model) => [
'subject' => [
'uri' => $model->subject_uri,
'cid' => $model->subject_cid,
],
'createdAt' => $model->liked_at->toIso8601String(),
],
);
$registry->register($mapper);
Handling Complex Records#
Embedded Objects#
AT Protocol records often contain embedded objects. Handle them in your mapping:
protected function recordToAttributes(Data $record): array
{
/** @var PostRecord $record */
$attributes = [
'content' => $record->text,
'published_at' => $record->createdAt,
];
// Handle reply reference
if ($record->reply) {
$attributes['reply_to_uri'] = $record->reply->parent->uri;
$attributes['thread_root_uri'] = $record->reply->root->uri;
}
// Handle embed
if ($record->embed) {
$attributes['embed_type'] = $record->embed->getType();
$attributes['embed_data'] = $record->embed->toArray();
}
return $attributes;
}
Facets (Rich Text)#
Posts with mentions, links, and hashtags have facets:
protected function recordToAttributes(Data $record): array
{
/** @var PostRecord $record */
return [
'content' => $record->text,
'facets' => $record->facets, // Store as JSON
'published_at' => $record->createdAt,
];
}
protected function modelToRecordData(Model $model): array
{
/** @var Post $model */
return [
'text' => $model->content,
'facets' => $model->facets, // Restore from JSON
'createdAt' => $model->published_at->toIso8601String(),
];
}
Multiple Mappers per Lexicon#
You can register multiple mappers for different model types:
// Map posts to different models based on criteria
class UserPostMapper extends RecordMapper
{
public function recordClass(): string
{
return PostRecord::class;
}
public function modelClass(): string
{
return UserPost::class;
}
// ... mapping logic for user's own posts
}
class FeedPostMapper extends RecordMapper
{
public function recordClass(): string
{
return PostRecord::class;
}
public function modelClass(): string
{
return FeedPost::class;
}
// ... mapping logic for feed posts
}
Note: The registry will return the first registered mapper for a given lexicon. Use explicit mapper instances when you need specific behavior.