├── .gitignore ├── src ├── Exceptions │ └── ActionVersionNotFoundException.php ├── ReadModels │ ├── ReadModelRepository.php │ ├── InMemoryReadModelRepository.php │ └── PdoReadModelRepository.php ├── Projections │ ├── Projection.php │ └── BaseProjection.php └── Events │ ├── EventRepository.php │ ├── Event.php │ ├── Action.php │ ├── BaseSnapshotEvent.php │ ├── InMemoryEventRepository.php │ ├── BaseEvent.php │ └── PdoEventRepository.php ├── tests ├── TestSnapshotEvent.php ├── Examples │ └── UserRegistration │ │ ├── Events │ │ ├── UserSignedUp.php │ │ ├── UserMadePayment.php │ │ └── UserNameWasUpdated.php │ │ ├── OnboardingProjection.php │ │ └── FromUserRegistrationToPayingCustomerTest.php ├── Unit │ ├── InMemoryEventRepositoryTest.php │ ├── ReadModelRepositoryTest.php │ ├── TestProjection.php │ └── ProjectionTest.php ├── SomethingHappened.php ├── SomethingElseHappened.php ├── BaseReadModelRepositoryTest.php ├── Integration │ ├── PdoReadModelRepositoryTest.php │ └── PdoEventRepositoryTest.php └── BaseEventRepositoryTest.php ├── composer.json ├── phpunit.xml ├── .circleci └── config.yml └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .idea/vcs.xml 3 | vendor 4 | 5 | composer.lock 6 | -------------------------------------------------------------------------------- /src/Exceptions/ActionVersionNotFoundException.php: -------------------------------------------------------------------------------- 1 | createdAt = $date; 12 | } 13 | } -------------------------------------------------------------------------------- /src/Events/EventRepository.php: -------------------------------------------------------------------------------- 1 | actions = [ 13 | new Action('signed_up', 1), 14 | ]; 15 | } 16 | } -------------------------------------------------------------------------------- /tests/Examples/UserRegistration/Events/UserMadePayment.php: -------------------------------------------------------------------------------- 1 | actions = [ 13 | new Action('payment_made', 1), 14 | ]; 15 | } 16 | } -------------------------------------------------------------------------------- /tests/Unit/InMemoryEventRepositoryTest.php: -------------------------------------------------------------------------------- 1 | actions = [ 13 | new Action('name_updated', 1), 14 | new Action('name_updated', 2), 15 | ]; 16 | } 17 | } -------------------------------------------------------------------------------- /tests/SomethingHappened.php: -------------------------------------------------------------------------------- 1 | createdAt = $date; 13 | } 14 | 15 | protected function loadActions() 16 | { 17 | $this->actions = [ 18 | new Action('something_happened', 1), 19 | ]; 20 | } 21 | } -------------------------------------------------------------------------------- /tests/SomethingElseHappened.php: -------------------------------------------------------------------------------- 1 | createdAt = $date; 13 | } 14 | 15 | protected function loadActions() 16 | { 17 | $this->actions = [ 18 | new Action('something_else_happened', 1), 19 | ]; 20 | } 21 | } -------------------------------------------------------------------------------- /src/ReadModels/InMemoryReadModelRepository.php: -------------------------------------------------------------------------------- 1 | data[$rootId] ?? null; 15 | } 16 | 17 | public function store($rootId, array $newState) 18 | { 19 | $this->data[$rootId] = $newState; 20 | } 21 | } -------------------------------------------------------------------------------- /src/Events/Event.php: -------------------------------------------------------------------------------- 1 | =7.1.0" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "~7.0" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Binocular\\": "src/", 26 | "Tests\\": "tests/" 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/Events/Action.php: -------------------------------------------------------------------------------- 1 | name = $name; 20 | $this->version = $version; 21 | } 22 | 23 | public function getVersion(): int 24 | { 25 | return $this->version; 26 | } 27 | 28 | public function getName(): string 29 | { 30 | return $this->name; 31 | } 32 | 33 | public function toArray(): array 34 | { 35 | return [ 36 | 'name' => $this->getName(), 37 | 'version' => $this->getVersion(), 38 | ]; 39 | } 40 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /tests/Unit/TestProjection.php: -------------------------------------------------------------------------------- 1 | [ 14 | 1 => function (array $currentState, Event $event): ?array { 15 | // calculate new state 16 | $eventPayload = $event->getPayload(); 17 | return array_merge($currentState, $eventPayload); 18 | } 19 | ], 20 | 'something_else_happened' => [ 21 | 1 => function (array $currentState, Event $event): ?array { 22 | // calculate new state 23 | $eventPayload = $event->getPayload(); 24 | return array_merge($currentState, $eventPayload); 25 | } 26 | ], 27 | ]; 28 | } 29 | 30 | public function getName(): string 31 | { 32 | return 'test_projection'; 33 | } 34 | } -------------------------------------------------------------------------------- /src/Events/BaseSnapshotEvent.php: -------------------------------------------------------------------------------- 1 | snapshotProjectionName = $snapshotProjectionName; 22 | } 23 | 24 | public function getSelectedAction(): Action 25 | { 26 | return $this->actions[0]; 27 | } 28 | 29 | /** 30 | * @return Action[] 31 | */ 32 | public function getActions(): array 33 | { 34 | return $this->actions; 35 | } 36 | 37 | protected function loadActions() 38 | { 39 | $this->actions = [ 40 | new Action('snapshot', -1) 41 | ]; 42 | } 43 | 44 | public function getActionVersion(): int 45 | { 46 | return $this->defaultActionVersion; 47 | } 48 | 49 | public function getSnapshotProjectionName(): ?string 50 | { 51 | return $this->snapshotProjectionName; 52 | } 53 | } -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # PHP CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-php/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/php:7.1-browsers 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mysql:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "composer.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" 30 | - run: php composer-setup.php 31 | - run: php -r "unlink('composer-setup.php');" 32 | - run: php composer.phar install 33 | 34 | - save_cache: 35 | paths: 36 | - ./vendor 37 | key: v1-dependencies-{{ checksum "composer.json" }} 38 | 39 | # run tests! 40 | - run: ./vendor/bin/phpunit 41 | -------------------------------------------------------------------------------- /tests/BaseReadModelRepositoryTest.php: -------------------------------------------------------------------------------- 1 | getRepository(); 15 | 16 | $readModelRepository->store(self::ROOT_ID, ['foo' => 'bar']); 17 | 18 | $state = $readModelRepository->get(self::ROOT_ID); 19 | 20 | $this->assertEquals(['foo' => 'bar'], $state); 21 | } 22 | 23 | public function test_new_read_model_state_are_grouped_by_root_id() 24 | { 25 | $readModelRepository = $this->getRepository(); 26 | 27 | $readModelRepository->store(self::ROOT_ID, ['foo' => 'bar']); 28 | $readModelRepository->store('bar', ['bar' => 'baz']); 29 | 30 | $state = $readModelRepository->get(self::ROOT_ID); 31 | 32 | $this->assertEquals(['foo' => 'bar'], $state); 33 | } 34 | 35 | public function test_null_is_returned_if_state_not_found_for_given_root_id() 36 | { 37 | $readModelRepository = $this->getRepository(); 38 | 39 | $readModelRepository->store('bar', ['bar' => 'baz']); 40 | 41 | $state = $readModelRepository->get(self::ROOT_ID); 42 | 43 | $this->assertNull($state); 44 | } 45 | 46 | abstract protected function getRepository(): ReadModelRepository; 47 | } -------------------------------------------------------------------------------- /src/Events/InMemoryEventRepository.php: -------------------------------------------------------------------------------- 1 | events[$event->getRootId()])) { 15 | $this->events[$event->getRootId()] = []; 16 | } 17 | 18 | $this->events[$event->getRootId()][] = $event; 19 | } 20 | 21 | /** 22 | * @return Event[] 23 | */ 24 | public function all($rootId, \DateTime $from = null): array 25 | { 26 | if (!isset($this->events[$rootId])) { 27 | return []; 28 | } 29 | 30 | if ($from) { 31 | return array_filter($this->events[$rootId], function (Event $event) use ($from) { 32 | return $event->getCreatedAt() <= $from; 33 | }); 34 | } 35 | 36 | return $this->events[$rootId] ?? []; 37 | } 38 | 39 | public function getFirstSnapshotAfter($rootId, string $snapshotProjectionName, \DateTime $from): ?Event 40 | { 41 | if (!isset($this->events[$rootId])) { 42 | return null; 43 | } 44 | 45 | foreach ($this->events[$rootId] as $event) { 46 | 47 | if ($event->getSnapshotProjectionName() == $snapshotProjectionName 48 | && $event->getCreatedAt() >= $from) { 49 | return $event; 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | } -------------------------------------------------------------------------------- /tests/Integration/PdoReadModelRepositoryTest.php: -------------------------------------------------------------------------------- 1 | pdo = new PDO('sqlite:' . self::DB_FILE); 32 | 33 | // Set errormode to exceptions 34 | $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 35 | 36 | /** 37 | * Create event table 38 | */ 39 | $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$this->table} ( 40 | root_id VARCHAR(255) PRIMARY KEY NOT NULL, 41 | payload TEXT, 42 | updated_at TIMESTAMP NOT NULL)"); 43 | } 44 | 45 | public function tearDown() 46 | { 47 | parent::tearDown(); 48 | 49 | unlink(self::DB_FILE); 50 | } 51 | 52 | protected function getRepository(): ReadModelRepository 53 | { 54 | return new PdoReadModelRepository($this->pdo, $this->table); 55 | } 56 | } -------------------------------------------------------------------------------- /tests/Unit/ProjectionTest.php: -------------------------------------------------------------------------------- 1 | eventRepository = new InMemoryEventRepository; 37 | 38 | $this->readModelRepository = new InMemoryReadModelRepository; 39 | 40 | $this->testProjection = new TestProjection($this->eventRepository); 41 | } 42 | 43 | public function test_state_calculation() 44 | { 45 | $cachedState = ['yes' => 'no']; 46 | 47 | $this->readModelRepository->store(self::ROOT_ID, $cachedState); 48 | 49 | $this->eventRepository->store( 50 | new SomethingHappened(self::ROOT_ID, ['foo' => 'bar']) 51 | ); 52 | 53 | $this->eventRepository->store( 54 | new SomethingElseHappened(self::ROOT_ID, ['yes' => 'no']) 55 | ); 56 | 57 | $newState = $this->testProjection->calculateState(self::ROOT_ID); 58 | 59 | $this->assertEquals(['foo' => 'bar', 'yes' => 'no'], $newState); 60 | } 61 | } -------------------------------------------------------------------------------- /tests/Integration/PdoEventRepositoryTest.php: -------------------------------------------------------------------------------- 1 | pdo = new PDO('sqlite:' . self::DB_FILE); 33 | 34 | // Set errormode to exceptions 35 | $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 36 | 37 | /** 38 | * Create event table 39 | */ 40 | $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$this->table} ( 41 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 42 | root_id VARCHAR(255), 43 | serialized TEXT, 44 | projection_snapshot VARCHAR(255) DEFAULT NULL, 45 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)"); 46 | } 47 | 48 | public function tearDown() 49 | { 50 | parent::tearDown(); 51 | 52 | unlink(self::DB_FILE); 53 | } 54 | 55 | protected function getRepository(): EventRepository 56 | { 57 | return new PdoEventRepository($this->pdo, $this->table); 58 | } 59 | } -------------------------------------------------------------------------------- /src/ReadModels/PdoReadModelRepository.php: -------------------------------------------------------------------------------- 1 | pdo = $pdo; 27 | $this->tableName = $tableName; 28 | } 29 | 30 | public function get($rootId): ?array 31 | { 32 | $stmt = $this->pdo->prepare("SELECT * FROM {$this->tableName} WHERE root_id=:root_id"); 33 | $stmt->execute(['root_id' => $rootId]); 34 | 35 | $readModel = $stmt->fetch(); 36 | 37 | if (!$readModel) { 38 | return null; 39 | } 40 | 41 | return json_decode($readModel['payload'], true); 42 | } 43 | 44 | public function store($rootId, array $newState) 45 | { 46 | $existing = $this->get($rootId); 47 | 48 | if ($existing) { 49 | $sql = "UPDATE {$this->tableName} SET root_id=:root_id, payload=:payload, updated_at=:updated_at"; 50 | } else { 51 | $sql = "INSERT INTO {$this->tableName} (root_id, payload, updated_at) 52 | VALUES (:root_id, :payload, :updated_at)"; 53 | } 54 | 55 | $stmt = $this->pdo->prepare($sql); 56 | 57 | $stmt->bindParam(':root_id', $rootId); 58 | $stmt->bindParam(':payload', json_encode($newState)); 59 | $stmt->bindParam(':updated_at', (new DateTime)->format(DATE_RFC3339_EXTENDED)); 60 | 61 | $stmt->execute(); 62 | } 63 | } -------------------------------------------------------------------------------- /src/Projections/BaseProjection.php: -------------------------------------------------------------------------------- 1 | eventRepository = $eventRepository; 19 | } 20 | 21 | public function calculateState($rootId): ?array 22 | { 23 | $events = $this->eventRepository->all($rootId); 24 | 25 | $reducers = $this->getReducers(); 26 | 27 | $currentState = []; 28 | 29 | foreach ($events as $event) { 30 | $action = $event->getSelectedAction(); 31 | 32 | $reducer = $this->getMappedReducerFromAction($reducers, $action); 33 | 34 | $currentState = $this->reduce($reducer, $currentState, $event); 35 | } 36 | 37 | return $currentState; 38 | } 39 | 40 | abstract public function getName(): string; 41 | 42 | abstract public function getReducers(): array; 43 | 44 | protected function reduce(callable $reducer, array $currentState, ?Event $event): array 45 | { 46 | return $reducer(is_null($currentState) ? [] : $currentState, $event); 47 | } 48 | 49 | protected function getMappedReducerFromAction(array $reducers, Action $action): callable 50 | { 51 | if (!isset($reducers[$action->getName()][$action->getVersion()])) { 52 | throw new \RuntimeException( 53 | sprintf('Action %s not found for version %s', $action->getName(), $action->getVersion()) 54 | ); 55 | } 56 | 57 | $reducer = $reducers[$action->getName()][$action->getVersion()]; 58 | 59 | if (!is_callable($reducer)) { 60 | throw new \RuntimeException( 61 | sprintf('Action %s version %s is not a callable', $action->getName(), $action->getVersion()) 62 | ); 63 | } 64 | 65 | return $reducer; 66 | } 67 | } -------------------------------------------------------------------------------- /tests/Examples/UserRegistration/OnboardingProjection.php: -------------------------------------------------------------------------------- 1 | [ 14 | 1 => function (array $currentState, Event $event): ?array { 15 | // calculate new state 16 | $eventPayload = $event->getPayload(); 17 | $newState = $eventPayload; 18 | $newState['onboarding_status'] = 'signed_up'; 19 | 20 | return $newState; 21 | } 22 | ], 23 | 'name_updated' => [ 24 | 1 => function (array $currentState, Event $event): ?array { 25 | // calculate new state 26 | $eventPayload = $event->getPayload(); 27 | $newState = $currentState; 28 | $newState['name'] = $eventPayload['name']; 29 | 30 | return $newState; 31 | }, 32 | 2 => function (array $currentState, Event $event): ?array { 33 | // calculate new state 34 | $eventPayload = $event->getPayload(); 35 | $newState = $currentState; 36 | $newState['name'] = $eventPayload['name']; 37 | $newState['first_letter'] = $newState['name'][0]; 38 | 39 | return $newState; 40 | } 41 | ], 42 | 'payment_made' => [ 43 | 1 => function (array $currentState, Event $event): ?array { 44 | // calculate new state 45 | $eventPayload = $event->getPayload(); 46 | $newState = $currentState; 47 | $newState['onboarding_status'] = 'completed'; 48 | $newState['revenue_to_date'] = $eventPayload['amount']; 49 | 50 | return $newState; 51 | } 52 | ], 53 | ]; 54 | } 55 | 56 | public function getName(): string 57 | { 58 | return 'onboarding_projection'; 59 | } 60 | } -------------------------------------------------------------------------------- /src/Events/BaseEvent.php: -------------------------------------------------------------------------------- 1 | rootId = $rootId; 43 | $this->payload = $payload; 44 | $this->actionVersion = $actionVersion ?? $this->defaultActionVersion; 45 | $this->createdAt = new DateTime; 46 | 47 | $this->loadActions(); 48 | } 49 | 50 | public function getRootId() 51 | { 52 | return $this->rootId; 53 | } 54 | 55 | public function getSelectedAction(): Action 56 | { 57 | foreach ($this->getActions() as $action) { 58 | if ($action->getVersion() === $this->actionVersion) { 59 | return $action; 60 | } 61 | } 62 | 63 | throw new ActionVersionNotFoundException('Make sure you have an action matching the ACTIVE_VERSION constant'); 64 | } 65 | 66 | /** 67 | * @return Action[] 68 | */ 69 | public function getActions(): array 70 | { 71 | return $this->actions; 72 | } 73 | 74 | public function getActionVersion(): int 75 | { 76 | return $this->defaultActionVersion; 77 | } 78 | 79 | public function getPayload(): array 80 | { 81 | return $this->payload; 82 | } 83 | 84 | public function getCreatedAt(): DateTime 85 | { 86 | return $this->createdAt; 87 | } 88 | 89 | public function setCreatedAt(\DateTime $createdAt) 90 | { 91 | $this->createdAt = $createdAt; 92 | } 93 | 94 | public function getSnapshotProjectionName(): ?string 95 | { 96 | return null; 97 | } 98 | 99 | public function equals(Event $event): bool 100 | { 101 | return $this->getActionVersion() === $event->getActionVersion() 102 | && $this->getSnapshotProjectionName() === $event->getSnapshotProjectionName() 103 | && $this->getRootId() === $event->getRootId() 104 | && $this->getPayload() === $event->getPayload() 105 | && $this->getCreatedAt()->format(DATE_RFC3339_EXTENDED) === $event->getCreatedAt()->format(DATE_RFC3339_EXTENDED); 106 | } 107 | 108 | abstract protected function loadActions(); 109 | } -------------------------------------------------------------------------------- /tests/Examples/UserRegistration/FromUserRegistrationToPayingCustomerTest.php: -------------------------------------------------------------------------------- 1 | store( 28 | new UserSignedUp(self::ROOT_ID, ['name' => 'John']) 29 | ); 30 | 31 | // update projection state 32 | $newState = $onboardingProjection->calculateState(self::ROOT_ID); 33 | $readModelRepository->store(self::ROOT_ID, $newState); 34 | 35 | $expected = [ 36 | 'name' => 'John', 37 | 'onboarding_status' => 'signed_up' 38 | ]; 39 | $this->assertEquals($expected, $newState); 40 | 41 | /** 42 | * Second event, update name with version 1 action 43 | */ 44 | $eventRepository->store( 45 | new UserNameWasUpdated(self::ROOT_ID, ['name' => 'John Smith'], 1) 46 | ); 47 | 48 | // update projection state 49 | $newState = $onboardingProjection->calculateState(self::ROOT_ID); 50 | $readModelRepository->store(self::ROOT_ID, $newState); 51 | 52 | $expected = [ 53 | 'name' => 'John Smith', 54 | 'onboarding_status' => 'signed_up' 55 | ]; 56 | $this->assertEquals($expected, $newState); 57 | 58 | /** 59 | * Third event, update name again but this time with version 2 of the action 60 | */ 61 | $eventRepository->store( 62 | new UserNameWasUpdated(self::ROOT_ID, ['name' => 'John Glen Smith'], 2) 63 | ); 64 | 65 | // update projection state 66 | $newState = $onboardingProjection->calculateState(self::ROOT_ID); 67 | $readModelRepository->store(self::ROOT_ID, $newState); 68 | 69 | $expected = [ 70 | 'name' => 'John Glen Smith', 71 | 'first_letter' => 'J', 72 | 'onboarding_status' => 'signed_up' 73 | ]; 74 | $this->assertEquals($expected, $newState); 75 | 76 | /** 77 | * Final event, payment was made, onboarding is completed 78 | */ 79 | $eventRepository->store( 80 | new UserMadePayment(self::ROOT_ID, ['amount' => 10.5]) 81 | ); 82 | 83 | // update projection state 84 | $newState = $onboardingProjection->calculateState(self::ROOT_ID); 85 | $readModelRepository->store(self::ROOT_ID, $newState); 86 | 87 | $expected = [ 88 | 'name' => 'John Glen Smith', 89 | 'revenue_to_date' => 10.5, 90 | 'onboarding_status' => 'completed', 91 | 'first_letter' => 'J' 92 | ]; 93 | $this->assertEquals($expected, $newState); 94 | } 95 | } -------------------------------------------------------------------------------- /src/Events/PdoEventRepository.php: -------------------------------------------------------------------------------- 1 | pdo = $pdo; 27 | $this->tableName = $tableName; 28 | } 29 | 30 | public function store(Event $event) 31 | { 32 | $insert = "INSERT INTO {$this->tableName} (root_id, serialized, projection_snapshot, created_at) 33 | VALUES (:root_id, :serialized, :projection_snapshot, :created_at)"; 34 | $stmt = $this->pdo->prepare($insert); 35 | 36 | // Bind parameters to statement variables 37 | $stmt->bindParam(':root_id', $event->getRootId()); 38 | $stmt->bindParam(':serialized', serialize($event)); 39 | $stmt->bindParam(':projection_snapshot', $event->getSnapshotProjectionName()); 40 | $stmt->bindParam(':created_at', $event->getCreatedAt()->format(DATE_RFC3339_EXTENDED)); 41 | 42 | $stmt->execute(); 43 | } 44 | 45 | /** 46 | * @return Event[] 47 | */ 48 | public function all($rootId, DateTime $from = null): array 49 | { 50 | if ($from) { 51 | $stmt = $this->pdo->prepare("SELECT * FROM {$this->tableName} 52 | WHERE root_id=:root_id 53 | AND created_at >= :created_at 54 | ORDER BY created_at ASC"); 55 | 56 | $stmt->execute([ 57 | 'root_id' => $rootId, 58 | 'created_at' => $from->format(DATE_RFC3339_EXTENDED) 59 | ]); 60 | } else { 61 | $stmt = $this->pdo->prepare("SELECT * FROM {$this->tableName} WHERE root_id=:root_id ORDER BY created_at ASC"); 62 | 63 | $stmt->execute(['root_id' => $rootId]); 64 | } 65 | 66 | $events = []; 67 | 68 | foreach ($stmt->fetchAll() as $row) { 69 | $events[] = $this->rowToEvent($row); 70 | } 71 | 72 | return $events; 73 | } 74 | 75 | public function getFirstSnapshotAfter($rootId, string $snapshotProjectionName, DateTime $from): ?Event 76 | { 77 | $stmt = $this->pdo->prepare("SELECT * FROM {$this->tableName} 78 | WHERE root_id=:root_id 79 | AND projection_snapshot=:projection_snapshot 80 | AND created_at >= :created_at 81 | ORDER BY created_at ASC 82 | LIMIT 1"); 83 | 84 | $stmt->execute(['root_id' => $rootId]); 85 | $stmt->execute(['projection_snapshot' => $snapshotProjectionName]); 86 | $stmt->execute(['created_at' => $from->format(DATE_RFC3339_EXTENDED)]); 87 | 88 | $row = $stmt->fetch(); 89 | 90 | if (!$row) { 91 | return null; 92 | } 93 | 94 | return $this->rowToEvent($row); 95 | } 96 | 97 | protected function rowToEvent(array $row): Event 98 | { 99 | return unserialize($row['serialized']); 100 | } 101 | } -------------------------------------------------------------------------------- /tests/BaseEventRepositoryTest.php: -------------------------------------------------------------------------------- 1 | getRepository(); 15 | 16 | $event = new SomethingHappened(self::ROOT_ID, []); 17 | 18 | $eventRepository->store($event); 19 | 20 | $events = $eventRepository->all(self::ROOT_ID); 21 | 22 | $this->assertCount(1, $events); 23 | 24 | $this->assertTrue($event->equals($events[0])); 25 | } 26 | 27 | public function test_events_fetched() 28 | { 29 | $eventRepository = $this->getRepository(); 30 | 31 | $event1 = new SomethingHappened(self::ROOT_ID, []); 32 | $eventRepository->store($event1); 33 | 34 | $event2 = new SomethingHappened(self::ROOT_ID, []); 35 | $eventRepository->store($event2); 36 | 37 | $events = $eventRepository->all(self::ROOT_ID); 38 | 39 | $this->assertCount(2, $events); 40 | } 41 | 42 | public function test_events_are_grouped_by_root_id() 43 | { 44 | $eventRepository = $this->getRepository(); 45 | 46 | $event1 = new SomethingHappened(self::ROOT_ID, []); 47 | $eventRepository->store($event1); 48 | 49 | $event2 = new SomethingHappened('bar', []); 50 | $eventRepository->store($event2); 51 | 52 | $events = $eventRepository->all(self::ROOT_ID); 53 | 54 | $this->assertCount(1, $events); 55 | } 56 | 57 | public function test_events_can_be_selected_from_point_in_time() 58 | { 59 | $eventRepository = $this->getRepository(); 60 | 61 | $event1 = new SomethingHappened(self::ROOT_ID, []); 62 | $event1->setDate(new \DateTime('2019-01-01 00:00:00')); 63 | $eventRepository->store($event1); 64 | 65 | $event2 = new SomethingHappened(self::ROOT_ID, []); 66 | $event2->setDate(new \DateTime('2019-01-02 00:00:00')); 67 | $eventRepository->store($event2); 68 | 69 | $event3 = new SomethingHappened(self::ROOT_ID, []); 70 | $event3->setDate(new \DateTime('2019-01-03 00:00:00')); 71 | $eventRepository->store($event3); 72 | 73 | $events = $eventRepository->all(self::ROOT_ID, new \DateTime('2019-01-02 00:00:00')); 74 | 75 | $this->assertCount(2, $events); 76 | } 77 | 78 | public function test_first_snapshot_after_given_point_in_time_can_be_fetched() 79 | { 80 | $eventRepository = $this->getRepository(); 81 | 82 | $projectionName = 'Foo'; 83 | 84 | $event1 = new TestSnapshotEvent(self::ROOT_ID, $projectionName, []); 85 | $event1->setDate(new \DateTime('2019-01-01 00:00:00')); 86 | $eventRepository->store($event1); 87 | 88 | $event2 = new SomethingHappened(self::ROOT_ID, []); 89 | $event2->setDate(new \DateTime('2019-01-02 00:00:00')); 90 | $eventRepository->store($event2); 91 | 92 | $event3 = new TestSnapshotEvent(self::ROOT_ID, $projectionName, []); 93 | $event3->setDate(new \DateTime('2019-01-02 00:00:00')); 94 | $eventRepository->store($event3); 95 | 96 | $snapshot = $eventRepository->getFirstSnapshotAfter( 97 | self::ROOT_ID, 98 | $projectionName, 99 | new \DateTime('2019-01-02 00:00:00') 100 | ); 101 | 102 | $this->assertTrue($event3->equals($snapshot)); 103 | } 104 | 105 | abstract protected function getRepository(): EventRepository; 106 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Binocular 2 | 3 | [![CircleCI](https://circleci.com/gh/thiagomarini/binocular.svg?style=svg)](https://circleci.com/gh/thiagomarini/binocular) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | 5 | Doing CQRS + Event Sourcing without building a spaceship. An attempt to bring event sourcing down from the over-engineering realm. 6 | 7 | The aim of this project is to enable you to do CQRS + ES in PHP using your current stack, without the need to adopt any new technology. 8 | Sometimes classic database models are not enough to represent the state of an application. If you find yourself creating database views or 9 | using heavy SQL queries to present data in different ways Binocular is for you, it will bring structure and order to your application. 10 | A bit of a mindset shift is necessary to work with events tough, you'll have to think about producing and consuming events. 11 | But you don't need to do it everywhere or change the architecture of your application, you can do it only where database models are struggling to represent state. 12 | 13 | This project focus only on 3 elements of ES + CQRS: 14 | 15 | * **Event stream**: async stream of events produced by the application. The write side. 16 | * **Projections**: will replay and process events from the event stream to calculate state. 17 | * **Read models**: will cache the result of the event processing by the projection. The read side. 18 | 19 | For more information please read [my post](https://medium.com/@marinithiago/doing-event-sourcing-without-building-a-spaceship-6dc3e7eac000) supporting the idea. 20 | 21 | #### What's different about it? 22 | 23 | * Binocular is super lightweight and can be used with any framework. 24 | * Should only be used where database models are struggling to represent state in the application. 25 | * Projections use reducers to calculate the state of read models, a bit like [Redux](https://redux.js.org/basics/reducers): `previousState + event = newState`. Reducers make testing extremely simple, the same input always produces the same output. 26 | * Actions and reducers are versioned so events can evolve drama-free. 27 | * The only premise is that events need to be persisted somewhere so they can be replayed. 28 | * The project consists mostly of interfaces and base classes, you'll need to make your own implementation and know where to place things. 29 | 30 | 31 | #### Why Binocular as project name? 32 | Like CQRS, binocular vision happens when two separate images from two eyes are successfully combined into one image in the brain. CQRS has two eyes: the read and write eyes. 33 | 34 | ### Usage in a nutshell 35 | 36 | ``` 37 | composer require thiagomarini/binocular 38 | ``` 39 | 40 | ```php 41 | // save some events 42 | $eventRepository->store( 43 | new UserSignedUp($userId, ['name' => 'John']) 44 | ); 45 | 46 | $eventRepository->store( 47 | new UserNameWasUpdated($userId, ['name' => 'John Smith']) 48 | ); 49 | 50 | // use a projection to process the events and calculate the state of its read model 51 | $newState = $onboardingProjection->calculateState($userId); 52 | 53 | // save the read model state 54 | $readModelRepository->store($userId, $newState); 55 | 56 | print_r($newState); // ['name' => 'John Smith'] 57 | ``` 58 | 59 | ### Laravel Example 60 | 61 | As already explained Binocular can be used with any framework, you just need to know where to place things. 62 | In the case of Laravel, it already has a simple [observer implementation](https://laravel.com/docs/master/events) which is more than enough to make things work with Binocular. 63 | 64 | I've created an [example app in Laravel](https://github.com/thiagomarini/binocular-laravel). In the example I used the `User` model as the root to be event sourced, meaning that will have its own events table and also a read model table. 65 | 66 | Conceptually you'll need to: 67 | 68 | * Create an [Eloquent implementation of the repository](https://github.com/thiagomarini/binocular-laravel/blob/master/app/EventSourcing/Repositories/UserEventRepository.php) if you don't want to use the PDO one. 69 | * Create [migrations](https://github.com/thiagomarini/binocular-laravel/tree/master/database/migrations) for event and read model tables. 70 | * Create a [custom implementation of the `event()` global helper](https://github.com/thiagomarini/binocular-laravel/blob/01a3449e31f70fd2689e74a601af294cfcbafea5/bootstrap/app.php#L60) in order to save the event before queueing it. 71 | * [Place a projection in an event listener](https://github.com/thiagomarini/binocular-laravel/blob/01a3449e31f70fd2689e74a601af294cfcbafea5/app/EventSourcing/Listeners/UserSubscriber.php#L41) to calculate and save the state of the read model. 72 | * [Fire events](https://github.com/thiagomarini/binocular-laravel/blob/01a3449e31f70fd2689e74a601af294cfcbafea5/app/Http/Controllers/Auth/RegisterController.php#L77) wherever you think it's appropriate. 73 | * The cached state will be available on the [read model](https://github.com/thiagomarini/binocular-laravel/blob/master/app/UserActions.php) and you can use it as any Eloquent model in the application. And remember that read models and projections are 1-1, meaning that one projection should produce state for one read model only. 74 | 75 | There's also other plain PHP examples on `tests/Examples` folder. 76 | 77 | ### How to contribute 78 | 79 | PRs are welcome :) 80 | --------------------------------------------------------------------------------