├── .styleci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── docker-compose.yml ├── phpunit.xml ├── src ├── CQRS │ ├── ReadModelRepository.php │ └── WriteModelRepository.php ├── EventSourcing │ ├── EventHandlerDoesNotExistException.php │ ├── EventSourcedEntity.php │ ├── EventSourcedEntityBase.php │ ├── EventSourcedEntityMixin.php │ ├── IncorrectEntityClassException.php │ ├── InvalidEventException.php │ ├── Repository.php │ └── VerifyEventIsAClassTrait.php └── EventStore │ ├── DBALEventStore │ ├── DBALEventStore.php │ └── TableAlreadyExistsException.php │ ├── EventPublisherMixin.php │ ├── EventStore.php │ ├── EventSubscriber.php │ ├── InMemoryEventStore │ ├── InMemoryEventStore.php │ └── TransactionAlreadyInProgressException.php │ ├── NoEventsFoundForKeyException.php │ ├── SerialNumberIntegrityValidator │ └── SerialNumberIntegrityValidator.php │ ├── Serializer.php │ ├── Symfony2EventDispatcherSubscriber │ ├── EventDispatcherEvent.php │ └── Symfony2EventDispatcherSubscriber.php │ └── VersionMismatchException.php └── usr └── share └── doc └── example ├── Event.php ├── EventCreatedWithName.php ├── User.php ├── UserCreatedWithUsername.php ├── UserDrankABeer.php └── run-me.php /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr2 2 | linting: true 3 | finder: 4 | path: 5 | - "src" 6 | name: 7 | - "*.php" 8 | 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.5 2 | * Supporting Symfony3 3 | ## 0.1.4 4 | * Repositories now verify that the EventSourcedEntity that is to be saved 5 | is the same version that is available in the event store. 6 | * Added ability to register subscribers to the event store to monitor 7 | various event hooks: Transaction started and completed, Event pre and post stored 8 | * Comprehensive test suite that runs against php 5.5, 5.6, 7.0 and MySQL & PostgreSQL 9 | 10 | ## 0.1.2 11 | * Added an example entity using EventSourcedEntityMixin 12 | 13 | ## 0.1.1 14 | * Initial release (Need to begin somewhere!) 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We accept contributions via Pull Requests on [Github](https://github.com/rawkode/eidetic). 4 | 5 | ## 6 | ## Pull Requests 7 | 8 | - Comply with **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** 9 | - [Always leave the campground cleaner than when you found it](http://programmer.97things.oreilly.com/wiki/index.php/The_Boy_Scout_Rule) 10 | - Add examples to [`usr/share/doc/examples`](usr/share/doc/examples) and document any changes in [`CHANGELOG.md`](CHANGELOG.md) and [`README.md`](README.md) 11 | - Small pull-requests - let's keep the merges simple: one feature per pull-request 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 David McKay 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eidetic 2 | 3 | **Warning: Unlikely to be updated anytime soon due to time restraints** 4 | 5 | [![Software License](https://img.shields.io/github/license/rawkode/eidetic.svg?style=flat-square)](LICENSE) 6 | [![Latest Version](https://img.shields.io/packagist/v/rawkode/eidetic.svg?style=flat-square)](https://packagist.org/packages/rawkode/eidetic) 7 | [![Build Status](https://img.shields.io/travis/rawkode/eidetic/master.svg?style=flat-square)](https://travis-ci.org/rawkode/eidetic) 8 | [![Quality Score](https://img.shields.io/scrutinizer/g/rawkode/eidetic.svg?style=flat-square)](https://scrutinizer-ci.com/g/rawkode/eidetic) 9 | 10 | [![SensioLabsInsight](https://insight.sensiolabs.com/projects/16900797-b872-44bf-8a20-b5e13080e9f0/small.png)](https://insight.sensiolabs.com/projects/16900797-b872-44bf-8a20-b5e13080e9f0) 11 | 12 | --- 13 | Eidetic is a CQRS and EventSourcing library for php >= 5.5 14 | 15 | #### Extremely Alpha 16 | **Please do not use this library for anything important - it's purely for fun** 17 | 18 | ## Why not Broadway? 19 | Yes - I've seen Broadway and it's a fantastic package, but it wasn't for me. 20 | 21 | * I should be able to use an EventStore / EventSourcing without committing to DDD (Not all projects suit!) 22 | * Even if it's just avoiding the vocabulary 23 | * I don't always want to use the Aggregate pattern 24 | * I prefer composition over inheritance: 25 | * I don't really want to use inheritance for my entities 26 | * I **really** don't want to use inheritance for my events 27 | 28 | This package should allow people to dip their toe in the waters and allow them to consider if using reactive / event based systems will work for them; even if that's simply setting up an EventStore to provide a basic audit trail for a legacy application. Take it slow, get your feet wet - then dive right in! :) 29 | 30 | ## Status 31 | Eidetic is currently under initial development. The aim is to provide helpers that allow you to implement CQRS and EventSourcing in your application. 32 | 33 | - CQRS 34 | - Write model repositories 35 | - Event Store 36 | 37 | 38 | - Event Stores 39 | - InMemory 40 | - Doctrine DBAL 41 | 42 | - Event Subscribers 43 | - Symfony2 Event Dispatcher 44 | 45 | 46 | ## Examples 47 | Examples can be found inside [`usr/share/doc/example`](usr/share/doc/example) 48 | 49 | ## Installation 50 | ```composer require rawkode/eidetic``` 51 | 52 | Sorry! As this is extremely experimental at the moment, please use ```dev-master```. 53 | 54 | ## Tests 55 | 56 | ### Testing with local version of php 57 | ~~~ 58 | bin/phpunit 59 | bin/phpspec run --format=pretty 60 | ~~~ 61 | 62 | ### Testing with Docker 63 | ~~~ 64 | docker-compose up testing-php-5.5 65 | docker-compose up testing-php-5.6 66 | docker-compose up testing-php-7.0 67 | ~~~ 68 | 69 | ### Extra Testing? 70 | ~~~ 71 | docker-compose up testing-database-mysql 72 | docker-compose up testing-database-postgres 73 | ~~~ 74 | 75 | If you're having problems with these tests, it's because we can't tell Docker Compose that we need the database servers up and running before running our test application and you might be subject to the race condition. Until Docker Compose has a solution for this, simply boot the database first: 76 | ~~~ 77 | docker-compose up -d mysql 78 | docker-compose up -d postgres 79 | ~~~ 80 | 81 | ## Contributing 82 | 83 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 84 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rawkode/eidetic", 3 | "description": "CQRS and EventSourcing package for php >= 5.5", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "David McKay", 9 | "email": "david@rawkode.com" 10 | } 11 | ], 12 | "keywords": [ 13 | "CQRS", 14 | "EventSourcing", 15 | "EventStore" 16 | ], 17 | "autoload": { 18 | "psr-4": { 19 | "Rawkode\\Eidetic\\": "src//" 20 | } 21 | }, 22 | "autoload-dev": { 23 | "psr-4": { 24 | "Example\\": "usr//share//doc//example//" 25 | } 26 | }, 27 | "config": { 28 | "bin-dir": "bin", 29 | "vendor-dir": "var/vendor" 30 | }, 31 | "require": { 32 | "php": ">=5.5", 33 | "doctrine/dbal": "^2.5", 34 | "symfony/event-dispatcher": "^2.6|^3.0" 35 | }, 36 | "require-dev": { 37 | "phpspec/phpspec": "^2.1", 38 | "phpunit/phpunit": "^4.3", 39 | "fabpot/php-cs-fixer": "^1.10" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | testing-php-5.5: 2 | image: php:5.5 3 | working_dir: /opt/eidetic 4 | entrypoint: /opt/eidetic/bin/run-tests.sh 5 | environment: 6 | - DATABASE_DRIVER=pdo_sqlite 7 | volumes: 8 | - ./:/opt/eidetic 9 | 10 | testing-php-5.6: 11 | extends: 12 | service: testing-php-5.5 13 | image: php:5.6 14 | 15 | testing-php-7.0: 16 | extends: 17 | service: testing-php-5.5 18 | image: php:7.0 19 | 20 | testing-database-pdo: 21 | extends: 22 | service: testing-php-5.5 23 | environment: 24 | - DATABASE_USER=root 25 | - DATABASE_PASS=testing 26 | - DATABASE_NAME=testing 27 | 28 | testing-database-mysql: 29 | extends: 30 | service: testing-database-pdo 31 | links: 32 | - mysql:mysql 33 | environment: 34 | - DATABASE_DRIVER=pdo_mysql 35 | - DATABASE_HOST=mysql 36 | - DATABASE_PORT=3306 37 | 38 | testing-database-postgres: 39 | extends: 40 | service: testing-database-pdo 41 | links: 42 | - postgres:postgres 43 | environment: 44 | - DATABASE_DRIVER=pdo_pgsql 45 | - DATABASE_HOST=postgres 46 | - DATABASE_PORT=5432 47 | - DATABASE_USER=postgres 48 | - DATABASE_NAME=postgres 49 | 50 | mysql: 51 | image: mysql:5 52 | environment: 53 | - MYSQL_ROOT_PASSWORD=testing 54 | - MYSQL_DATABASE=testing 55 | 56 | postgres: 57 | image: postgres:9 58 | environment: 59 | - POSTGRES_PASSWORD=testing 60 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | test/phpunit 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/CQRS/ReadModelRepository.php: -------------------------------------------------------------------------------- 1 | identifier; 30 | } 31 | 32 | /** 33 | * @return int 34 | */ 35 | public function version() 36 | { 37 | return $this->version; 38 | } 39 | 40 | /** 41 | * @param array $eventStream 42 | */ 43 | public static function initialise(array $eventStream) 44 | { 45 | $entity = new static(); 46 | 47 | foreach ($eventStream as $event) { 48 | $entity->applyEvent($event); 49 | } 50 | 51 | $entity->commit(); 52 | 53 | return $entity; 54 | } 55 | 56 | /** 57 | * @return array 58 | */ 59 | public function stagedEvents() 60 | { 61 | return $this->stagedEvents; 62 | } 63 | 64 | /** 65 | */ 66 | public function commit() 67 | { 68 | $this->version += count($this->stagedEvents); 69 | $this->stagedEvents = []; 70 | } 71 | 72 | /** 73 | * @param object $event 74 | * 75 | * @throws InvalidEventException 76 | */ 77 | private function applyEvent($event) 78 | { 79 | $this->verifyEventIsAClass($event); 80 | 81 | $applyMethod = $this->findEventHandler($event); 82 | 83 | array_push($this->stagedEvents, $event); 84 | 85 | $this->$applyMethod($event); 86 | } 87 | 88 | /** 89 | * @param object $event 90 | * 91 | * @throws EventHandlerDoesNotExist 92 | * 93 | * @return string 94 | */ 95 | private function findEventHandler($event) 96 | { 97 | $class = get_class($event); 98 | $explode = explode('\\', $class); 99 | $applyMethod = 'apply'.end($explode); 100 | 101 | if (!method_exists($this, $applyMethod)) { 102 | throw new EventHandlerDoesNotExistException("Couldn't find event handler for '{$applyMethod}'"); 103 | } 104 | 105 | return $applyMethod; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/EventSourcing/IncorrectEntityClassException.php: -------------------------------------------------------------------------------- 1 | entityClass = $class; 27 | $this->eventStore = $eventStore; 28 | } 29 | 30 | /** 31 | * @param $class 32 | * @param EventStore $eventStore 33 | * 34 | * @return Repository 35 | */ 36 | public static function createForWrites($class, EventStore $eventStore) 37 | { 38 | return new self($class, $eventStore); 39 | } 40 | 41 | /** 42 | * @param string $entityIdentifier 43 | * 44 | * @return mixed 45 | */ 46 | public function load($entityIdentifier) 47 | { 48 | $this->enforceTypeConstraint($this->eventStore->entityClass($entityIdentifier)); 49 | 50 | $events = $this->eventStore->retrieve($entityIdentifier); 51 | 52 | return call_user_func(array($this->entityClass, 'initialise'), $events); 53 | } 54 | 55 | /** 56 | * @param EventSourcedEntity $eventSourcedEntity 57 | * 58 | * @throws \Exception 59 | */ 60 | public function save(EventSourcedEntity $eventSourcedEntity) 61 | { 62 | $this->enforceTypeConstraint(get_class($eventSourcedEntity)); 63 | 64 | $this->enforceVersionMismatchConstraint($eventSourcedEntity); 65 | 66 | $this->eventStore->store($eventSourcedEntity); 67 | } 68 | 69 | /** 70 | * @param string $class 71 | * 72 | * @throws IncorrectEntityClassException 73 | */ 74 | private function enforceTypeConstraint($class) 75 | { 76 | if ($this->entityClass !== $class) { 77 | throw new IncorrectEntityClassException(); 78 | } 79 | } 80 | 81 | /** 82 | * @param EventSourcedEntity $eventSourcedEntity 83 | * 84 | * @throws VersionMismatchException 85 | */ 86 | private function enforceVersionMismatchConstraint(EventSourcedEntity $eventSourcedEntity) 87 | { 88 | /** @var EventSourcedEntity $databaseVersion */ 89 | $databaseVersion = $this->load($eventSourcedEntity->identifier()); 90 | 91 | if ($databaseVersion->version() !== $eventSourcedEntity->version()) { 92 | throw new VersionMismatchException('Local entity is at version ' 93 | .$eventSourcedEntity->version().' and database is at '.$databaseVersion->version()); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/EventSourcing/VerifyEventIsAClassTrait.php: -------------------------------------------------------------------------------- 1 | tableName = $tableName; 31 | $this->connection = $connection; 32 | } 33 | 34 | /** 35 | * @param string $tableName 36 | * @param array $options 37 | * 38 | * @return static 39 | */ 40 | public static function createWithOptions($tableName, array $options) 41 | { 42 | $connection = DriverManager::getConnection($options); 43 | 44 | return new static($tableName, $connection); 45 | } 46 | 47 | /** 48 | * @param string $tableName 49 | * @param Connection $connection 50 | * 51 | * @return static 52 | */ 53 | public static function createWithConnection($tableName, Connection $connection) 54 | { 55 | return new static($tableName, $connection); 56 | } 57 | 58 | /** 59 | * @param EventSourcedEntity $eventSourcedEntity 60 | */ 61 | protected function persist(EventSourcedEntity $eventSourcedEntity, $event) 62 | { 63 | $eventCount = $this->countEntityEvents($eventSourcedEntity->identifier()); 64 | 65 | $this->connection->insert($this->tableName, [ 66 | 'entity_identifier' => $eventSourcedEntity->identifier(), 67 | 'serial_number' => ++$eventCount, 68 | 'entity_class' => get_class($eventSourcedEntity), 69 | 'recorded_at' => new \DateTime('now', new \DateTimeZone('UTC')), 70 | 'event_class' => get_class($event), 71 | 'event' => $this->serialize($event), 72 | ], [ 73 | \PDO::PARAM_STR, 74 | \PDO::PARAM_INT, 75 | \PDO::PARAM_STR, 76 | 'datetime', 77 | \PDO::PARAM_STR, 78 | \PDO::PARAM_STR, 79 | ]); 80 | 81 | array_push($this->stagedEvents, $event); 82 | } 83 | 84 | /** 85 | * @param string $entityIdentifier 86 | * 87 | * @throws NoEventsFoundForKeyException 88 | * 89 | * @return array 90 | */ 91 | protected function eventLog($entityIdentifier) 92 | { 93 | if (0 === $this->countEntityEvents($entityIdentifier)) { 94 | throw new NoEventsFoundForKeyException(); 95 | } 96 | 97 | $statement = $this->eventLogQuery($entityIdentifier)->execute(); 98 | 99 | $eventLog = $statement->fetchAll(); 100 | 101 | return array_map(function ($eventLogEntry) { 102 | $eventLogEntry['event'] = $this->unserialize($eventLogEntry['event']); 103 | $eventLogEntry['recorded_at'] = new \DateTime($eventLogEntry['recorded_at']); 104 | 105 | return $eventLogEntry; 106 | }, $eventLog); 107 | } 108 | 109 | /** 110 | */ 111 | protected function startTransaction(EventSourcedEntity $eventSourcedEntity) 112 | { 113 | $this->connection->beginTransaction(); 114 | 115 | $this->stagedEvents = []; 116 | } 117 | 118 | /** 119 | */ 120 | protected function abortTransaction(EventSourcedEntity $eventSourcedEntity) 121 | { 122 | $this->connection->rollBack(); 123 | $this->stagedEvents = []; 124 | } 125 | 126 | /** 127 | */ 128 | protected function completeTransaction(EventSourcedEntity $eventSourcedEntity) 129 | { 130 | $this->connection->commit(); 131 | 132 | $this->stagedEvents = []; 133 | } 134 | 135 | /** 136 | */ 137 | public function createTable() 138 | { 139 | $schemaManager = $this->connection->getSchemaManager(); 140 | $schema = $schemaManager->createSchema(); 141 | 142 | if ($schema->hasTable($this->tableName)) { 143 | throw new TableAlreadyExistsException(); 144 | } 145 | 146 | $table = $schema->createTable($this->tableName); 147 | 148 | $table->addColumn('entity_identifier', 'string', ['length' => 255]); 149 | $table->addColumn('serial_number', 'integer'); 150 | 151 | $table->setPrimaryKey(['entity_identifier', 'serial_number']); 152 | 153 | $table->addColumn('entity_class', 'string', ['length' => 255]); 154 | $table->addColumn('recorded_at', 'datetime'); 155 | $table->addColumn('event_class', 'string', ['length' => 255]); 156 | $table->addColumn('event', 'text'); 157 | 158 | $table->addIndex(['entity_class']); 159 | $table->addIndex(['recorded_at']); 160 | $table->addIndex(['event_class']); 161 | 162 | $schemaManager->createTable($table); 163 | } 164 | 165 | /** 166 | */ 167 | public function dropTable() 168 | { 169 | $this->connection->getSchemaManager()->dropTable($this->tableName); 170 | } 171 | 172 | /** 173 | * @param string $entityIdentifier 174 | * 175 | * @return int 176 | */ 177 | protected function countEntityEvents($entityIdentifier) 178 | { 179 | /* @var QueryBuilder $queryBuilder */ 180 | $queryBuilder = $this->connection->createQueryBuilder(); 181 | 182 | $queryBuilder->select('COUNT(entity_identifier)'); 183 | $queryBuilder->from($this->tableName); 184 | $queryBuilder->where('entity_identifier = :entity_identifier'); 185 | 186 | $queryBuilder->setParameter('entity_identifier', $entityIdentifier); 187 | 188 | return (int) $queryBuilder->execute()->fetchColumn(0); 189 | } 190 | 191 | /** 192 | * @param string $entityIdentifier 193 | * 194 | * @return \Doctrine\DBAL\Query\QueryBuilder 195 | */ 196 | protected function eventLogQuery($entityIdentifier) 197 | { 198 | /* @var QueryBuilder $queryBuilder */ 199 | $queryBuilder = $this->connection->createQueryBuilder(); 200 | 201 | $queryBuilder->select('*'); 202 | $queryBuilder->from($this->tableName); 203 | $queryBuilder->where('entity_identifier = :entity_identifier'); 204 | $queryBuilder->orderBy('serial_number', 'ASC'); 205 | 206 | $queryBuilder->setParameter('entity_identifier', $entityIdentifier); 207 | 208 | return $queryBuilder; 209 | } 210 | 211 | /** 212 | * @param string $entityIdentifier 213 | * 214 | * @throws NoEventsFoundForKeyException 215 | * 216 | * @return string 217 | */ 218 | public function entityClass($entityIdentifier) 219 | { 220 | $this->verifyEventExistsForKey($entityIdentifier); 221 | 222 | /* @var QueryBuilder $queryBuilder */ 223 | $queryBuilder = $this->connection->createQueryBuilder(); 224 | 225 | $queryBuilder->select('entity_class'); 226 | $queryBuilder->from($this->tableName); 227 | $queryBuilder->where('entity_identifier = :entity_identifier'); 228 | $queryBuilder->orderBy('serial_number', 'ASC'); 229 | $queryBuilder->setMaxResults(1); 230 | 231 | $queryBuilder->setParameter('entity_identifier', $entityIdentifier); 232 | 233 | return $queryBuilder->execute()->fetchColumn(0); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/EventStore/DBALEventStore/TableAlreadyExistsException.php: -------------------------------------------------------------------------------- 1 | subscribers, $subscriber); 20 | } 21 | 22 | /** 23 | * @param string $eventHook 24 | * @param EventSourcedEntity $eventSourcedEntity 25 | */ 26 | public function publishAll($eventHook, EventSourcedEntity $eventSourcedEntity) 27 | { 28 | foreach ($eventSourcedEntity->stagedEvents() as $event) { 29 | $this->publish($eventHook, $eventSourcedEntity, $event); 30 | } 31 | } 32 | 33 | /** 34 | * @param int $eventHook 35 | * @param EventSourcedEntity $eventSourcedEntity 36 | * @param object $event 37 | */ 38 | public function publish($eventHook, EventSourcedEntity $eventSourcedEntity, $event) 39 | { 40 | /** @var Subscriber $subscriber */ 41 | foreach ($this->subscribers as $subscriber) { 42 | $subscriber->handle($eventHook, $eventSourcedEntity, $event); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/EventStore/EventStore.php: -------------------------------------------------------------------------------- 1 | startTransaction($eventSourcedEntity); 81 | $this->publishAll(self::TRANSACTION_STARTED, $eventSourcedEntity); 82 | 83 | $this->enforceEventIntegrity($eventSourcedEntity); 84 | 85 | foreach ($eventSourcedEntity->stagedEvents() as $event) { 86 | $this->publishAll(self::EVENT_PRE_STORE, $eventSourcedEntity); 87 | $this->persist($eventSourcedEntity, $event); 88 | $this->publishAll(self::EVENT_STORED, $eventSourcedEntity); 89 | } 90 | } catch (\Exception $exception) { 91 | $this->abortTransaction($eventSourcedEntity); 92 | throw $exception; 93 | } 94 | 95 | $this->completeTransaction($eventSourcedEntity); 96 | $this->publishAll(self::TRANSACTION_COMPLETED, $eventSourcedEntity); 97 | } 98 | 99 | /** 100 | * @param EventSourcedEntity $eventSourcedEntity [description] 101 | * 102 | * @throws InvalidEventException 103 | */ 104 | private function enforceEventIntegrity(EventSourcedEntity $eventSourcedEntity) 105 | { 106 | foreach ($eventSourcedEntity->stagedEvents() as $event) { 107 | $this->verifyEventIsAClass($event); 108 | } 109 | } 110 | 111 | /** 112 | * Returns all events for $entityIdentifier. 113 | * 114 | * @param string $entityIdentifier 115 | * 116 | * @return array 117 | */ 118 | public function retrieve($entityIdentifier) 119 | { 120 | $eventLog = $this->eventLog($entityIdentifier); 121 | 122 | return array_map(function ($eventLogEntry) { 123 | return $eventLogEntry['event']; 124 | }, $eventLog); 125 | } 126 | 127 | /** 128 | * Returns all the log entries for $entityIdentifier. 129 | * 130 | * @param string $entityIdentifier 131 | * 132 | * @return array 133 | */ 134 | public function retrieveLog($entityIdentifier) 135 | { 136 | return $this->eventLog($entityIdentifier); 137 | } 138 | 139 | /** 140 | * @param string $entityIdentifier 141 | * 142 | * @throws NoEventsFoundForKeyException 143 | */ 144 | protected function verifyEventExistsForKey($entityIdentifier) 145 | { 146 | if (0 === $this->countEntityEvents($entityIdentifier)) { 147 | throw new NoEventsFoundForKeyException(); 148 | } 149 | } 150 | 151 | /** 152 | * @param object $object 153 | * 154 | * @return string 155 | */ 156 | public function serialize($object) 157 | { 158 | if (false === is_null($this->serializer)) { 159 | return $this->serializer->serialize($object); 160 | } 161 | 162 | return base64_encode(serialize($object)); 163 | } 164 | 165 | /** 166 | * @param string $serializedObject 167 | * 168 | * @return object 169 | */ 170 | public function unserialize($serializedObject) 171 | { 172 | if (false === is_null($this->serializer)) { 173 | return $this->serializer->serialize($serializedObject); 174 | } 175 | 176 | return unserialize(base64_decode($serializedObject)); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/EventStore/EventSubscriber.php: -------------------------------------------------------------------------------- 1 | identifier(), $this->events)) { 30 | $this->events[$eventSourcedEntity->identifier()] = []; 31 | } 32 | 33 | $this->events[$eventSourcedEntity->identifier()][] = [ 34 | 'entity_identifier' => $eventSourcedEntity->identifier(), 35 | 'serial_number' => count($this->events[$eventSourcedEntity->identifier()]) + 1, 36 | 'entity_class' => get_class($eventSourcedEntity), 37 | 'recorded_at' => new \DateTime('now', new \DateTimeZone('UTC')), 38 | 'event_class' => get_class($event), 39 | 'event' => $this->serialize($event), 40 | ]; 41 | 42 | array_push($this->stagedEvents, $event); 43 | } 44 | 45 | /** 46 | * @param string $entityIdentifier 47 | * 48 | * @throws NoEventsFoundForKeyException 49 | * 50 | * @return array 51 | */ 52 | protected function eventLog($entityIdentifier) 53 | { 54 | $this->verifyEventExistsForKey($entityIdentifier); 55 | 56 | return array_map(function ($eventLogEntry) { 57 | $eventLogEntry['event'] = $this->unserialize($eventLogEntry['event']); 58 | 59 | return $eventLogEntry; 60 | }, $this->events[$entityIdentifier]); 61 | } 62 | 63 | /** 64 | */ 65 | protected function startTransaction(EventSourcedEntity $eventSourcedEntity) 66 | { 67 | $this->transactionBackup = $this->events; 68 | 69 | $this->stagedEvents = []; 70 | } 71 | 72 | /** 73 | */ 74 | protected function abortTransaction(EventSourcedEntity $eventSourcedEntity) 75 | { 76 | $this->events = $this->transactionBackup; 77 | $this->stagedEvents = []; 78 | } 79 | 80 | /** 81 | */ 82 | protected function completeTransaction(EventSourcedEntity $eventSourcedEntity) 83 | { 84 | $this->transactionBackup = []; 85 | 86 | $this->stagedEvents = []; 87 | } 88 | 89 | /** 90 | * @param string $entityIdentifier 91 | * 92 | * @throws NoEventsFoundForKeyException 93 | * 94 | * @return int 95 | */ 96 | protected function countEntityEvents($entityIdentifier) 97 | { 98 | if (false === array_key_exists($entityIdentifier, $this->events)) { 99 | return 0; 100 | } 101 | 102 | return count($this->events[$entityIdentifier]); 103 | } 104 | 105 | /** 106 | * @param string $entityIdentifier 107 | * 108 | * @throws NoEventsFoundForKeyException 109 | * 110 | * @return string 111 | */ 112 | public function entityClass($entityIdentifier) 113 | { 114 | $this->verifyEventExistsForKey($entityIdentifier); 115 | 116 | return $this->events[$entityIdentifier][0]['entity_class']; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/EventStore/InMemoryEventStore/TransactionAlreadyInProgressException.php: -------------------------------------------------------------------------------- 1 | entity = $eventSourcedEntity; 22 | $this->event = $event; 23 | } 24 | 25 | /** 26 | * @return EventSourcedEntity 27 | */ 28 | public function entity() 29 | { 30 | return $this->entity; 31 | } 32 | 33 | /** 34 | * @return object 35 | */ 36 | public function event() 37 | { 38 | return $this->event; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/EventStore/Symfony2EventDispatcherSubscriber/Symfony2EventDispatcherSubscriber.php: -------------------------------------------------------------------------------- 1 | eventDispatcher = $eventDispatcher; 21 | } 22 | 23 | /** 24 | * @param int $eventHook 25 | * @param EventSourcedEntity $eventSourcedEntity 26 | * @param object $event 27 | */ 28 | public function handle($eventHook, EventSourcedEntity $eventSourcedEntity, $event) 29 | { 30 | $this->eventDispatcher->dispatch($eventHook, new EventDispatcherEvent($eventSourcedEntity, $event)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/EventStore/VersionMismatchException.php: -------------------------------------------------------------------------------- 1 | identifier = uniqid('event-'); 21 | } 22 | 23 | /** 24 | * @param string $username 25 | * 26 | * @return User 27 | */ 28 | public static function createWithName($name) 29 | { 30 | $event = new static(); 31 | $event->applyEvent(new EventCreatedWithName($name)); 32 | 33 | return $event; 34 | } 35 | 36 | /** 37 | * @param UserCreatedWithUsername $userCreatedWithUsername 38 | */ 39 | private function applyEventCreatedWithName(EventCreatedWithName $eventCreatedWithName) 40 | { 41 | $this->name = $eventCreatedWithName->name(); 42 | } 43 | 44 | /** 45 | * @return string 46 | */ 47 | public function identifier() 48 | { 49 | return $this->identifier; 50 | } 51 | 52 | /** 53 | * @return string 54 | */ 55 | public function name() 56 | { 57 | return $this->name; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /usr/share/doc/example/EventCreatedWithName.php: -------------------------------------------------------------------------------- 1 | name = $name; 16 | } 17 | 18 | /** 19 | * @return string 20 | */ 21 | public function name() 22 | { 23 | return $this->name; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /usr/share/doc/example/User.php: -------------------------------------------------------------------------------- 1 | identifier = uniqid('user-'); 24 | } 25 | 26 | /** 27 | * @param string $username 28 | * 29 | * @return User 30 | */ 31 | public static function createWithUsername($username) 32 | { 33 | $user = new self(); 34 | $user->applyEvent(new UserCreatedWithUsername($username)); 35 | 36 | return $user; 37 | } 38 | 39 | /** 40 | * @param UserCreatedWithUsername $userCreatedWithUsername 41 | */ 42 | private function applyUserCreatedWithUsername(UserCreatedWithUsername $userCreatedWithUsername) 43 | { 44 | $this->username = $userCreatedWithUsername->username(); 45 | } 46 | 47 | /** 48 | */ 49 | public function drinkBeer() 50 | { 51 | $this->applyEvent(new UserDrankABeer()); 52 | } 53 | 54 | /** 55 | */ 56 | private function applyUserDrankABeer() 57 | { 58 | $this->balance -= 1; 59 | } 60 | 61 | /** 62 | */ 63 | public function adoptDog() 64 | { 65 | $adoptDog = 'not implemented yet'; 66 | $this->applyEvent($adoptDog); 67 | } 68 | 69 | /** 70 | * @return string 71 | */ 72 | public function identifier() 73 | { 74 | return $this->identifier; 75 | } 76 | 77 | /** 78 | * @return string 79 | */ 80 | public function username() 81 | { 82 | return $this->username; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /usr/share/doc/example/UserCreatedWithUsername.php: -------------------------------------------------------------------------------- 1 | username = $username; 16 | } 17 | 18 | /** 19 | * @return string 20 | */ 21 | public function username() 22 | { 23 | return $this->username; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /usr/share/doc/example/UserDrankABeer.php: -------------------------------------------------------------------------------- 1 | addListener(EventStore::EVENT_STORED, 'thisIsMyListener'); 17 | 18 | // Event Dispatcher Listener ... ish 19 | function thisIsMyListener(Event $event) 20 | { 21 | echo 'Hello, I am the Symfony2 Event Dispatcher Listener!'.PHP_EOL; 22 | var_dump($event->event()); 23 | } 24 | 25 | // Now create our integration class and pass in the dispatcher 26 | $symfony2EventDispatcherSubscriber = new symfony2EventDispatcherSubscriber($symfony2EventDispatcher); 27 | 28 | // We need an EventStore. We'll use the DBAL with in-memory sqlite 29 | $eventStore = DBALEventStore::createWithOptions('events', [ 30 | 'driver' => 'pdo_sqlite', 31 | 'memory' => true, 32 | ]); 33 | 34 | // Register :D 35 | $eventStore->registerSubscriber($symfony2EventDispatcherSubscriber); 36 | 37 | // Create the table we need 38 | $eventStore->createTable(); 39 | 40 | // Initialise a repository with this event store 41 | $userRepository = Repository::createForWrites('Example\User', $eventStore); 42 | 43 | // Create a user 44 | $user = User::createWithUsername('David'); 45 | 46 | // We can output the users username and there's no sign of an event 47 | // event anywhere! Nifty 48 | echo "Hello, {$user->username()}!".PHP_EOL; 49 | 50 | // We can even save this user, still no mention of an event 51 | $userRepository->save($user); 52 | 53 | // Lets backup the identifier so that we can discard and reload 54 | $userIdentifier = $user->identifier(); 55 | unset($user); 56 | 57 | echo 'The user object has now been unset. Lets load through our repository!'.PHP_EOL; 58 | 59 | // Load the user from the EventStore, using our repository 60 | $user = $userRepository->load($userIdentifier); 61 | 62 | // Viola! 63 | echo "Hello, {$user->username()}!".PHP_EOL; 64 | var_dump($user); 65 | 66 | // What about identifiers that don't exist? 67 | try { 68 | $userRepository->load('random'); 69 | } catch (NoEventsFoundForKeyException $noEventsFoundForKeyException) { 70 | echo "Sorry, can't find any events for this entity.".PHP_EOL; 71 | echo 'You should probably put me in your repository and throw a more domain specific exception'.PHP_EOL; 72 | } 73 | --------------------------------------------------------------------------------