Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)
1# atp-schema Integration
2
3Parity is built on top of atp-schema, using its `Data` base class for all record DTOs. This provides type safety, validation, and compatibility with the AT Protocol ecosystem.
4
5## How It Works
6
7The `SocialDept\AtpParity\Data\Record` class extends `SocialDept\AtpSchema\Data\Data`:
8
9```php
10namespace SocialDept\AtpParity\Data;
11
12use SocialDept\AtpClient\Contracts\Recordable;
13use SocialDept\AtpSchema\Data\Data;
14
15abstract class Record extends Data implements Recordable
16{
17 public function getType(): string
18 {
19 return static::getLexicon();
20 }
21}
22```
23
24This means all Parity records inherit:
25
26- `getLexicon()` - Returns the lexicon NSID
27- `fromArray()` - Creates instance from array data
28- `toArray()` - Converts to array
29- `toRecord()` - Converts to record format for API calls
30- Type validation and casting
31
32## Using Generated Schema Classes
33
34atp-schema generates PHP classes for all AT Protocol lexicons. Use them directly with Parity:
35
36```php
37use SocialDept\AtpParity\Support\SchemaMapper;
38use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
39use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like;
40use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Follow;
41
42// Post mapper
43$postMapper = new SchemaMapper(
44 schemaClass: Post::class,
45 modelClass: \App\Models\Post::class,
46 toAttributes: fn(Post $post) => [
47 'content' => $post->text,
48 'published_at' => $post->createdAt,
49 'langs' => $post->langs,
50 'reply_parent' => $post->reply?->parent->uri,
51 'reply_root' => $post->reply?->root->uri,
52 ],
53 toRecordData: fn($model) => [
54 'text' => $model->content,
55 'createdAt' => $model->published_at->toIso8601String(),
56 'langs' => $model->langs ?? ['en'],
57 ],
58);
59
60// Like mapper
61$likeMapper = new SchemaMapper(
62 schemaClass: Like::class,
63 modelClass: \App\Models\Like::class,
64 toAttributes: fn(Like $like) => [
65 'subject_uri' => $like->subject->uri,
66 'subject_cid' => $like->subject->cid,
67 'liked_at' => $like->createdAt,
68 ],
69 toRecordData: fn($model) => [
70 'subject' => [
71 'uri' => $model->subject_uri,
72 'cid' => $model->subject_cid,
73 ],
74 'createdAt' => $model->liked_at->toIso8601String(),
75 ],
76);
77
78// Follow mapper
79$followMapper = new SchemaMapper(
80 schemaClass: Follow::class,
81 modelClass: \App\Models\Follow::class,
82 toAttributes: fn(Follow $follow) => [
83 'subject_did' => $follow->subject,
84 'followed_at' => $follow->createdAt,
85 ],
86 toRecordData: fn($model) => [
87 'subject' => $model->subject_did,
88 'createdAt' => $model->followed_at->toIso8601String(),
89 ],
90);
91```
92
93## Creating Custom Records
94
95For custom lexicons or when you need more control, extend the `Record` class:
96
97```php
98<?php
99
100namespace App\AtpRecords;
101
102use Carbon\Carbon;
103use SocialDept\AtpParity\Data\Record;
104
105class CustomPost extends Record
106{
107 public function __construct(
108 public readonly string $text,
109 public readonly Carbon $createdAt,
110 public readonly ?array $facets = null,
111 public readonly ?array $embed = null,
112 public readonly ?array $langs = null,
113 ) {}
114
115 public static function getLexicon(): string
116 {
117 return 'app.bsky.feed.post';
118 }
119
120 public static function fromArray(array $data): static
121 {
122 return new static(
123 text: $data['text'],
124 createdAt: Carbon::parse($data['createdAt']),
125 facets: $data['facets'] ?? null,
126 embed: $data['embed'] ?? null,
127 langs: $data['langs'] ?? null,
128 );
129 }
130
131 public function toArray(): array
132 {
133 return array_filter([
134 '$type' => static::getLexicon(),
135 'text' => $this->text,
136 'createdAt' => $this->createdAt->toIso8601String(),
137 'facets' => $this->facets,
138 'embed' => $this->embed,
139 'langs' => $this->langs,
140 ], fn($v) => $v !== null);
141 }
142}
143```
144
145## Custom Lexicons (AppView)
146
147Building a custom AT Protocol application? Define your own lexicons:
148
149```php
150<?php
151
152namespace App\AtpRecords;
153
154use Carbon\Carbon;
155use SocialDept\AtpParity\Data\Record;
156
157class Article extends Record
158{
159 public function __construct(
160 public readonly string $title,
161 public readonly string $body,
162 public readonly Carbon $publishedAt,
163 public readonly ?array $tags = null,
164 public readonly ?string $coverImage = null,
165 ) {}
166
167 public static function getLexicon(): string
168 {
169 return 'com.myapp.blog.article'; // Your custom NSID
170 }
171
172 public static function fromArray(array $data): static
173 {
174 return new static(
175 title: $data['title'],
176 body: $data['body'],
177 publishedAt: Carbon::parse($data['publishedAt']),
178 tags: $data['tags'] ?? null,
179 coverImage: $data['coverImage'] ?? null,
180 );
181 }
182
183 public function toArray(): array
184 {
185 return array_filter([
186 '$type' => static::getLexicon(),
187 'title' => $this->title,
188 'body' => $this->body,
189 'publishedAt' => $this->publishedAt->toIso8601String(),
190 'tags' => $this->tags,
191 'coverImage' => $this->coverImage,
192 ], fn($v) => $v !== null);
193 }
194}
195```
196
197## Working with Embedded Types
198
199atp-schema generates classes for embedded types. Use them in your mappings:
200
201```php
202use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
203use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Images;
204use SocialDept\AtpSchema\Generated\App\Bsky\Embed\External;
205use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\StrongRef;
206
207$mapper = new SchemaMapper(
208 schemaClass: Post::class,
209 modelClass: \App\Models\Post::class,
210 toAttributes: fn(Post $post) => [
211 'content' => $post->text,
212 'published_at' => $post->createdAt,
213 'has_images' => $post->embed instanceof Images,
214 'has_link' => $post->embed instanceof External,
215 'embed_data' => $post->embed?->toArray(),
216 ],
217 toRecordData: fn($model) => [
218 'text' => $model->content,
219 'createdAt' => $model->published_at->toIso8601String(),
220 ],
221);
222```
223
224## Handling Union Types
225
226AT Protocol uses union types for fields like `embed`. atp-schema handles these via discriminated unions:
227
228```php
229use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
230use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Images;
231use SocialDept\AtpSchema\Generated\App\Bsky\Embed\External;
232use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Record;
233use SocialDept\AtpSchema\Generated\App\Bsky\Embed\RecordWithMedia;
234
235$toAttributes = function(Post $post): array {
236 $attributes = [
237 'content' => $post->text,
238 'published_at' => $post->createdAt,
239 ];
240
241 // Handle embed union type
242 if ($post->embed) {
243 match (true) {
244 $post->embed instanceof Images => $attributes['embed_type'] = 'images',
245 $post->embed instanceof External => $attributes['embed_type'] = 'external',
246 $post->embed instanceof Record => $attributes['embed_type'] = 'quote',
247 $post->embed instanceof RecordWithMedia => $attributes['embed_type'] = 'quote_media',
248 default => $attributes['embed_type'] = 'unknown',
249 };
250 $attributes['embed_data'] = $post->embed->toArray();
251 }
252
253 return $attributes;
254};
255```
256
257## Reply Threading
258
259Posts can be replies to other posts:
260
261```php
262use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
263
264$toAttributes = function(Post $post): array {
265 $attributes = [
266 'content' => $post->text,
267 'published_at' => $post->createdAt,
268 'is_reply' => $post->reply !== null,
269 ];
270
271 if ($post->reply) {
272 // Parent is the immediate post being replied to
273 $attributes['reply_parent_uri'] = $post->reply->parent->uri;
274 $attributes['reply_parent_cid'] = $post->reply->parent->cid;
275
276 // Root is the top of the thread
277 $attributes['reply_root_uri'] = $post->reply->root->uri;
278 $attributes['reply_root_cid'] = $post->reply->root->cid;
279 }
280
281 return $attributes;
282};
283```
284
285## Facets (Rich Text)
286
287Posts with mentions, links, and hashtags use facets:
288
289```php
290use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
291use SocialDept\AtpSchema\Generated\App\Bsky\Richtext\Facet;
292
293$toAttributes = function(Post $post): array {
294 $attributes = [
295 'content' => $post->text,
296 'published_at' => $post->createdAt,
297 ];
298
299 // Extract mentions, links, and tags from facets
300 $mentions = [];
301 $links = [];
302 $tags = [];
303
304 foreach ($post->facets ?? [] as $facet) {
305 foreach ($facet->features as $feature) {
306 $type = $feature->getType();
307 match ($type) {
308 'app.bsky.richtext.facet#mention' => $mentions[] = $feature->did,
309 'app.bsky.richtext.facet#link' => $links[] = $feature->uri,
310 'app.bsky.richtext.facet#tag' => $tags[] = $feature->tag,
311 default => null,
312 };
313 }
314 }
315
316 $attributes['mentions'] = $mentions;
317 $attributes['links'] = $links;
318 $attributes['tags'] = $tags;
319 $attributes['facets'] = $post->facets; // Store raw for reconstruction
320
321 return $attributes;
322};
323```
324
325## Type Safety Benefits
326
327Using atp-schema classes provides:
328
3291. **IDE Autocompletion** - Full property and method suggestions
3302. **Type Checking** - Static analysis catches errors
3313. **Validation** - Data is validated on construction
3324. **Documentation** - Generated classes include docblocks
333
334```php
335// IDE knows $post->text is string, $post->createdAt is string, etc.
336$toAttributes = function(Post $post): array {
337 return [
338 'content' => $post->text, // string
339 'published_at' => $post->createdAt, // string (ISO 8601)
340 'langs' => $post->langs, // ?array
341 'facets' => $post->facets, // ?array
342 ];
343};
344```
345
346## Regenerating Schema Classes
347
348When the AT Protocol schema updates, regenerate the classes:
349
350```bash
351# In the atp-schema package
352php artisan atp:generate
353```
354
355Your mappers will automatically work with the updated types.