diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f4acd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock \ No newline at end of file diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..e2e6c1f --- /dev/null +++ b/.php_cs @@ -0,0 +1,34 @@ +in(['src', 'tests']); +$header = <<< EOF +This file is part of LaravelCqrsEs + +(c) Madewithlove + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +EOF; + +HeaderCommentFixer::setHeader($header); + +return Config::create() + ->level(FixerInterface::SYMFONY_LEVEL) + ->fixers([ + 'ereg_to_preg', + 'header_comment', + 'multiline_spaces_before_semicolon', + 'ordered_use', + 'php4_constructor', + 'phpdoc_order', + 'short_array_syntax', + 'short_echo_tag', + 'strict', + 'strict_param', + ]) + ->setUsingCache(true) + ->finder($finder); \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b393396 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +# Use Docker environment +sudo: false + +# Setup build matrix +language: php +php: + - 5.6 + - 7 + +env: + matrix: + - PREFER_LOWEST="--prefer-lowest" + - PREFER_LOWEST="" + +# Dependencies +before_install: + - composer self-update + +install: + - travis_retry composer update --no-interaction --prefer-source --dev $PREFER_LOWEST + +script: composer test \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..28b1e86 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2015 Madewithlove + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 1c2f210..2c52cdf 100644 --- a/README.md +++ b/README.md @@ -1 +1,22 @@ # laravel-cqrs-es + +A package to kickstart your CQRS/ES development in Laravel using the Broadway event store. + +## Installation + +``` +$ composer require madewithlove/laravel-cqrs-es +``` + +## Configuration + +Add the service provider to config/app.php: + +``` +Madewithlove\LaravelCqrsEs\ServiceProvider::class +``` + + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..efc1a61 --- /dev/null +++ b/composer.json @@ -0,0 +1,42 @@ +{ + "name": "madewithlove/laravel-cqrs-es", + "description": "A Laravel package to kick start CQRS\ES projects using Broadway and Tactician.", + "license": "MIT", + "keywords": [ + "laravel", + "broadway", + "event sourcing", + "ES", + "CQRS", + "Domain Driven Design", + "DDD" + ], + "authors": [ + { + "name": "Madewithlove", + "email": "heroes@madewithlove.be" + } + ], + "require": { + "php": ">=5.6.0", + "doctrine/dbal": "^2.5", + "broadway/broadway": "^0.9", + "illuminate/console": "^5.1", + "illuminate/database": "^5.1", + "illuminate/support": "^5.1", + }, + "require-dev": { + "fabpot/php-cs-fixer": "2.0.*@dev", + "phpunit/phpunit": "^4.7" + }, + "autoload": { + "psr-4": { + "Madewithlove\\LaravelCqrsEs\\": "src/" + } + }, + "suggest": { + "madewithlove/tactician-laravel": "Laravel wrapper for a configurable command bus", + }, + "minimum-stability": "dev", + "prefer-stable": true +} \ No newline at end of file diff --git a/config/broadway.php b/config/broadway.php new file mode 100644 index 0000000..62d192e --- /dev/null +++ b/config/broadway.php @@ -0,0 +1,20 @@ + [ + 'driver' => 'dbal', + 'dbal' => [ + 'connection' => 'mysql', + 'table' => 'event_store', + ], + ], +]; diff --git a/database/migrations/2016_04_22_151617_init_event_store.php b/database/migrations/2016_04_22_151617_init_event_store.php new file mode 100644 index 0000000..89c6800 --- /dev/null +++ b/database/migrations/2016_04_22_151617_init_event_store.php @@ -0,0 +1,40 @@ +table = Config::get('broadway.event-store.dbal.table', 'event_store'); + } + + public function up() + { + Schema::create($this->table, function (Blueprint $table) { + $table->increments('id'); + + $table->string('uuid', 36); + $table->integer('playhead')->unsigned(); + $table->text('metadata'); + $table->text('payload'); + $table->string('recorded_on', 32); + $table->text('type'); + + $table->unique(['uuid', 'playhead']); + }); + } + + public function down() + { + Schema::drop($this->table); + } +} \ No newline at end of file diff --git a/src/EventStore/Console/Replay.php b/src/EventStore/Console/Replay.php new file mode 100644 index 0000000..f8f8247 --- /dev/null +++ b/src/EventStore/Console/Replay.php @@ -0,0 +1,62 @@ +replayService = $replayService; + $this->container = $container; + } + + /** + * @return void + */ + public function handle() + { + $params = []; + + if ($id = $this->argument('id')) { + $params['id'] = explode(',', $id); + } + + if ($types = $this->option('types')) { + $params['types'] = explode(',', $types); + } + + $this->replayService->replay($params); + } +} \ No newline at end of file diff --git a/src/EventStore/EventStoreManager.php b/src/EventStore/EventStoreManager.php new file mode 100644 index 0000000..cbd10a5 --- /dev/null +++ b/src/EventStore/EventStoreManager.php @@ -0,0 +1,56 @@ +app['config']->get('broadway.event-store.driver'); + } + + /** + * @return DBALEventStore + */ + protected function createDbalDriver() + { + $config = $this->app['config']->get('broadway.event-store.dbal'); + $driver = $config['connection']; + + $params = $this->app['config']->get("database.connections.{$driver}"); + $params['dbname'] = $params['database']; + $params['user'] = $params['username']; + $params['driver'] = "pdo_$driver"; + + unset($params['database'], $params['username']); + + $connection = DriverManager::getConnection($params); + + + return new DBALEventStore( + $connection, + $this->app->make(SerializerInterface::class), + $this->app->make(SerializerInterface::class), + array_get($config, 'table', 'event_store') + ); + } + + /** + * @return InMemoryEventStore + */ + protected function createInMemory() + { + return new InMemoryEventStore(); + } +} \ No newline at end of file diff --git a/src/EventStore/ServiceProvider.php b/src/EventStore/ServiceProvider.php new file mode 100644 index 0000000..bcc1de3 --- /dev/null +++ b/src/EventStore/ServiceProvider.php @@ -0,0 +1,43 @@ +commands([ + Replay::class, + ]); + + $this->app->singleton('event_store.driver', function () { + return (new EventStoreManager($this->app))->driver(); + }); + + $this->app->alias(EventStoreInterface::class, 'event_store.driver'); + $this->app->alias(EventStoreManagementInterface::class, 'event_store.driver'); + } + + /** + * @return array + */ + public function provides() + { + return [ + EventStoreManagementInterface::class, + EventStoreInterface::class, + ]; + } +} \ No newline at end of file diff --git a/src/EventStore/Services/Replay.php b/src/EventStore/Services/Replay.php new file mode 100644 index 0000000..8ad4ba1 --- /dev/null +++ b/src/EventStore/Services/Replay.php @@ -0,0 +1,86 @@ +eventBus = $eventBus; + $this->eventManager = $eventManager; + } + + /** + * @param array $parameters + */ + public function replay($parameters = []) + { + $criteria = Criteria::create(); + + if (isset($parameters['types'])) { + $criteria = $criteria->withEventTypes($parameters['types']); + } + + if (isset($parameters['id'])) { + $criteria = $criteria->withAggregateRootIds($parameters['id']); + } + + $visitor = new CallableEventVisitor(function ($event) { + $this->addEvent($event); + }); + + $this->eventManager->visitEvents($criteria, $visitor); + $this->publishEvents(); + } + + /** + * @return void + */ + protected function publishEvents() + { + $this->eventBus->publish(new DomainEventStream($this->eventBuffer)); + $this->eventBuffer = []; + } + + /** + * @param $event + */ + private function addEvent($event) + { + $this->eventBuffer[] = $event; + + if ($this->eventBufferSize < count($this->eventBuffer)) { + $this->publishEvents(); + } + } +} \ No newline at end of file diff --git a/src/Identifier/Identifier.php b/src/Identifier/Identifier.php new file mode 100644 index 0000000..9b42e49 --- /dev/null +++ b/src/Identifier/Identifier.php @@ -0,0 +1,30 @@ +value = $value; + } + + /** + * @return static + */ + public static function generate() + { + return new static(Uuid::uuid4()); + } + + /** + * @param $string + * + * @return static + */ + public static function fromString($string) + { + return new static(Uuid::fromString($string)); + } + + /** + * @param Identifier $identifier + * + * @return bool + */ + public function equals(Identifier $identifier) + { + return $this == $identifier; + } + + /** + * @return string + */ + public function toString() + { + return $this->value->toString(); + } + + /** + * @return string + */ + public function __toString() + { + return $this->value->toString(); + } +} \ No newline at end of file diff --git a/src/ReadModel/MethodNameInflector.php b/src/ReadModel/MethodNameInflector.php new file mode 100644 index 0000000..c909e25 --- /dev/null +++ b/src/ReadModel/MethodNameInflector.php @@ -0,0 +1,12 @@ +methodNameInflector = $methodNameInflector; + } + + /** + * {@inheritDoc} + */ + public function handle(DomainMessage $domainMessage) + { + $event = $domainMessage->getPayload(); + $method = $this->methodNameInflector->inflect($event); + + if (! method_exists($this, $method)) { + return; + } + + $this->$method($event, $domainMessage); + } +} \ No newline at end of file diff --git a/src/ReadModel/ServiceProvider.php b/src/ReadModel/ServiceProvider.php new file mode 100644 index 0000000..2f8590a --- /dev/null +++ b/src/ReadModel/ServiceProvider.php @@ -0,0 +1,28 @@ +app->singleton(MethodNameInflector::class, ProjectClassNameInflector::class); + } + + public function provides() + { + return [ + MethodNameInflector::class + ]; + } +} \ No newline at end of file diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..7ad660c --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,28 @@ +app->register(EventStore\ServiceProvider::class); + $this->app->register(ReadModel\ServiceProvider::class); + } + + /** + * Perform post-registration booting of services. + * + * @return void + */ + public function boot() + { + $this->publishes([ + __DIR__.'/../config/broadway.php' => config_path('broadway.php') + ], 'config'); + + $this->publishes([ + __DIR__.'/../database/migrations/' => database_path('migrations') + ], 'migrations'); + } +} \ No newline at end of file