A barebones implementation of an atproto PDS in PHP and Slim Framework 4.
pds php atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add actor data schema

Andrés Ignacio Torres (May 17, 2026, 12:01 AM -0700) 20040624 f99c891d

+386 -11
+5 -4
src/Domain/Account/Account.php
··· 4 4 5 5 namespace App\Domain\Account; 6 6 7 + use DateTimeImmutable; 7 8 use JsonSerializable; 8 9 9 10 class Account implements JsonSerializable ··· 14 15 15 16 private string $passwordScrypt; 16 17 17 - private ?string $emailConfirmedAt; 18 + private ?DateTimeImmutable $emailConfirmedAt; 18 19 19 20 private bool $invitesDisabled; 20 21 ··· 22 23 string $did, 23 24 string $email, 24 25 string $passwordScrypt, 25 - ?string $emailConfirmedAt = null, 26 + ?DateTimeImmutable $emailConfirmedAt = null, 26 27 bool $invitesDisabled = false 27 28 ) { 28 29 $this->did = $did; ··· 47 48 return $this->passwordScrypt; 48 49 } 49 50 50 - public function getEmailConfirmedAt(): ?string 51 + public function getEmailConfirmedAt(): ?DateTimeImmutable 51 52 { 52 53 return $this->emailConfirmedAt; 53 54 } ··· 63 64 return [ 64 65 'did' => $this->did, 65 66 'email' => $this->email, 66 - 'emailConfirmedAt' => $this->emailConfirmedAt, 67 + 'emailConfirmedAt' => $this->emailConfirmedAt?->format(DATE_ATOM), 67 68 'invitesDisabled' => $this->invitesDisabled, 68 69 ]; 69 70 }
+82
src/Domain/Actor/Actor.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Domain\Actor; 6 + 7 + use DateTimeImmutable; 8 + use JsonSerializable; 9 + 10 + class Actor implements JsonSerializable 11 + { 12 + private string $did; 13 + 14 + private ?string $handle; 15 + 16 + private DateTimeImmutable $createdAt; 17 + 18 + private ?string $takedownRef; 19 + 20 + private ?DateTimeImmutable $deactivatedAt; 21 + 22 + private ?DateTimeImmutable $deleteAfter; 23 + 24 + public function __construct( 25 + string $did, 26 + ?string $handle, 27 + DateTimeImmutable $createdAt, 28 + ?string $takedownRef = null, 29 + ?DateTimeImmutable $deactivatedAt = null, 30 + ?DateTimeImmutable $deleteAfter = null 31 + ) { 32 + $this->did = $did; 33 + $this->handle = $handle === null ? null : strtolower(trim($handle)); 34 + $this->createdAt = $createdAt; 35 + $this->takedownRef = $takedownRef; 36 + $this->deactivatedAt = $deactivatedAt; 37 + $this->deleteAfter = $deleteAfter; 38 + } 39 + 40 + public function getDid(): string 41 + { 42 + return $this->did; 43 + } 44 + 45 + public function getHandle(): ?string 46 + { 47 + return $this->handle; 48 + } 49 + 50 + public function getCreatedAt(): DateTimeImmutable 51 + { 52 + return $this->createdAt; 53 + } 54 + 55 + public function getTakedownRef(): ?string 56 + { 57 + return $this->takedownRef; 58 + } 59 + 60 + public function getDeactivatedAt(): ?DateTimeImmutable 61 + { 62 + return $this->deactivatedAt; 63 + } 64 + 65 + public function getDeleteAfter(): ?DateTimeImmutable 66 + { 67 + return $this->deleteAfter; 68 + } 69 + 70 + #[\ReturnTypeWillChange] 71 + public function jsonSerialize(): array 72 + { 73 + return [ 74 + 'did' => $this->did, 75 + 'handle' => $this->handle, 76 + 'createdAt' => $this->createdAt->format(DATE_ATOM), 77 + 'takedownRef' => $this->takedownRef, 78 + 'deactivatedAt' => $this->deactivatedAt?->format(DATE_ATOM), 79 + 'deleteAfter' => $this->deleteAfter?->format(DATE_ATOM), 80 + ]; 81 + } 82 + }
+12
src/Domain/Actor/ActorNotFoundException.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Domain\Actor; 6 + 7 + use App\Domain\DomainException\DomainRecordNotFoundException; 8 + 9 + class ActorNotFoundException extends DomainRecordNotFoundException 10 + { 11 + public $message = 'Actor not found'; 12 + }
+23
src/Domain/Actor/ActorRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Domain\Actor; 6 + 7 + interface ActorRepository 8 + { 9 + /** 10 + * @return Actor[] 11 + */ 12 + public function findAll(): array; 13 + 14 + /** 15 + * @throws ActorNotFoundException 16 + */ 17 + public function findActorByDid(string $did): Actor; 18 + 19 + /** 20 + * @throws ActorNotFoundException 21 + */ 22 + public function findActorByHandle(string $handle): Actor; 23 + }
+2 -2
src/Infrastructure/Persistence/Account/InMemoryAccountRepository.php
··· 28 28 did: "did:web:alice.{$hostname}", 29 29 email: "alice@{$hostname}", 30 30 passwordScrypt: 'placeholder-scrypt-hash', 31 - emailConfirmedAt: '2024-01-01T00:00:00Z', 31 + emailConfirmedAt: new \DateTimeImmutable('2024-01-01T00:00:00Z'), 32 32 invitesDisabled: false, 33 33 ), 34 34 new Account( ··· 42 42 did: 'did:plc:carol000000000000000000000', 43 43 email: "carol@{$hostname}", 44 44 passwordScrypt: 'placeholder-scrypt-hash', 45 - emailConfirmedAt: '2024-01-03T00:00:00Z', 45 + emailConfirmedAt: new \DateTimeImmutable('2024-01-03T00:00:00Z'), 46 46 invitesDisabled: true, 47 47 ), 48 48 ];
+92
src/Infrastructure/Persistence/Actor/InMemoryActorRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Actor; 6 + 7 + use App\Application\Settings\SettingsInterface; 8 + use App\Domain\Actor\Actor; 9 + use App\Domain\Actor\ActorNotFoundException; 10 + use App\Domain\Actor\ActorRepository; 11 + 12 + class InMemoryActorRepository implements ActorRepository 13 + { 14 + /** 15 + * @var array<string, Actor> 16 + */ 17 + private array $actorsByDid; 18 + 19 + /** 20 + * @param Actor[]|null $actors 21 + */ 22 + public function __construct(?SettingsInterface $settings = null, ?array $actors = null) 23 + { 24 + if ($actors === null) { 25 + $hostname = $settings !== null ? ($settings->get('pds')['hostname'] ?? 'localhost') : 'localhost'; 26 + $actors = [ 27 + new Actor( 28 + "did:web:alice.{$hostname}", 29 + "alice.{$hostname}", 30 + new \DateTimeImmutable('2024-01-01T00:00:00Z') 31 + ), 32 + new Actor( 33 + "did:web:bob.{$hostname}", 34 + "bob.{$hostname}", 35 + new \DateTimeImmutable('2024-01-02T00:00:00Z') 36 + ), 37 + new Actor( 38 + 'did:plc:carol000000000000000000000', 39 + "carol.{$hostname}", 40 + new \DateTimeImmutable('2024-01-03T00:00:00Z') 41 + ), 42 + ]; 43 + } 44 + 45 + $this->actorsByDid = []; 46 + foreach ($actors as $actor) { 47 + $this->actorsByDid[$actor->getDid()] = $actor; 48 + } 49 + } 50 + 51 + /** 52 + * {@inheritdoc} 53 + */ 54 + public function findAll(): array 55 + { 56 + return array_values($this->actorsByDid); 57 + } 58 + 59 + /** 60 + * {@inheritdoc} 61 + */ 62 + public function findActorByDid(string $did): Actor 63 + { 64 + $key = trim($did); 65 + 66 + if (!isset($this->actorsByDid[$key])) { 67 + throw new ActorNotFoundException(); 68 + } 69 + 70 + return $this->actorsByDid[$key]; 71 + } 72 + 73 + /** 74 + * {@inheritdoc} 75 + */ 76 + public function findActorByHandle(string $handle): Actor 77 + { 78 + $needle = strtolower(trim($handle)); 79 + 80 + if ($needle === '') { 81 + throw new ActorNotFoundException(); 82 + } 83 + 84 + foreach ($this->actorsByDid as $actor) { 85 + if ($actor->getHandle() === $needle) { 86 + return $actor; 87 + } 88 + } 89 + 90 + throw new ActorNotFoundException(); 91 + } 92 + }
+8 -5
tests/Domain/Account/AccountTest.php
··· 5 5 namespace Tests\Domain\Account; 6 6 7 7 use App\Domain\Account\Account; 8 + use DateTimeImmutable; 8 9 use Tests\TestCase; 9 10 10 11 class AccountTest extends TestCase 11 12 { 12 13 public function testGettersWithAllFields(): void 13 14 { 15 + $emailConfirmedAt = new DateTimeImmutable('2026-01-01T00:00:00Z'); 16 + 14 17 $account = new Account( 15 18 did: 'did:web:alice.pds.test', 16 19 email: 'alice@pds.test', 17 20 passwordScrypt: 'hash', 18 - emailConfirmedAt: '2024-01-01T00:00:00Z', 21 + emailConfirmedAt: $emailConfirmedAt, 19 22 invitesDisabled: true, 20 23 ); 21 24 22 25 $this->assertSame('did:web:alice.pds.test', $account->getDid()); 23 26 $this->assertSame('alice@pds.test', $account->getEmail()); 24 27 $this->assertSame('hash', $account->getPasswordScrypt()); 25 - $this->assertSame('2024-01-01T00:00:00Z', $account->getEmailConfirmedAt()); 28 + $this->assertEquals($emailConfirmedAt, $account->getEmailConfirmedAt()); 26 29 $this->assertTrue($account->isInvitesDisabled()); 27 30 } 28 31 ··· 49 52 $this->assertSame('alice@pds.test', $account->getEmail()); 50 53 } 51 54 52 - public function testJsonSerializeOmitsPasswordScrypt(): void 55 + public function testJsonSerializeFormatsDatetimeAndOmitsPasswordScrypt(): void 53 56 { 54 57 $account = new Account( 55 58 did: 'did:web:alice.pds.test', 56 59 email: 'alice@pds.test', 57 60 passwordScrypt: 'secret', 58 - emailConfirmedAt: '2024-01-01T00:00:00Z', 61 + emailConfirmedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 59 62 invitesDisabled: false, 60 63 ); 61 64 ··· 64 67 $this->assertSame([ 65 68 'did' => 'did:web:alice.pds.test', 66 69 'email' => 'alice@pds.test', 67 - 'emailConfirmedAt' => '2024-01-01T00:00:00Z', 70 + 'emailConfirmedAt' => '2026-01-01T00:00:00+00:00', 68 71 'invitesDisabled' => false, 69 72 ], $json); 70 73 $this->assertArrayNotHasKey('passwordScrypt', $json);
+81
tests/Domain/Actor/ActorTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Domain\Actor; 6 + 7 + use App\Domain\Actor\Actor; 8 + use DateTimeImmutable; 9 + use Tests\TestCase; 10 + 11 + class ActorTest extends TestCase 12 + { 13 + public function testGettersWithAllFields(): void 14 + { 15 + $createdAt = new DateTimeImmutable('2026-01-01T00:00:00Z'); 16 + $deactivatedAt = new DateTimeImmutable('2026-02-01T00:00:00Z'); 17 + $deleteAfter = new DateTimeImmutable('2026-03-01T00:00:00Z'); 18 + 19 + $actor = new Actor( 20 + did: 'did:web:alice.pds.test', 21 + handle: 'alice.pds.test', 22 + createdAt: $createdAt, 23 + takedownRef: 'ref-1', 24 + deactivatedAt: $deactivatedAt, 25 + deleteAfter: $deleteAfter, 26 + ); 27 + 28 + $this->assertSame('did:web:alice.pds.test', $actor->getDid()); 29 + $this->assertSame('alice.pds.test', $actor->getHandle()); 30 + $this->assertEquals($createdAt, $actor->getCreatedAt()); 31 + $this->assertSame('ref-1', $actor->getTakedownRef()); 32 + $this->assertEquals($deactivatedAt, $actor->getDeactivatedAt()); 33 + $this->assertEquals($deleteAfter, $actor->getDeleteAfter()); 34 + } 35 + 36 + public function testGettersWithNullableFieldsDefaultToNull(): void 37 + { 38 + $actor = new Actor( 39 + did: 'did:web:alice.pds.test', 40 + handle: null, 41 + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 42 + ); 43 + 44 + $this->assertNull($actor->getHandle()); 45 + $this->assertNull($actor->getTakedownRef()); 46 + $this->assertNull($actor->getDeactivatedAt()); 47 + $this->assertNull($actor->getDeleteAfter()); 48 + } 49 + 50 + public function testConstructorNormalizesHandle(): void 51 + { 52 + $actor = new Actor( 53 + did: 'did:web:alice.pds.test', 54 + handle: ' Alice.PDS.Test ', 55 + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 56 + ); 57 + 58 + $this->assertSame('alice.pds.test', $actor->getHandle()); 59 + } 60 + 61 + public function testJsonSerializeFormatsDatetimesAsRfc3339(): void 62 + { 63 + $actor = new Actor( 64 + did: 'did:web:alice.pds.test', 65 + handle: 'alice.pds.test', 66 + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 67 + takedownRef: null, 68 + deactivatedAt: null, 69 + deleteAfter: null, 70 + ); 71 + 72 + $payload = json_decode((string) json_encode($actor), true); 73 + 74 + $this->assertSame('did:web:alice.pds.test', $payload['did']); 75 + $this->assertSame('alice.pds.test', $payload['handle']); 76 + $this->assertSame('2026-01-01T00:00:00+00:00', $payload['createdAt']); 77 + $this->assertNull($payload['takedownRef']); 78 + $this->assertNull($payload['deactivatedAt']); 79 + $this->assertNull($payload['deleteAfter']); 80 + } 81 + }
+81
tests/Infrastructure/Persistence/Actor/InMemoryActorRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Actor; 6 + 7 + use App\Application\Settings\Settings; 8 + use App\Domain\Actor\Actor; 9 + use App\Domain\Actor\ActorNotFoundException; 10 + use App\Infrastructure\Persistence\Actor\InMemoryActorRepository; 11 + use DateTimeImmutable; 12 + use Tests\TestCase; 13 + 14 + class InMemoryActorRepositoryTest extends TestCase 15 + { 16 + private function makeActor(string $did, ?string $handle): Actor 17 + { 18 + return new Actor($did, $handle, new DateTimeImmutable('2026-01-01T00:00:00Z')); 19 + } 20 + 21 + public function testFindAllReturnsProvidedActors(): void 22 + { 23 + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 24 + $repository = new InMemoryActorRepository(null, [$alice]); 25 + 26 + $this->assertEquals([$alice], $repository->findAll()); 27 + } 28 + 29 + public function testFindAllSeedsFromSettings(): void 30 + { 31 + $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); 32 + $repository = new InMemoryActorRepository($settings); 33 + 34 + $actors = $repository->findAll(); 35 + 36 + $this->assertCount(3, $actors); 37 + $this->assertSame('did:web:alice.pds.test', $actors[0]->getDid()); 38 + $this->assertSame('alice.pds.test', $actors[0]->getHandle()); 39 + } 40 + 41 + public function testFindActorByDid(): void 42 + { 43 + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 44 + $repository = new InMemoryActorRepository(null, [$alice]); 45 + 46 + $this->assertEquals($alice, $repository->findActorByDid('did:web:alice.pds.test')); 47 + } 48 + 49 + public function testFindActorByDidThrowsWhenMissing(): void 50 + { 51 + $repository = new InMemoryActorRepository(null, []); 52 + 53 + $this->expectException(ActorNotFoundException::class); 54 + $repository->findActorByDid('did:web:missing.pds.test'); 55 + } 56 + 57 + public function testFindActorByHandleIsCaseInsensitive(): void 58 + { 59 + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 60 + $repository = new InMemoryActorRepository(null, [$alice]); 61 + 62 + $this->assertEquals($alice, $repository->findActorByHandle(' ALICE.PDS.TEST ')); 63 + } 64 + 65 + public function testFindActorByHandleThrowsWhenMissing(): void 66 + { 67 + $repository = new InMemoryActorRepository(null, []); 68 + 69 + $this->expectException(ActorNotFoundException::class); 70 + $repository->findActorByHandle('missing.pds.test'); 71 + } 72 + 73 + public function testFindActorByHandleSkipsActorsWithoutHandle(): void 74 + { 75 + $headless = $this->makeActor('did:web:headless.pds.test', null); 76 + $repository = new InMemoryActorRepository(null, [$headless]); 77 + 78 + $this->expectException(ActorNotFoundException::class); 79 + $repository->findActorByHandle('headless.pds.test'); 80 + } 81 + }