Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)
1# Record Mappers
2
3Mappers are the core of atp-parity. They define bidirectional transformations between AT Protocol record DTOs and Eloquent models.
4
5## Creating a Mapper
6
7Extend the `RecordMapper` abstract class and implement the required methods:
8
9```php
10<?php
11
12namespace App\AtpMappers;
13
14use App\Models\Post;
15use Illuminate\Database\Eloquent\Model;
16use SocialDept\AtpParity\RecordMapper;
17use SocialDept\AtpSchema\Data\Data;
18use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostRecord;
19
20/**
21 * @extends RecordMapper<PostRecord, Post>
22 */
23class PostMapper extends RecordMapper
24{
25 /**
26 * The AT Protocol record class this mapper handles.
27 */
28 public function recordClass(): string
29 {
30 return PostRecord::class;
31 }
32
33 /**
34 * The Eloquent model class this mapper handles.
35 */
36 public function modelClass(): string
37 {
38 return Post::class;
39 }
40
41 /**
42 * Transform a record DTO into model attributes.
43 */
44 protected function recordToAttributes(Data $record): array
45 {
46 /** @var PostRecord $record */
47 return [
48 'content' => $record->text,
49 'published_at' => $record->createdAt,
50 'langs' => $record->langs,
51 'facets' => $record->facets,
52 ];
53 }
54
55 /**
56 * Transform a model into record data for creating/updating.
57 */
58 protected function modelToRecordData(Model $model): array
59 {
60 /** @var Post $model */
61 return [
62 'text' => $model->content,
63 'createdAt' => $model->published_at->toIso8601String(),
64 'langs' => $model->langs ?? ['en'],
65 ];
66 }
67}
68```
69
70## Required Methods
71
72### `recordClass(): string`
73
74Returns the fully qualified class name of the AT Protocol record DTO. This can be:
75
76- A generated class from atp-schema (e.g., `SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post`)
77- A custom class extending `SocialDept\AtpParity\Data\Record`
78
79### `modelClass(): string`
80
81Returns the fully qualified class name of the Eloquent model.
82
83### `recordToAttributes(Data $record): array`
84
85Transforms an AT Protocol record into an array of Eloquent model attributes. This is used when:
86
87- Creating a new model from a remote record
88- Updating an existing model from a remote record
89
90### `modelToRecordData(Model $model): array`
91
92Transforms an Eloquent model into an array suitable for creating an AT Protocol record. This is used when:
93
94- Publishing a local model to the AT Protocol network
95- Comparing local and remote state
96
97## Inherited Methods
98
99The abstract `RecordMapper` class provides these methods:
100
101### `lexicon(): string`
102
103Returns the lexicon NSID (e.g., `app.bsky.feed.post`). Automatically derived from the record class's `getLexicon()` method.
104
105### `toModel(Data $record, array $meta = []): Model`
106
107Creates a new (unsaved) model instance from a record DTO.
108
109```php
110$record = PostRecord::fromArray($data);
111$model = $mapper->toModel($record, [
112 'uri' => 'at://did:plc:xxx/app.bsky.feed.post/abc123',
113 'cid' => 'bafyre...',
114]);
115```
116
117### `toRecord(Model $model): Data`
118
119Converts a model back to a record DTO.
120
121```php
122$record = $mapper->toRecord($post);
123// Use $record->toArray() to get data for API calls
124```
125
126### `updateModel(Model $model, Data $record, array $meta = []): Model`
127
128Updates an existing model with data from a record. Does not save the model.
129
130```php
131$mapper->updateModel($existingPost, $record, ['cid' => $newCid]);
132$existingPost->save();
133```
134
135### `findByUri(string $uri): ?Model`
136
137Finds a model by its AT Protocol URI.
138
139```php
140$post = $mapper->findByUri('at://did:plc:xxx/app.bsky.feed.post/abc123');
141```
142
143### `upsert(Data $record, array $meta = []): Model`
144
145Creates or updates a model based on the URI. This is the primary method used for syncing.
146
147```php
148$post = $mapper->upsert($record, [
149 'uri' => $uri,
150 'cid' => $cid,
151]);
152```
153
154### `deleteByUri(string $uri): bool`
155
156Deletes a model by its AT Protocol URI.
157
158```php
159$deleted = $mapper->deleteByUri('at://did:plc:xxx/app.bsky.feed.post/abc123');
160```
161
162## Meta Fields
163
164The `$meta` array passed to `toModel`, `updateModel`, and `upsert` can contain:
165
166| Key | Description |
167|-----|-------------|
168| `uri` | The AT Protocol URI (e.g., `at://did:plc:xxx/app.bsky.feed.post/abc123`) |
169| `cid` | The content identifier hash |
170
171These are automatically mapped to your configured column names (default: `atp_uri`, `atp_cid`).
172
173## Customizing Column Names
174
175Override the column methods to use different database columns:
176
177```php
178class PostMapper extends RecordMapper
179{
180 protected function uriColumn(): string
181 {
182 return 'at_uri'; // Instead of default 'atp_uri'
183 }
184
185 protected function cidColumn(): string
186 {
187 return 'at_cid'; // Instead of default 'atp_cid'
188 }
189
190 // ... other methods
191}
192```
193
194Or configure globally in `config/parity.php`:
195
196```php
197'columns' => [
198 'uri' => 'at_uri',
199 'cid' => 'at_cid',
200],
201```
202
203## Registering Mappers
204
205### Via Configuration
206
207Add your mapper classes to `config/parity.php`:
208
209```php
210return [
211 'mappers' => [
212 App\AtpMappers\PostMapper::class,
213 App\AtpMappers\ProfileMapper::class,
214 App\AtpMappers\LikeMapper::class,
215 ],
216];
217```
218
219### Programmatically
220
221Register mappers at runtime via the `MapperRegistry`:
222
223```php
224use SocialDept\AtpParity\MapperRegistry;
225
226$registry = app(MapperRegistry::class);
227$registry->register(new PostMapper());
228```
229
230## Using the Registry
231
232The `MapperRegistry` provides lookup methods:
233
234```php
235use SocialDept\AtpParity\MapperRegistry;
236
237$registry = app(MapperRegistry::class);
238
239// Find mapper by record class
240$mapper = $registry->forRecord(PostRecord::class);
241
242// Find mapper by model class
243$mapper = $registry->forModel(Post::class);
244
245// Find mapper by lexicon NSID
246$mapper = $registry->forLexicon('app.bsky.feed.post');
247
248// Get all registered lexicons
249$lexicons = $registry->lexicons();
250// ['app.bsky.feed.post', 'app.bsky.actor.profile', ...]
251```
252
253## SchemaMapper for Quick Setup
254
255For simple mappings, use `SchemaMapper` instead of creating a full class:
256
257```php
258use SocialDept\AtpParity\Support\SchemaMapper;
259use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like;
260
261$mapper = new SchemaMapper(
262 schemaClass: Like::class,
263 modelClass: \App\Models\Like::class,
264 toAttributes: fn(Like $like) => [
265 'subject_uri' => $like->subject->uri,
266 'subject_cid' => $like->subject->cid,
267 'liked_at' => $like->createdAt,
268 ],
269 toRecordData: fn($model) => [
270 'subject' => [
271 'uri' => $model->subject_uri,
272 'cid' => $model->subject_cid,
273 ],
274 'createdAt' => $model->liked_at->toIso8601String(),
275 ],
276);
277
278$registry->register($mapper);
279```
280
281## Handling Complex Records
282
283### Embedded Objects
284
285AT Protocol records often contain embedded objects. Handle them in your mapping:
286
287```php
288protected function recordToAttributes(Data $record): array
289{
290 /** @var PostRecord $record */
291 $attributes = [
292 'content' => $record->text,
293 'published_at' => $record->createdAt,
294 ];
295
296 // Handle reply reference
297 if ($record->reply) {
298 $attributes['reply_to_uri'] = $record->reply->parent->uri;
299 $attributes['thread_root_uri'] = $record->reply->root->uri;
300 }
301
302 // Handle embed
303 if ($record->embed) {
304 $attributes['embed_type'] = $record->embed->getType();
305 $attributes['embed_data'] = $record->embed->toArray();
306 }
307
308 return $attributes;
309}
310```
311
312### Facets (Rich Text)
313
314Posts with mentions, links, and hashtags have facets:
315
316```php
317protected function recordToAttributes(Data $record): array
318{
319 /** @var PostRecord $record */
320 return [
321 'content' => $record->text,
322 'facets' => $record->facets, // Store as JSON
323 'published_at' => $record->createdAt,
324 ];
325}
326
327protected function modelToRecordData(Model $model): array
328{
329 /** @var Post $model */
330 return [
331 'text' => $model->content,
332 'facets' => $model->facets, // Restore from JSON
333 'createdAt' => $model->published_at->toIso8601String(),
334 ];
335}
336```
337
338## Multiple Mappers per Lexicon
339
340You can register multiple mappers for different model types:
341
342```php
343// Map posts to different models based on criteria
344class UserPostMapper extends RecordMapper
345{
346 public function recordClass(): string
347 {
348 return PostRecord::class;
349 }
350
351 public function modelClass(): string
352 {
353 return UserPost::class;
354 }
355
356 // ... mapping logic for user's own posts
357}
358
359class FeedPostMapper extends RecordMapper
360{
361 public function recordClass(): string
362 {
363 return PostRecord::class;
364 }
365
366 public function modelClass(): string
367 {
368 return FeedPost::class;
369 }
370
371 // ... mapping logic for feed posts
372}
373```
374
375Note: The registry will return the first registered mapper for a given lexicon. Use explicit mapper instances when you need specific behavior.