├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── config.yml └── workflows │ ├── php-cs-fixer.yml │ └── run-tests.yml ├── src ├── Concerns │ └── IgnoresMissingMethods.php ├── Exceptions │ ├── CouldNotMakeAggregateRoot.php │ └── InvalidConfiguration.php ├── Commands │ ├── stubs │ │ ├── AggregateRoot.php.stub │ │ ├── AggregateRootRepository.php.stub │ │ └── create_domain_messages_table.php.stub │ ├── GenerateCodeCommand.php │ └── MakeAggregateRootCommand.php ├── QueuedMessageDispatcher.php ├── EventSauceServiceProvider.php ├── QueuedMessageJob.php ├── MessageRepository.php └── AggregateRootRepository.php ├── CHANGELOG.md ├── LICENSE.md ├── .php_cs ├── config └── eventsauce.php ├── composer.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: spatie 2 | -------------------------------------------------------------------------------- /src/Concerns/IgnoresMissingMethods.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | $table->string('event_id', 36); 14 | $table->string('event_type', 100); 15 | $table->string('aggregate_root_id', 36)->nullable()->index(); 16 | $table->dateTime('recorded_at', 6)->index(); 17 | $table->text('payload'); 18 | }); 19 | } 20 | 21 | public function down() 22 | { 23 | Schema::dropIfExists('{{ tableName }}'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | style: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Fix style 14 | uses: docker://oskarstark/php-cs-fixer-ga 15 | with: 16 | args: --config=.php_cs --allow-risky=yes 17 | 18 | - name: Extract branch name 19 | shell: bash 20 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 21 | id: extract_branch 22 | 23 | - name: Commit changes 24 | uses: stefanzweifel/git-auto-commit-action@v2.3.0 25 | with: 26 | commit_message: Fix styling 27 | branch: ${{ steps.extract_branch.outputs.branch }} 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /src/QueuedMessageDispatcher.php: -------------------------------------------------------------------------------- 1 | jobClass = $jobClass; 19 | 20 | return $this; 21 | } 22 | 23 | public function setConsumers(array $consumers) 24 | { 25 | $this->consumers = $consumers; 26 | 27 | return $this; 28 | } 29 | 30 | public function dispatch(Message ...$messages) 31 | { 32 | if (! count($this->consumers)) { 33 | return; 34 | } 35 | 36 | dispatch(new $this->jobClass( 37 | $messages, 38 | $this->consumers, 39 | )); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->ignoreDotFiles(true) 10 | ->ignoreVCS(true); 11 | 12 | return PhpCsFixer\Config::create() 13 | ->setRules([ 14 | '@PSR2' => true, 15 | 'array_syntax' => ['syntax' => 'short'], 16 | 'ordered_imports' => ['sortAlgorithm' => 'alpha'], 17 | 'no_unused_imports' => true, 18 | 'not_operator_with_successor_space' => true, 19 | 'trailing_comma_in_multiline_array' => true, 20 | 'phpdoc_scalar' => true, 21 | 'unary_operator_spaces' => true, 22 | 'binary_operator_spaces' => true, 23 | 'blank_line_before_statement' => [ 24 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 25 | ], 26 | 'phpdoc_single_line_var_spacing' => true, 27 | 'phpdoc_var_without_name' => true, 28 | 'method_argument_space' => [ 29 | 'on_multiline' => 'ensure_fully_multiline', 30 | 'keep_multiple_spaces_after_comma' => true, 31 | ] 32 | ]) 33 | ->setFinder($finder); 34 | -------------------------------------------------------------------------------- /config/eventsauce.php: -------------------------------------------------------------------------------- 1 | [ 12 | [ 13 | 'input_yaml_file' => null, 14 | 'output_file' => null, 15 | ], 16 | ], 17 | 18 | /* 19 | * This connection name will be used to storge messages. When 20 | * set to null the default connection will be used. 21 | */ 22 | 'database_connection' => null, 23 | 24 | /* 25 | * This class will be used to store messages. 26 | * 27 | * You may change this to any class that implements 28 | * \EventSauce\EventSourcing\MessageRepository 29 | */ 30 | 'message_repository' => \Spatie\LaravelEventSauce\MessageRepository::class, 31 | 32 | /* 33 | * This class will be used to put EventSauce messages on the queue. 34 | * 35 | * You may change this to any class that extends 36 | * \Spatie\LaravelEventSauce\QueuedMessageJob::class 37 | */ 38 | 'queued_message_job' => \Spatie\LaravelEventSauce\QueuedMessageJob::class, 39 | ]; 40 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | php: [8.0, 7.4] 13 | laravel: [7.*, 8.*] 14 | dependency-version: [prefer-lowest, prefer-stable] 15 | include: 16 | - laravel: 8.* 17 | testbench: 6.* 18 | - laravel: 7.* 19 | testbench: 5.* 20 | 21 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v2 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 32 | coverage: none 33 | 34 | - name: Install dependencies 35 | run: | 36 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 37 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest 38 | 39 | - name: Execute tests 40 | run: vendor/bin/phpunit 41 | -------------------------------------------------------------------------------- /src/EventSauceServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 16 | $this->publishes([ 17 | __DIR__.'/../config/eventsauce.php' => config_path('eventsauce.php'), 18 | ], 'config'); 19 | } 20 | } 21 | 22 | public function register() 23 | { 24 | $this->mergeConfigFrom(__DIR__.'/../config/eventsauce.php', 'eventsauce'); 25 | 26 | $this->commands([ 27 | GenerateCodeCommand::class, 28 | MakeAggregateRootCommand::class, 29 | ]); 30 | 31 | $this->app->bind(MessageSerializer::class, function () { 32 | return new ConstructingMessageSerializer(); 33 | }); 34 | 35 | return $this; 36 | } 37 | 38 | public function provides() 39 | { 40 | return [ 41 | GenerateCodeCommand::class, 42 | MakeAggregateRootCommand::class, 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Commands/GenerateCodeCommand.php: -------------------------------------------------------------------------------- 1 | info('Start generating code...'); 19 | 20 | $codeGenerationConfig = config('eventsauce.code_generation'); 21 | 22 | collect($codeGenerationConfig) 23 | ->reject(function (array $config) { 24 | return is_null($config['input_yaml_file']); 25 | }) 26 | ->each(function (array $config) { 27 | $this->generateCode($config['input_yaml_file'], $config['output_file']); 28 | }); 29 | 30 | $this->info('All done!'); 31 | } 32 | 33 | private function generateCode(string $inputFile, string $outputFile) 34 | { 35 | if (! file_exists($inputFile)) { 36 | throw InvalidConfiguration::definitionFileDoesNotExist($inputFile); 37 | } 38 | 39 | $loader = new YamlDefinitionLoader(); 40 | $dumper = new CodeDumper(); 41 | 42 | $loadedYamlContent = $loader->load($inputFile); 43 | 44 | $phpCode = $dumper->dump($loadedYamlContent); 45 | 46 | file_put_contents($outputFile, $phpCode); 47 | 48 | $this->warn("Written code to `{$outputFile}`"); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-eventsauce", 3 | "description": "Use EventSauce in Laravel apps", 4 | "keywords": [ 5 | "spatie", 6 | "laravel-eventsauce", 7 | "event sourcing", 8 | "eventsauce", 9 | "events" 10 | ], 11 | "homepage": "https://github.com/spatie/laravel-eventsauce", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Freek Van der Herten", 16 | "email": "freek@spatie.be", 17 | "homepage": "https://spatie.be", 18 | "role": "Developer" 19 | }, 20 | { 21 | "name": "Dries Vints", 22 | "email": "dries.vints@gmail.com", 23 | "role": "Developer" 24 | } 25 | ], 26 | "require": { 27 | "php": "^8.0|^7.4", 28 | "ext-json": "*", 29 | "eventsauce/eventsauce": "^0.8", 30 | "illuminate/bus": "^7.0|^8.0", 31 | "illuminate/container": "^7.0|^8.0", 32 | "illuminate/queue": "^7.0|^8.0", 33 | "illuminate/support": "^7.0|^8.0", 34 | "ramsey/uuid": "^4.0", 35 | "spatie/temporary-directory": "^1.2" 36 | }, 37 | "require-dev": { 38 | "mockery/mockery": "^1.4", 39 | "orchestra/testbench": "^5.0|^6.0", 40 | "phpunit/phpunit": "^9.4.0" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Spatie\\LaravelEventSauce\\": "src" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "Spatie\\LaravelEventSauce\\Tests\\": "tests" 50 | } 51 | }, 52 | "scripts": { 53 | "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes", 54 | "test": "vendor/bin/phpunit", 55 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 56 | 57 | }, 58 | "config": { 59 | "sort-packages": true 60 | }, 61 | "extra": { 62 | "laravel": { 63 | "providers": [ 64 | "Spatie\\LaravelEventSauce\\EventSauceServiceProvider" 65 | ] 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/QueuedMessageJob.php: -------------------------------------------------------------------------------- 1 | messages = $messages; 30 | 31 | $this->consumerClasses = array_map(function (Consumer $consumer) { 32 | return get_class($consumer); 33 | }, $consumers); 34 | } 35 | 36 | public function tags(): array 37 | { 38 | return $this->convertToTags($this->messages); 39 | } 40 | 41 | public function handle() 42 | { 43 | $dispatcher = $this->getMessageDispatcher(); 44 | 45 | $dispatcher->dispatch(...$this->messages); 46 | } 47 | 48 | public function getMessageDispatcher(): MessageDispatcher 49 | { 50 | $consumers = collect($this->consumerClasses) 51 | ->filter(function (string $consumerClass) { 52 | return class_exists($consumerClass); 53 | }) 54 | ->map(function (string $consumerClass) { 55 | return app($consumerClass); 56 | }) 57 | ->toArray(); 58 | 59 | return new SynchronousMessageDispatcher(...$consumers); 60 | } 61 | 62 | protected function convertToTags(array $messages): array 63 | { 64 | return collect($messages) 65 | ->flatMap(function (Message $message) { 66 | return [ 67 | 'aggregateRootId:'.$message->aggregateRootId()->toString(), 68 | 'aggregateRootType:'.$message->header(Header::AGGREGATE_ROOT_ID_TYPE), 69 | 'eventType:'.$message->header(Header::EVENT_TYPE), 70 | ]; 71 | }) 72 | ->filter() 73 | ->unique() 74 | ->toArray(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/MessageRepository.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 36 | 37 | $this->tableName = $tableName; 38 | 39 | $this->serializer = $serializer; 40 | } 41 | 42 | /** 43 | * @param \EventSauce\EventSourcing\Message ...$messages 44 | */ 45 | public function persist(Message ...$messages) 46 | { 47 | foreach ($messages as $message) { 48 | $serializeMessage = $this->serializer->serializeMessage($message); 49 | 50 | $this->connection 51 | ->table($this->tableName) 52 | ->insert([ 53 | 'event_id' => $serializeMessage['headers'][Header::EVENT_ID] ?? Uuid::uuid4()->toString(), 54 | 'event_type' => $serializeMessage['headers'][Header::EVENT_TYPE], 55 | 'aggregate_root_id' => $serializeMessage['headers'][Header::AGGREGATE_ROOT_ID] ?? null, 56 | 'payload' => json_encode($serializeMessage), 57 | 'recorded_at' => $serializeMessage['headers'][Header::TIME_OF_RECORDING], 58 | ]); 59 | } 60 | } 61 | 62 | /** 63 | * @param \EventSauce\EventSourcing\AggregateRootId $id 64 | * @return \Generator 65 | */ 66 | public function retrieveAll(AggregateRootId $id): Generator 67 | { 68 | $payloads = $this->connection 69 | ->table($this->tableName) 70 | ->select('payload') 71 | ->where('aggregate_root_id', $id->toString()) 72 | ->orderBy('recorded_at') 73 | ->get(); 74 | 75 | foreach ($payloads as $payload) { 76 | yield from $this->serializer->unserializePayload(json_decode($payload->payload, true)); 77 | } 78 | } 79 | 80 | /** 81 | * @param \EventSauce\EventSourcing\AggregateRootId $id 82 | * @param int $aggregateRootVersion 83 | * @return \Generator 84 | * @throws \Exception 85 | */ 86 | public function retrieveAllAfterVersion(AggregateRootId $id, int $aggregateRootVersion): Generator 87 | { 88 | throw new Exception('Snapshotting not supported yet.'); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Commands/MakeAggregateRootCommand.php: -------------------------------------------------------------------------------- 1 | filesystem = $files; 24 | } 25 | 26 | public function handle() 27 | { 28 | $aggregateRootFqcn = $this->qualifyClass($this->argument('class')); 29 | $aggregateRootPath = $this->getPath($aggregateRootFqcn); 30 | 31 | $aggregateRootRepositoryFqcn = $this->qualifyClass($this->argument('class').'Repository'); 32 | $aggregateRootRepositoryPath = $this->getPath($aggregateRootRepositoryFqcn); 33 | 34 | $this->ensureValidPaths([$aggregateRootPath, $aggregateRootRepositoryPath]); 35 | 36 | $this->makeDirectory($aggregateRootPath); 37 | 38 | $replacements = [ 39 | 'aggregateRootClass' => class_basename($aggregateRootFqcn), 40 | 'namespace' => substr($aggregateRootFqcn, 0, strrpos($aggregateRootFqcn, '\\')), 41 | 'tableName' => Str::snake(class_basename($aggregateRootFqcn)).'_domain_messages', 42 | 'migrationClassName' => 'Create'.ucfirst(class_basename($aggregateRootFqcn)).'DomainMessagesTable', 43 | ]; 44 | 45 | $this->filesystem->put($aggregateRootPath, $this->getStubContent('AggregateRoot', $replacements)); 46 | $this->filesystem->put($aggregateRootRepositoryPath, $this->getStubContent('AggregateRootRepository', $replacements)); 47 | 48 | $this->createMigration($replacements); 49 | 50 | $this->info('Aggregate root classes and migration created successfully!'); 51 | $this->comment("Run `php artisan migrate` to create the {$replacements['tableName']} table."); 52 | } 53 | 54 | protected function createMigration(array $replacements) 55 | { 56 | $timestamp = now()->format('Y_m_d_His'); 57 | 58 | $migrationFileName = "{$timestamp}_create_{$replacements['tableName']}_table.php"; 59 | $this->filesystem->put( 60 | database_path("migrations/{$migrationFileName}"), 61 | $this->getStubContent('create_domain_messages_table', $replacements), 62 | ); 63 | } 64 | 65 | protected function getPath(string $name): string 66 | { 67 | $name = Str::replaceFirst($this->laravel->getNamespace(), '', $name); 68 | 69 | return $this->laravel['path'].'/'.str_replace('\\', '/', $name).'.php'; 70 | } 71 | 72 | protected function qualifyClass(string $name): string 73 | { 74 | $name = ltrim($name, '\\/'); 75 | 76 | $rootNamespace = $this->laravel->getNamespace(); 77 | 78 | if (Str::startsWith($name, $rootNamespace)) { 79 | return $name; 80 | } 81 | 82 | $name = str_replace('/', '\\', $name); 83 | 84 | return $this->qualifyClass(trim($rootNamespace, '\\').'\\'.$name); 85 | } 86 | 87 | protected function ensureValidPaths(array $paths) 88 | { 89 | foreach ($paths as $path) { 90 | if (file_exists($path)) { 91 | throw CouldNotMakeAggregateRoot::fileAlreadyExists($path); 92 | } 93 | } 94 | } 95 | 96 | protected function makeDirectory(string $path) 97 | { 98 | if (! $this->filesystem->isDirectory(dirname($path))) { 99 | $this->filesystem->makeDirectory(dirname($path), 0777, true, true); 100 | } 101 | } 102 | 103 | protected function getStubContent(string $stubName, array $replacements): string 104 | { 105 | $content = $this->filesystem->get(__DIR__."/stubs/{$stubName}.php.stub"); 106 | 107 | foreach ($replacements as $search => $replace) { 108 | $content = str_replace("{{ {$search} }}", $replace, $content); 109 | } 110 | 111 | return $content; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/AggregateRootRepository.php: -------------------------------------------------------------------------------- 1 | getAggregateRootClass(); 50 | 51 | if (! is_a($aggregateRootClass, AggregateRoot::class, true)) { 52 | throw new Exception('Not a valid aggregateRoot'); 53 | } 54 | 55 | $queuedMessageJobClass = $this->getQueuedMessageJobClass(); 56 | 57 | if (! is_a($queuedMessageJobClass, QueuedMessageJob::class, true)) { 58 | throw new Exception('Not a valid queued message job'); 59 | } 60 | 61 | $this->constructingAggregateRootRepository = new ConstructingAggregateRootRepository( 62 | $aggregateRootClass, 63 | $this->getMessageRepository(), 64 | new MessageDispatcherChain( 65 | (new QueuedMessageDispatcher()) 66 | ->setJobClass($queuedMessageJobClass) 67 | ->setConsumers($this->getInstanciatedQueuedConsumers()), 68 | new SynchronousMessageDispatcher(...$this->getInstanciatedConsumers()) 69 | ), 70 | $this->getMessageDecorator() 71 | ); 72 | } 73 | 74 | public function retrieve(AggregateRootId $aggregateRootId): object 75 | { 76 | return $this->constructingAggregateRootRepository->retrieve($aggregateRootId); 77 | } 78 | 79 | public function persist(object $aggregateRoot) 80 | { 81 | return $this->constructingAggregateRootRepository->persist($aggregateRoot); 82 | } 83 | 84 | public function persistEvents(AggregateRootId $aggregateRootId, int $aggregateRootVersion, object ...$events) 85 | { 86 | $this->constructingAggregateRootRepository->persistEvents($aggregateRootId, $aggregateRootVersion); 87 | } 88 | 89 | protected function getAggregateRootClass(): string 90 | { 91 | return $this->aggregateRoot; 92 | } 93 | 94 | public function getQueuedMessageJobClass(): string 95 | { 96 | return $this->queuedMessageJob ?? config('eventsauce.queued_message_job'); 97 | } 98 | 99 | protected function getMessageRepository(): MessageRepository 100 | { 101 | $messageRepositoryClass = $this->messageRepository ?? config('eventsauce.message_repository'); 102 | 103 | return app()->makeWith($messageRepositoryClass, [ 104 | 'connection' => $this->getConnection(), 105 | 'tableName' => $this->tableName, 106 | ]); 107 | } 108 | 109 | protected function getConnection(): Connection 110 | { 111 | $connectionName = $this->connectionName 112 | ?? config('eventsauce.database_connection') 113 | ?? config('database.default'); 114 | 115 | return DB::connection($connectionName); 116 | } 117 | 118 | protected function getConsumers(): array 119 | { 120 | return $this->consumers; 121 | } 122 | 123 | protected function getQueuedConsumers(): array 124 | { 125 | return $this->queuedConsumers; 126 | } 127 | 128 | protected function getMessageDecorator(): ?MessageDecorator 129 | { 130 | return $this->messageDecorator; 131 | } 132 | 133 | protected function getInstanciatedConsumers(): array 134 | { 135 | return $this->instanciate($this->consumers); 136 | } 137 | 138 | protected function getInstanciatedQueuedConsumers(): array 139 | { 140 | return $this->instanciate($this->queuedConsumers); 141 | } 142 | 143 | protected function instanciate(array $classes): array 144 | { 145 | return array_map(function ($class): Consumer { 146 | return is_string($class) 147 | ? app($class) 148 | : $class; 149 | }, $classes); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [](https://supportukrainenow.org) 3 | 4 | # Use EventSauce in Laravel apps 5 | 6 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-eventsauce.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-eventsauce) 7 | ![Tests](https://github.com/spatie/laravel-eventsauce/workflows/run-tests/badge.svg) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-eventsauce.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-eventsauce) 9 | 10 | [EventSauce](https://eventsauce.io/) is an easy way to introduce event sourcing into PHP projects. This package allows EventSauce to make use of Laravel's migrations, models and jobs. It can also help with generating code for commands and events. If you want to use EventSauce in a Laravel app, this package is the way to go! 11 | 12 | Before using laravel-eventsauce you should already know how to work with EventSauce. 13 | 14 | Here's a quick example of how to create a new aggregate root and matching repository. Let's run this command: 15 | 16 | ```php 17 | php artisan make:aggregate-root "MyDomain\MyAggregateRoot" 18 | ``` 19 | 20 | The `App\MyDomain\MyAggregateRoot` and `App\MyDomain\MyAggregateRootRepository` classes will have been created. A migration to create `my_aggregate_root_domain_messages` will have been added to your application too. This is how `MyAggregateRootRepository` looks like: 21 | 22 | ```php 23 | namespace App\MyDomain; 24 | 25 | use App\Domain\Account\Projectors\AccountProjector; 26 | use App\Domain\Account\Projectors\TransactionCountProjector; 27 | use Spatie\LaravelEventSauce\AggregateRootRepository; 28 | 29 | /** @method \App\MyDomain\MyAggregateRoot retrieve */ 30 | class MyAggregateRootRepository extends AggregateRootRepository 31 | { 32 | /** @var string */ 33 | protected $aggregateRoot = MyAggregateRoot::class; 34 | 35 | /** @var string */ 36 | protected $tableName = 'my_aggregate_root_domain_messages'; 37 | 38 | /** @var array */ 39 | protected $consumers = [ 40 | 41 | ]; 42 | 43 | /** @var array */ 44 | protected $queuedConsumers = [ 45 | 46 | ]; 47 | } 48 | ``` 49 | 50 | You can put classnames of consumers in the `$consumers` array. Consumers in the `$queuedConsumers` array will called and be passed their messages using a queued job. 51 | 52 | The `MyAggregateRootRepository` can be injected and used in any class. In this example we assume you've manually created a `performMySpecialCommand` method on `MyAggregateRoot`: 53 | 54 | ```php 55 | namespace App\MyDomain; 56 | 57 | class CommandHandler 58 | { 59 | /** @var \EventSauce\EventSourcing\AggregateRootRepository */ 60 | private $repository; 61 | 62 | public function __construct(MyAggregateRootRepository $repository) 63 | { 64 | $this->repository = $repository; 65 | } 66 | 67 | public function handle(object $command) 68 | { 69 | $aggregateRootId = $command->identifier(); 70 | 71 | $aggregateRoot = $this->repository->retrieve($aggregateRootId); 72 | 73 | try { 74 | if ($command instanceof MySpecialCommand) { 75 | $aggregateRoot->performMySpecialCommand($command); 76 | } 77 | } finally { 78 | $this->repository->persist($aggregateRoot); 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | ## Support us 85 | 86 | [](https://spatie.be/github-ad-click/laravel-eventsauce) 87 | 88 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 89 | 90 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 91 | 92 | ## Installation 93 | 94 | You can install the package via composer: 95 | 96 | ```bash 97 | composer require spatie/laravel-eventsauce 98 | ``` 99 | 100 | Next you must publish the `eventsauce` config file. 101 | 102 | ```bash 103 | php artisan vendor:publish --provider="Spatie\LaravelEventSauce\EventSauceServiceProvider" --tag="config" 104 | ``` 105 | 106 | This is the contents of the file that will be publish to `config/eventsauce.php`: 107 | 108 | ```php 109 | return [ 110 | /* 111 | * Types, commands and events can be generated starting from a yaml file. 112 | * Here you can specify the input and the output of the code generation. 113 | * 114 | * More info on code generation here: 115 | * https://eventsauce.io/docs/getting-started/create-events-and-commands 116 | */ 117 | 'code_generation' => [ 118 | [ 119 | 'input_yaml_file' => null, 120 | 'output_file' => null, 121 | ], 122 | ], 123 | 124 | /* 125 | * This connection name will be used to store messages. When 126 | * set to null the default connection will be used. 127 | */ 128 | 'database_connection' => null, 129 | 130 | /* 131 | * This class will be used to store messages. 132 | * 133 | * You may change this to any class that implements 134 | * \EventSauce\EventSourcing\MessageRepository 135 | */ 136 | 'message_repository' => \Spatie\LaravelEventSauce\MessageRepository::class, 137 | 138 | /* 139 | * This class will be used to put EventSauce messages on the queue. 140 | * 141 | * You may change this to any class that extends 142 | * \Spatie\LaravelEventSauce\QueuedMessageJob::class 143 | */ 144 | 'queued_message_job' => \Spatie\LaravelEventSauce\QueuedMessageJob::class, 145 | ]; 146 | ``` 147 | 148 | ## Usage 149 | 150 | ### Generating an aggregate root and repository 151 | 152 | An aggregate root and matching repository can be generated used this command 153 | 154 | ```bash 155 | php artisan make:aggregate-root "MyDomain\MyAggregateRoot" 156 | ``` 157 | 158 | This command will create `App\MyDomain\MyAggregateRoot` and `App\MyDomain\MyAggregateRootRepository`. 159 | 160 | This is how `MyAggregateRootRepository` looks like: 161 | 162 | ```php 163 | namespace App\MyDomain; 164 | 165 | use App\Domain\Account\Projectors\AccountProjector; 166 | use App\Domain\Account\Projectors\TransactionCountProjector; 167 | use Spatie\LaravelEventSauce\AggregateRootRepository; 168 | 169 | /** @method \App\MyDomain\MyAggregateRoot retrieve */ 170 | class MyAggregateRootRepository extends AggregateRootRepository 171 | { 172 | /** @var string */ 173 | protected $aggregateRoot = MyAggregateRoot::class; 174 | 175 | /** @var string */ 176 | protected $tableName = 'my_aggregate_root_domain_messages'; 177 | 178 | /** @var array */ 179 | protected $consumers = [ 180 | 181 | ]; 182 | 183 | /** @var array */ 184 | protected $queuedConsumers = [ 185 | 186 | ]; 187 | } 188 | ``` 189 | 190 | If you repository doesn't need consumers or queued consumers, you can safely remove those variables. The only required variable is `$aggregateRoot`. 191 | 192 | Of course you can also manually create an aggregate root repository. Just create a class, let it extend`Spatie\LaravelEventSauce\AggregateRootRepository`. Next, put the fully qualified classname of your aggregate root in a protected `$aggregateRoot` property. Finally add a `$tableName` property containing the name of the table where you want to store domain messages. 193 | 194 | ### Configuring the aggregate root repository 195 | 196 | #### Specifying the aggregate root 197 | 198 | The `$aggregateRoot` property should contain the fully qualied class name of an aggregate root. A valid aggregate root is any class that implements `EventSauce\EventSourcing\AggregateRoot` 199 | 200 | #### Adding consumers 201 | 202 | Consumers are classes that receive all events and do something with them, for example creation a projection. The `$consumers` property should be an array that contains class names of consumers. A valid consumer is any class that implements `EventSauce\EventSourcing\Consumer`. 203 | 204 | #### Adding queued consumers 205 | 206 | Unless you need the result of a consumer in the same request as your command or event is fired, it's recommanded to let a consumer to perform it's work on a queue. The `$queuedConsumers` property should be an array that contains class names of consumers. A valid consumer is any class that implements `EventSauce\EventSourcing\Consumer`. 207 | 208 | If there are any message that needs to be sent to any of these consumers, the package will dispatch a `Spatie\LaravelEventSauce\QueuedMessageJob` by default. 209 | 210 | #### Customizing the job that passes messages to queued consumers 211 | 212 | By default `Spatie\LaravelEventSauce\QueuedMessageJob` is used to pass messages to queued consumers. You can customized this job by setting the `queued_message_job` entry in the `eventsauce` config file to the class of your custom job. A valid job is any class that extends `Spatie\LaravelEventSauce\QueuedMessageJob`. 213 | 214 | Changing the `queued_message_job` entry will change the default job of all aggregate root repositories. If you want to change the job class for a specific repository add a `$queuedMessageJob` property to that repository. 215 | 216 | Here is an example: 217 | 218 | ```php 219 | // ... 220 | 221 | class MyAggregateRootRepository extends AggregateRootRepository 222 | { 223 | // ... 224 | 225 | protected $queuedMessageJob = MyCustomJob::class; 226 | } 227 | ``` 228 | 229 | You can use that custom job to add properties to control the timeout, max attempts and the queue to be used. You can read more on how to configure a job in the [Laravel docs on queueing](https://laravel.com/docs/master/queues). 230 | 231 | Here's an example of a custom job. 232 | 233 | ```php 234 | use Spatie\LaravelEventSauce\QueuedMessageJob; 235 | 236 | class MyCustomJob extends QueuedMessageJob 237 | { 238 | /* 239 | * The name of the connection the job should be sent to. 240 | */ 241 | public $connection = 'my-custom-connection'; 242 | 243 | /* 244 | * The name of the queue the job should be sent to. 245 | */ 246 | public $queue = 'my-custom-queue'; 247 | 248 | /* 249 | * The number of times the job may be attempted. 250 | * 251 | * @var int 252 | */ 253 | public $tries = 5; 254 | 255 | /* 256 | * The number of seconds the job can run before timing out. 257 | * 258 | * @var int 259 | */ 260 | public $timeout = 120; 261 | 262 | /* 263 | * The number of seconds before the job should be made available. 264 | * 265 | * @var int|null 266 | */ 267 | public $delay; 268 | } 269 | ``` 270 | 271 | ### Customizing the table name where messages are stored 272 | 273 | The `$tableName` property on your aggregate root repository determines where messages are being stored. You can change this to any name you want as long as you've created a a table with that name that has the following columns: 274 | 275 | ```php 276 | Schema::create('custom_table_name', function (Blueprint $table) { 277 | $table->increments('id'); 278 | $table->string('event_id', 36); 279 | $table->string('event_type', 100); 280 | $table->string('aggregate_root_id', 36)->nullable()->index(); 281 | $table->dateTime('recorded_at', 6)->index(); 282 | $table->text('payload'); 283 | }); 284 | ``` 285 | 286 | ### Specifying a connection 287 | 288 | Laravel has support for multiple database connections. By default the aggregate root will use Laravel's default connection. If you want all your aggregate roots to use a the same alternative connection then specify that connection name in the `connection` property of the `eventsauce` config file. 289 | 290 | If you want let a specific repository use an alternative connection, you can just specify it's name in the `$connection` property 291 | 292 | ```php 293 | // ... 294 | 295 | class MyAggregateRootRepository extends AggregateRootRepository 296 | { 297 | // ... 298 | 299 | protected $connection = 'connection-name'; 300 | } 301 | ``` 302 | 303 | ### Code generation 304 | 305 | We can generate types, events and commands from you starting from a yaml file. You can read more on the contents of the yaml file and the generated output in the "[Defining command and events using Yaml](https://eventsauce.io/docs/getting-started/create-events-and-commands/)" section of the EventSauce docs. 306 | 307 | To generate code, fill in the keys in the `code_generation` parts of the `eventsauce` config file and execute this command. 308 | 309 | ``` 310 | php artisan eventsauce:generate 311 | ``` 312 | 313 | ### Testing 314 | 315 | ``` bash 316 | composer test 317 | ``` 318 | 319 | ### Changelog 320 | 321 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 322 | 323 | ## Contributing 324 | 325 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 326 | 327 | ### Security 328 | 329 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 330 | 331 | ## Postcardware 332 | 333 | You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. 334 | 335 | Our address is: Spatie, Kruikstraat 22, 2018 Antwerp, Belgium. 336 | 337 | We publish all received postcards [on our company website](https://spatie.be/en/opensource/postcards). 338 | 339 | ## Credits 340 | 341 | - [Freek Van der Herten](https://github.com/freekmurze) 342 | 343 | The initial version of this package was based upon a development version of [LaravelEventSauce](https://github.com/EventSaucePHP/LaravelEventSauce). 344 | 345 | ## License 346 | 347 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 348 | --------------------------------------------------------------------------------