├── .styleci.yml ├── LICENSE.md ├── composer.json ├── config └── eventsauce.php ├── database └── migrations │ └── 2018_08_31_104000_create_domain_messages_table.php └── src ├── AggregateRootRepository.php ├── Console ├── GenerateCommand.php ├── MakeAggregateRootCommand.php ├── MakeCommand.php ├── MakeConsumerCommand.php └── stubs │ ├── AggregateRoot.php.stub │ ├── AggregateRootId.php.stub │ ├── AggregateRootRepository.php.stub │ ├── Consumer.php.stub │ └── create_domain_messages_table.php.stub ├── Consumer.php ├── EventMessageDispatcher.php ├── EventSauceServiceProvider.php ├── Exceptions ├── CodeGenerationFailed.php └── MakeFileFailed.php ├── HandleConsumer.php ├── LaravelMessageDispatcher.php └── LaravelMessageRepository.php /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | enabled: 3 | - heredoc_indentation 4 | - trailing_comma_in_multiline_call 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Dries Vints 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eventsauce/laravel-eventsauce", 3 | "description": "Integration support for EventSauce with the Laravel framework.", 4 | "keywords": ["laravel", "event sourcing", "eventsauce"], 5 | "license": "MIT", 6 | "support": { 7 | "issues": "https://github.com/EventSaucePHP/LaravelEventSauce/issues", 8 | "source": "https://github.com/EventSaucePHP/LaravelEventSauce" 9 | }, 10 | "authors": [ 11 | { 12 | "name": "Dries Vints", 13 | "homepage": "https://driesvints.com" 14 | }, 15 | { 16 | "name": "Freek Van der Herten", 17 | "email": "freek@spatie.be", 18 | "homepage": "https://spatie.be" 19 | } 20 | ], 21 | "require": { 22 | "php": "^7.4|^8.0", 23 | "ext-json": "*", 24 | "eventsauce/eventsauce": "^1.0|^2.0", 25 | "illuminate/bus": "^8.0|^9.0", 26 | "illuminate/container": "^8.0|^9.0", 27 | "illuminate/queue": "^8.0|^9.0.0", 28 | "illuminate/support": "^8.0|^9.0", 29 | "ramsey/uuid": "^3.8|^4.0" 30 | }, 31 | "require-dev": { 32 | "orchestra/testbench": "^6.0|^7.0", 33 | "phpunit/phpunit": "^9.3" 34 | }, 35 | "suggest": { 36 | "eventsauce/code-generation": "Generate commands and events with ease" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "EventSauce\\LaravelEventSauce\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Tests\\": "tests" 46 | } 47 | }, 48 | "extra": { 49 | "laravel": { 50 | "providers": [ 51 | "EventSauce\\LaravelEventSauce\\EventSauceServiceProvider" 52 | ] 53 | } 54 | }, 55 | "config": { 56 | "sort-packages": true 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true 60 | } 61 | -------------------------------------------------------------------------------- /config/eventsauce.php: -------------------------------------------------------------------------------- 1 | env('EVENTSAUCE_CONNECTION'), 11 | 12 | /* 13 | * The default database table name, used to store messages. 14 | */ 15 | 16 | 'table' => env('EVENTSAUCE_TABLE', 'domain_messages'), 17 | 18 | /* 19 | * Here you specify all of your aggregate root repositories. 20 | * We'll use this info to generate commands and events. 21 | * 22 | * More info on code generation here: 23 | * https://eventsauce.io/docs/event-sourcing/create-events-and-commands 24 | */ 25 | 26 | 'repositories' => [ 27 | // App\Domain\MyAggregateRoot\MyAggregateRootRepository::class, 28 | ], 29 | 30 | ]; 31 | -------------------------------------------------------------------------------- /database/migrations/2018_08_31_104000_create_domain_messages_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 13 | $table->string('event_id', 36); 14 | $table->string('event_type', 100); 15 | $table->string('event_stream', 36)->index(); 16 | $table->dateTime('recorded_at', 6)->index(); 17 | $table->text('payload'); 18 | }); 19 | } 20 | 21 | public function down() 22 | { 23 | Schema::dropIfExists('domain_messages'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/AggregateRootRepository.php: -------------------------------------------------------------------------------- 1 | aggregateRoot, AggregateRoot::class, true)) { 43 | throw new LogicException('You have to set an aggregate root before the repository can be initialized.'); 44 | } 45 | 46 | $this->messageRepository = $messageRepository; 47 | 48 | if ($this->connection) { 49 | $this->messageRepository->setConnection($this->connection); 50 | } 51 | 52 | if ($this->table) { 53 | $this->messageRepository->setTable($this->table); 54 | } 55 | } 56 | 57 | public function retrieve(AggregateRootId $aggregateRootId): object 58 | { 59 | return $this->repository()->retrieve($aggregateRootId); 60 | } 61 | 62 | public function persist(object $aggregateRoot): void 63 | { 64 | $this->repository()->persist($aggregateRoot); 65 | } 66 | 67 | public function persistEvents(AggregateRootId $aggregateRootId, int $aggregateRootVersion, object ...$events): void 68 | { 69 | $this->repository()->persistEvents($aggregateRootId, $aggregateRootVersion, ...$events); 70 | } 71 | 72 | private function repository(): EventSauceAggregateRootRepository 73 | { 74 | return new ConstructingAggregateRootRepository( 75 | $this->aggregateRoot, 76 | $this->messageRepository, 77 | new MessageDispatcherChain( 78 | $this->buildLaravelMessageDispatcher(), 79 | new EventMessageDispatcher(), 80 | ), 81 | new MessageDecoratorChain(...$this->buildMessageDecorators()), 82 | ); 83 | } 84 | 85 | public static function inputFile(): string 86 | { 87 | return static::$inputFile; 88 | } 89 | 90 | public static function outputFile(): string 91 | { 92 | return static::$outputFile; 93 | } 94 | 95 | private function buildLaravelMessageDispatcher(): MessageDispatcher 96 | { 97 | $dispatcher = new LaravelMessageDispatcher( 98 | ...$this->consumers, 99 | ); 100 | 101 | if ($this->queue) { 102 | $dispatcher->onQueue($this->queue); 103 | } 104 | 105 | return $dispatcher; 106 | } 107 | 108 | private function buildMessageDecorators(): array 109 | { 110 | if (! in_array(DefaultHeadersDecorator::class, $this->decorators)) { 111 | array_unshift($this->decorators, DefaultHeadersDecorator::class); 112 | } 113 | 114 | $decorators = []; 115 | 116 | foreach ($this->decorators as $decorator) { 117 | $decorators[] = resolve($decorator); 118 | } 119 | 120 | return $decorators; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Console/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | filesystem = $files; 27 | } 28 | 29 | public function handle(): void 30 | { 31 | if (! class_exists(CodeDumper::class)) { 32 | throw CodeGenerationFailed::codeGenerationNotInstalled(); 33 | } 34 | 35 | $this->info('Start generating code...'); 36 | 37 | collect(config('eventsauce.repositories', [])) 38 | ->reject(function (string $repository) { 39 | return $repository::inputFile() === ''; 40 | }) 41 | ->each(function (string $repository) { 42 | $this->generateCode($repository::inputFile(), $repository::outputFile()); 43 | }); 44 | 45 | $this->info('All done!'); 46 | } 47 | 48 | private function generateCode(string $inputFile, string $outputFile): void 49 | { 50 | $this->assertFileExists($inputFile); 51 | 52 | $loadedYamlContent = (new YamlDefinitionLoader())->load($inputFile); 53 | $phpCode = (new CodeDumper())->dump($loadedYamlContent); 54 | 55 | $this->filesystem->put($outputFile, $phpCode); 56 | 57 | $this->warn("Written code to `{$outputFile}`"); 58 | } 59 | 60 | private function assertFileExists(string $file): void 61 | { 62 | if (! file_exists($file)) { 63 | throw CodeGenerationFailed::definitionFileDoesNotExist($file); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Console/MakeAggregateRootCommand.php: -------------------------------------------------------------------------------- 1 | formatClassName($this->argument('namespace')); 20 | $aggregateRootPath = $this->getPath($aggregateRootClass); 21 | 22 | $aggregateRootIdClass = $this->formatClassName($this->argument('namespace').'Id'); 23 | $aggregateRootIdPath = $this->getPath($aggregateRootIdClass); 24 | 25 | $aggregateRootRepositoryClass = $this->formatClassName($this->argument('namespace').'Repository'); 26 | $aggregateRootRepositoryPath = $this->getPath($aggregateRootRepositoryClass); 27 | 28 | try { 29 | $this->ensureValidPaths([ 30 | $aggregateRootPath, 31 | $aggregateRootIdPath, 32 | $aggregateRootRepositoryPath, 33 | ]); 34 | } catch (MakeFileFailed $exception) { 35 | return 1; 36 | } 37 | 38 | $this->makeDirectory($aggregateRootPath); 39 | 40 | $replacements = [ 41 | 'aggregateRoot' => $aggregateRoot = class_basename($aggregateRootClass), 42 | 'namespace' => substr($aggregateRootClass, 0, strrpos($aggregateRootClass, '\\')), 43 | 'table' => Str::snake(class_basename($aggregateRootClass)).'_domain_messages', 44 | 'migration' => 'Create'.ucfirst(class_basename($aggregateRootClass)).'DomainMessagesTable', 45 | ]; 46 | 47 | $this->makeFiles([ 48 | 'AggregateRoot' => $aggregateRootPath, 49 | 'AggregateRootId' => $aggregateRootIdPath, 50 | 'AggregateRootRepository' => $aggregateRootRepositoryPath, 51 | ], $replacements); 52 | 53 | $this->createMigration($replacements); 54 | 55 | $this->info("{$aggregateRoot} classes and migration created successfully!"); 56 | $this->comment("Run `php artisan migrate` to create the {$replacements['table']} table."); 57 | } 58 | 59 | private function createMigration(array $replacements): void 60 | { 61 | $timestamp = (new DateTimeImmutable())->format('Y_m_d_His'); 62 | $filename = "{$timestamp}_create_{$replacements['table']}_table.php"; 63 | 64 | $this->filesystem->put( 65 | $this->laravel->databasePath("migrations/{$filename}"), 66 | $this->getStubContent('create_domain_messages_table', $replacements), 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Console/MakeCommand.php: -------------------------------------------------------------------------------- 1 | filesystem = $files; 21 | } 22 | 23 | protected function formatClassName(string $namespace): string 24 | { 25 | $name = ltrim($namespace, '\\/'); 26 | $name = str_replace('/', '\\', $name); 27 | 28 | $rootNamespace = $this->laravel->getNamespace(); 29 | 30 | if (Str::startsWith($name, $rootNamespace)) { 31 | return $name; 32 | } 33 | 34 | return $this->formatClassName(trim($rootNamespace, '\\').'\\'.$name); 35 | } 36 | 37 | protected function getPath(string $name): string 38 | { 39 | $name = Str::replaceFirst($this->laravel->getNamespace(), '', $name); 40 | 41 | return $this->laravel['path'].'/'.str_replace('\\', '/', $name).'.php'; 42 | } 43 | 44 | protected function ensureValidPaths(array $paths): void 45 | { 46 | foreach ($paths as $path) { 47 | if (file_exists($path)) { 48 | $this->error("The file at path `{$path}` already exists."); 49 | 50 | throw MakeFileFailed::fileExists($path); 51 | } 52 | } 53 | } 54 | 55 | protected function makeDirectory(string $path): void 56 | { 57 | if (! $this->filesystem->isDirectory(dirname($path))) { 58 | $this->filesystem->makeDirectory(dirname($path), 0755, true, true); 59 | } 60 | } 61 | 62 | protected function getStubContent(string $stubName, array $replacements): string 63 | { 64 | $content = $this->filesystem->get(__DIR__."/stubs/{$stubName}.php.stub"); 65 | 66 | foreach ($replacements as $search => $replace) { 67 | $content = str_replace("{{ {$search} }}", $replace, $content); 68 | } 69 | 70 | return $content; 71 | } 72 | 73 | protected function makeFiles(array $paths, array $replacements): void 74 | { 75 | collect($paths)->map(function (string $path, string $stubName) use ($replacements) { 76 | $this->filesystem->put($path, $this->getStubContent($stubName, $replacements)); 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Console/MakeConsumerCommand.php: -------------------------------------------------------------------------------- 1 | formatClassName($this->argument('class')); 18 | $consumerPath = $this->getPath($consumerClass); 19 | 20 | try { 21 | $this->ensureValidPaths([ 22 | $consumerPath, 23 | ]); 24 | } catch (MakeFileFailed $exception) { 25 | return 1; 26 | } 27 | 28 | $this->makeDirectory($consumerPath); 29 | 30 | $this->makeFiles( 31 | ['Consumer' => $consumerPath], 32 | [ 33 | 'consumer' => class_basename($consumerClass), 34 | 'namespace' => substr($consumerClass, 0, strrpos($consumerClass, '\\')), 35 | ], 36 | ); 37 | 38 | $this->info("{$consumerClass} class created successfully!"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Console/stubs/AggregateRoot.php.stub: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 18 | } 19 | 20 | public function toString(): string 21 | { 22 | return $this->identifier; 23 | } 24 | 25 | public function toUuid(): UuidInterface 26 | { 27 | return Uuid::fromString($this->identifier); 28 | } 29 | 30 | public static function create(): self 31 | { 32 | return new static(Uuid::uuid4()->toString()); 33 | } 34 | 35 | public static function fromString(string $aggregateRootId): static 36 | { 37 | return new static($aggregateRootId); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/stubs/AggregateRootRepository.php.stub: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 13 | $table->string('event_id', 36); 14 | $table->string('event_type', 100); 15 | $table->string('event_stream', 36)->index(); 16 | $table->dateTime('recorded_at', 6)->index(); 17 | $table->text('payload'); 18 | }); 19 | } 20 | 21 | public function down() 22 | { 23 | Schema::dropIfExists('{{ table }}'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Consumer.php: -------------------------------------------------------------------------------- 1 | event(); 15 | $parts = explode('\\', get_class($event)); 16 | $method = 'handle'.end($parts); 17 | 18 | if (method_exists($this, $method)) { 19 | $this->{$method}($event, $message); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/EventMessageDispatcher.php: -------------------------------------------------------------------------------- 1 | event()); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/EventSauceServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/../database/migrations'); 21 | 22 | if ($this->app->runningInConsole()) { 23 | $this->publishes([ 24 | __DIR__.'/../config/eventsauce.php' => $this->app->configPath('eventsauce.php'), 25 | ], ['eventsauce', 'eventsauce-config']); 26 | 27 | $this->publishes([ 28 | __DIR__.'/../database/migrations' => $this->app->databasePath('migrations'), 29 | ], ['eventsauce', 'eventsauce-migrations']); 30 | } 31 | } 32 | 33 | public function register() 34 | { 35 | $this->mergeConfigFrom(__DIR__.'/../config/eventsauce.php', 'eventsauce'); 36 | 37 | $this->commands([ 38 | GenerateCommand::class, 39 | MakeAggregateRootCommand::class, 40 | MakeConsumerCommand::class, 41 | ]); 42 | 43 | $this->app->bind(MessageSerializer::class, function ($app) { 44 | return $app->make(ConstructingMessageSerializer::class); 45 | }); 46 | 47 | $this->app->bind(MessageDecorator::class, function ($app) { 48 | return $app->make(DefaultHeadersDecorator::class); 49 | }); 50 | } 51 | 52 | public function provides() 53 | { 54 | return [ 55 | GenerateCommand::class, 56 | MakeAggregateRootCommand::class, 57 | MakeConsumerCommand::class, 58 | ]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Exceptions/CodeGenerationFailed.php: -------------------------------------------------------------------------------- 1 | consumer = $consumer; 27 | $this->messages = $messages; 28 | } 29 | 30 | public function handle(Container $container): void 31 | { 32 | $consumer = $this->resolveConsumer($container); 33 | 34 | foreach ($this->messages as $message) { 35 | $consumer->handle($message); 36 | } 37 | } 38 | 39 | private function resolveConsumer(Container $container): Consumer 40 | { 41 | return $container->make($this->consumer); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/LaravelMessageDispatcher.php: -------------------------------------------------------------------------------- 1 | consumers = $consumers; 21 | } 22 | 23 | public function dispatch(Message ...$messages): void 24 | { 25 | foreach ($this->consumers as $consumer) { 26 | if (is_a($consumer, ShouldQueue::class, true)) { 27 | $dispatch = dispatch(new HandleConsumer($consumer, ...$messages)); 28 | 29 | if ($this->queue) { 30 | $dispatch->onQueue($this->queue); 31 | } 32 | } else { 33 | dispatch_now(new HandleConsumer($consumer, ...$messages)); 34 | } 35 | } 36 | } 37 | 38 | public function onQueue(string $queue): self 39 | { 40 | $this->queue = $queue; 41 | 42 | return $this; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/LaravelMessageRepository.php: -------------------------------------------------------------------------------- 1 | database = $database; 32 | $this->serializer = $serializer; 33 | $this->connection = (string) $config->get('eventsauce.connection'); 34 | $this->table = (string) $config->get('eventsauce.table'); 35 | } 36 | 37 | public function persist(Message ...$messages): void 38 | { 39 | $connection = $this->connection(); 40 | 41 | collect($messages)->map(function (Message $message) { 42 | return $this->serializer->serializeMessage($message); 43 | })->each(function (array $message) use ($connection) { 44 | $headers = $message['headers']; 45 | 46 | $connection->table($this->table)->insert([ 47 | 'event_id' => $headers[Header::EVENT_ID] ?? Uuid::uuid4()->toString(), 48 | 'event_type' => $headers[Header::EVENT_TYPE], 49 | 'event_stream' => $headers[Header::AGGREGATE_ROOT_ID] ?? null, 50 | 'recorded_at' => $headers[Header::TIME_OF_RECORDING], 51 | 'payload' => json_encode($message), 52 | ]); 53 | }); 54 | } 55 | 56 | public function retrieveAll(AggregateRootId $id): Generator 57 | { 58 | $payloads = $this->connection()->table($this->table) 59 | ->where('event_stream', $id->toString()) 60 | ->orderBy('recorded_at') 61 | ->get('payload'); 62 | 63 | foreach ($payloads as $payload) { 64 | $messages = $this->serializer->unserializePayload(json_decode($payload->payload, true)); 65 | 66 | if ($messages instanceof Message) { 67 | yield $messages; 68 | } else { 69 | yield from $messages; 70 | } 71 | } 72 | 73 | return $payloads->count(); 74 | } 75 | 76 | /** 77 | * @throws \Exception 78 | */ 79 | public function retrieveAllAfterVersion(AggregateRootId $id, int $aggregateRootVersion): Generator 80 | { 81 | throw new Exception('Snapshotting not supported yet.'); 82 | } 83 | 84 | private function connection(): ConnectionInterface 85 | { 86 | return $this->database->connection($this->connection); 87 | } 88 | 89 | public function setConnection(string $connection): void 90 | { 91 | $this->connection = $connection; 92 | } 93 | 94 | public function setTable(string $table): void 95 | { 96 | $this->table = $table; 97 | } 98 | } 99 | --------------------------------------------------------------------------------