├── .gitignore ├── README.md ├── composer.json ├── phpunit.xml ├── publishable ├── config │ └── model_changes_history.php └── database │ └── migrations │ └── create_model_changes_history_table.php ├── src ├── Console │ └── Commands │ │ └── ChangesHistoryClearCommand.php ├── Exceptions │ └── StorageNotFoundException.php ├── Facades │ ├── ChangesHistory.php │ └── HistoryStorage.php ├── Interfaces │ └── HistoryStorageInterface.php ├── Models │ └── Change.php ├── Observers │ └── ModelChangesHistoryObserver.php ├── Providers │ └── ModelChangesHistoryServiceProvider.php ├── Services │ ├── ChangesHistoryService.php │ └── HistoryStorageService.php ├── Stores │ ├── DatabaseHistoryStorage.php │ ├── FileHistoryStorage.php │ ├── HistoryStorageRegistry.php │ └── RedisHistoryStorage.php └── Traits │ └── HasChangesHistory.php └── tests ├── TestCase.php ├── Unit ├── ChangesHistoryServiceTest.php └── HistoryStorageServiceTest.php └── fixtures └── TestModel.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | composer.lock 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # antonrom00/laravel-model-changes-history 2 | 3 | Records the changes history made to an eloquent model. 4 | 5 | [![Total Downloads](https://img.shields.io/packagist/dt/antonrom00/laravel-model-changes-history.svg?style=flat-square)](https://packagist.org/packages/antonrom00/laravel-model-changes-history) 6 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=P58FVTP9QTTEW&source=url) 7 | 8 | ## Quick start 9 | 10 | **Your model must have an `id` field!** 11 | 12 | ```bash 13 | composer require antonrom00/laravel-model-changes-history 14 | ``` 15 | 16 | ```bash 17 | php artisan vendor:publish --tag="model-changes-history" 18 | ``` 19 | 20 | ```bash 21 | php artisan migrate 22 | ``` 23 | 24 | **Note: this library use `database` storage as default.** 25 | 26 | ## Installation 27 | 28 | ```bash 29 | composer require antonrom00/laravel-model-changes-history 30 | ``` 31 | 32 | The package is auto discovered. 33 | 34 | To change the config, publish it using the following command: 35 | 36 | ```bash 37 | php artisan vendor:publish --provider="Antonrom\ModelChangesHistory\Providers\ModelChangesHistoryServiceProvider" --tag="config" 38 | ``` 39 | 40 | You can use three ways for record changes: `'storage' => 'database', 'file' or 'redis'` 41 | 42 | If you want to use `database` storage, you must publish the migration file, run the following artisan commands: 43 | 44 | ```bash 45 | php artisan vendor:publish --provider="Antonrom\ModelChangesHistory\Providers\ModelChangesHistoryServiceProvider" --tag="migrations" 46 | ``` 47 | 48 | ```bash 49 | php artisan migrate 50 | ``` 51 | 52 | Use this environment to manage library: 53 | 54 | ```dotenv 55 | # Global recorgin model changes history 56 | RECORD_CHANGES_HISTORY=true 57 | 58 | # Default storage for recorgin model changes history 59 | MODEL_CHANGES_HISTORY_STORAGE=database 60 | ``` 61 | 62 | **Explore 63 | the [config](https://github.com/Antonrom00/laravel-model-changes-history/blob/master/publishable/config/model_changes_history.php) 64 | for more detailed library setup.** 65 | 66 | ## Usage 67 | 68 | Add the trait to your model class you want to record changes history for: 69 | 70 | ```php 71 | use Antonrom\ModelChangesHistory\Traits\HasChangesHistory; 72 | use Illuminate\Database\Eloquent\Model; 73 | 74 | class TestModel extends Model { 75 | use HasChangesHistory; 76 | 77 | /** 78 | * The attributes that are mass assignable. 79 | * This will also be hidden for changes history. 80 | * 81 | * @var array 82 | */ 83 | protected $hidden = ['password', 'remember_token']; 84 | } 85 | 86 | ``` 87 | 88 | Your model now has a relation to all the changes made: 89 | 90 | ```php 91 | $testModel->latestChange(); 92 | 93 | Antonrom\ModelChangesHistory\Models\Change { 94 | ... 95 | #attributes: [ 96 | "model_id" => 1 97 | "model_type" => "App\TestModel" 98 | "before_changes" => "{...}" 99 | "after_changes" => "{...}" 100 | "change_type" => "updated" 101 | "changes" => "{ 102 | "title": { 103 | "before": "Some old title", 104 | "after": "This is the new title" 105 | }, 106 | "body": { 107 | "before": "Some old body", 108 | "after": "This is the new body" 109 | }, 110 | "password": { 111 | "before": "[hidden]", 112 | "after": "[hidden]" 113 | } 114 | }" 115 | "changer_type" => "App\User" 116 | "changer_id" => 1 117 | "stack_trace" => "{...}" 118 | "created_at" => "2020-01-21 17:34:31" 119 | ] 120 | ... 121 | } 122 | ``` 123 | 124 | #### Getting all changes history: 125 | 126 | ```php 127 | $testModel->historyChanges(); 128 | 129 | Illuminate\Database\Eloquent\Collection { 130 | #items: array:3 [ 131 | 0 => Antonrom\ModelChangesHistory\Models\Change {...} 132 | 1 => Antonrom\ModelChangesHistory\Models\Change {...} 133 | 2 => Antonrom\ModelChangesHistory\Models\Change {...} 134 | ... 135 | } 136 | ``` 137 | 138 | **If you use `database` storage you can also use morph relations to `Change` model:** 139 | 140 | ```php 141 | $testModel->latestChangeMorph(); 142 | $testModel->historyChangesMorph(); 143 | ``` 144 | 145 | #### Clearing changes history: 146 | 147 | ```php 148 | $testModel->clearHistoryChanges(); 149 | ``` 150 | 151 | #### Get an independent changes history: 152 | 153 | ```php 154 | use Antonrom\ModelChangesHistory\Facades\HistoryStorage; 155 | ... 156 | 157 | $latestChanges = HistoryStorage::getHistoryChanges(); // Return collection of all latest changes 158 | $latestChanges = HistoryStorage::getHistoryChanges($testModel); // Return collection of all latest changes for model 159 | 160 | $latestChange = HistoryStorage::getLatestChange(); // Return latest change 161 | $latestChange = HistoryStorage::getLatestChange($testModel); // Return latest change for model 162 | 163 | HistoryStorage::deleteHistoryChanges(); // This will delete all history changes 164 | HistoryStorage::deleteHistoryChanges($testModel); // This will delete all history changes for model 165 | ``` 166 | 167 | #### Getting model changer: 168 | 169 | ```php 170 | // Return Authenticatable `changer_type` using HasOne relation to changer_type and changer_id 171 | $changer = $latestChange->changer; 172 | ``` 173 | 174 | ##### If you use `database` storage you can use `Change` model as: 175 | 176 | ```php 177 | use Antonrom\ModelChangesHistory\Models\Change; 178 | 179 | // Get the updates on the given model, by the given user, in the last 30 days: 180 | Change::query() 181 | ->whereModel($testModel) 182 | ->whereChanger($user) 183 | ->whereType(Change::TYPE_UPDATED) 184 | ->whereCreatedBetween(now()->subDays(30), now()) 185 | ->get(); 186 | ``` 187 | 188 | #### Clearing changes history using console: 189 | 190 | ```bash 191 | php artisan changes-history:clear 192 | ``` 193 | 194 | You can use it in `Kelner`: 195 | 196 | ```php 197 | protected function schedule(Schedule $schedule) 198 | { 199 | $schedule->command('changes-history:clear')->monthly(); 200 | } 201 | ``` 202 | 203 | ## Donation 204 | 205 | If this project help you reduce time to develop, you can buy me a cup of 206 | coffee [![buy_me_a_coffee](https://cdn.buymeacoffee.com/buttons/bmc-new-btn-logo.svg)](https://www.buymeacoffee.com/antonrom) 207 | =) 208 | 209 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=P58FVTP9QTTEW&source=url) 210 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "antonrom00/laravel-model-changes-history", 3 | "description": "Model changes history for laravel", 4 | "license": "MIT", 5 | "type": "library", 6 | "version": "1.0", 7 | "keywords": [ 8 | "laravel", 9 | "framework", 10 | "model", 11 | "history", 12 | "changes" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Antonrom00", 17 | "email": "anton.romanyuk.2000@gmail.com" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=7.2", 22 | "laravel/framework": ">=5.8", 23 | "predis/predis": "^1.1", 24 | "ext-json": "*" 25 | }, 26 | "require-dev": { 27 | "orchestra/testbench": "^3.5|^4.2" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Antonrom\\ModelChangesHistory\\": "src/" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Antonrom\\ModelChangesHistory\\Tests\\": "tests/" 37 | } 38 | }, 39 | "extra": { 40 | "laravel": { 41 | "providers": [ 42 | "Antonrom\\ModelChangesHistory\\Providers\\ModelChangesHistoryServiceProvider" 43 | ], 44 | "aliases": { 45 | "ChangesHistory": "Antonrom\\ModelChangesHistory\\Facade\\HistoryChanges", 46 | "HistoryStorage": "Antonrom\\ModelChangesHistory\\Facade\\HistoryStorage" 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/Unit 14 | 15 | 16 | 17 | ./tests/Feature 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /publishable/config/model_changes_history.php: -------------------------------------------------------------------------------- 1 | env('RECORD_CHANGES_HISTORY', true), 17 | 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Record Model Changes History Using Only For Debugging 22 | |-------------------------------------------------------------------------- 23 | | 24 | | Supported: true or false 25 | | 26 | */ 27 | 28 | 'use_only_for_debug' => true, 29 | 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Recording Stack Trace in DB 34 | |-------------------------------------------------------------------------- 35 | | 36 | | This option controls the recording stack trace for getting Class, Function and Line of calling code 37 | | 38 | | Supported: true or false 39 | | 40 | */ 41 | 42 | 'record_stack_trace' => true, 43 | 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Ignoring actions of CRUD 48 | |-------------------------------------------------------------------------- 49 | | 50 | | This option controls the ignoring of recording changes types 51 | | 52 | */ 53 | 54 | 'ignored_actions' => [ 55 | // Change::TYPE_CREATED, 56 | // Change::TYPE_UPDATED, 57 | // Change::TYPE_DELETED, 58 | // Change::TYPE_RESTORED, 59 | // Change::TYPE_FORCE_DELETED, 60 | ], 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | Default Model Changes History Store 65 | |-------------------------------------------------------------------------- 66 | | 67 | | This option controls the default model changes history connection that gets used while 68 | | using this library. 69 | | 70 | | Supported: "database", "file", "redis" 71 | | 72 | */ 73 | 74 | 'storage' => env('MODEL_CHANGES_HISTORY_STORAGE', 'database'), 75 | 76 | /* 77 | |-------------------------------------------------------------------------- 78 | | Model Changes History Stores 79 | |-------------------------------------------------------------------------- 80 | | 81 | | Here you may define all of the model changes history "stores" for your application as 82 | | well as their drivers. 83 | | 84 | */ 85 | 86 | 'stores' => [ 87 | 88 | 'database' => [ 89 | 'driver' => 'database', 90 | 'table' => 'model_changes_history', 91 | 'connection' => null, 92 | ], 93 | 94 | 'file' => [ 95 | 'driver' => 'file', 96 | 'disk' => 'model_changes_history', 97 | 'file_name' => 'changes_history.txt', 98 | 99 | // This disk used as a default 100 | 'model_changes_history' => [ 101 | 'driver' => 'local', 102 | 'root' => storage_path('app/model_changes_history'), 103 | ], 104 | ], 105 | 106 | 'redis' => [ 107 | 'driver' => 'redis', 108 | 'key' => 'model_changes_history', 109 | 'connection' => 'model_changes_history', 110 | 111 | // This connection used as a default 112 | 'model_changes_history' => [ 113 | 'host' => env('REDIS_HOST', '127.0.0.1'), 114 | 'password' => env('REDIS_PASSWORD', null), 115 | 'port' => env('REDIS_PORT', 6379), 116 | 'database' => env('REDIS_DB', 0), 117 | ], 118 | ], 119 | 120 | ], 121 | 122 | ]; 123 | -------------------------------------------------------------------------------- /publishable/database/migrations/create_model_changes_history_table.php: -------------------------------------------------------------------------------- 1 | connection = config('model_changes_history.stores.database.connection', null); 16 | $this->tableName = config('model_changes_history.stores.database.table', 'model_changes_history'); 17 | } 18 | 19 | /** 20 | * Run the migrations. 21 | * 22 | * @return void 23 | */ 24 | public function up(): void 25 | { 26 | Schema::create($this->tableName, function (Blueprint $table) { 27 | $table->bigIncrements('id'); 28 | 29 | $table->unsignedBigInteger('model_id'); 30 | $table->string('model_type'); 31 | 32 | $table->json('before_changes')->nullable(); 33 | $table->json('after_changes')->nullable(); 34 | 35 | $table->json('changes')->nullable(); 36 | 37 | $table->enum('change_type', Change::getTypes()); 38 | 39 | $table->string('changer_type')->nullable(); 40 | $table->unsignedBigInteger('changer_id')->nullable(); 41 | 42 | $table->json('stack_trace')->nullable(); 43 | 44 | $table->timestamp(Model::CREATED_AT); 45 | }); 46 | } 47 | 48 | /** 49 | * Reverse the migrations. 50 | * 51 | * @return void 52 | */ 53 | public function down(): void 54 | { 55 | Schema::drop($this->tableName); 56 | } 57 | } -------------------------------------------------------------------------------- /src/Console/Commands/ChangesHistoryClearCommand.php: -------------------------------------------------------------------------------- 1 | 'datetime']; 63 | 64 | public static function getTypes(): array 65 | { 66 | return [ 67 | Change::TYPE_CREATED, 68 | Change::TYPE_UPDATED, 69 | Change::TYPE_DELETED, 70 | Change::TYPE_RESTORED, 71 | Change::TYPE_FORCE_DELETED, 72 | ]; 73 | } 74 | 75 | public function getTable(): string 76 | { 77 | return config('model_changes_history.stores.database.table', 'model_changes_history'); 78 | } 79 | 80 | 81 | /* 82 | * Scopes 83 | */ 84 | 85 | public function scopeWhereModel(Builder $query, Model $model): Builder 86 | { 87 | return $query->whereModelType(get_class($model))->whereModelId($model->id); 88 | } 89 | 90 | public function scopeWhereChanger(Builder $query, Authenticatable $changer): Builder 91 | { 92 | return $query->whereChangerType(get_class($changer))->whereChangerId($changer->id); 93 | } 94 | 95 | public function scopeWhereCreatedBetween(Builder $query, CarbonInterface $from, CarbonInterface $to): Builder 96 | { 97 | return $query->whereBetween(self::CREATED_AT, [$from, $to]); 98 | } 99 | 100 | public function scopeWhereType(Builder $query, string $type): Builder 101 | { 102 | return $query->whereChangeType($type); 103 | } 104 | 105 | 106 | /* 107 | * Relations 108 | */ 109 | 110 | public function model(): MorphTo 111 | { 112 | return $this->morphTo(); 113 | } 114 | 115 | public function changer(): HasOne 116 | { 117 | return $this->hasOne($this->changer_type, 'id', 'changer_id'); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/Observers/ModelChangesHistoryObserver.php: -------------------------------------------------------------------------------- 1 | ignoredActions = config('model_changes_history.ignored_actions', []); 21 | } 22 | 23 | public function created(Model $model) 24 | { 25 | if (!in_array(Change::TYPE_CREATED, $this->ignoredActions)) { 26 | HistoryStorage::recordChange(ChangesHistory::createChange(Change::TYPE_CREATED, $model, Auth::user())); 27 | } 28 | } 29 | 30 | public function updated(Model $model) 31 | { 32 | if (!in_array(Change::TYPE_UPDATED, $this->ignoredActions)) { 33 | HistoryStorage::recordChange(ChangesHistory::createChange(Change::TYPE_UPDATED, $model, Auth::user())); 34 | } 35 | } 36 | 37 | public function deleted(Model $model) 38 | { 39 | if (!in_array(Change::TYPE_DELETED, $this->ignoredActions)) { 40 | HistoryStorage::recordChange(ChangesHistory::createChange(Change::TYPE_DELETED, $model, Auth::user())); 41 | } 42 | } 43 | 44 | public function restored(Model $model) 45 | { 46 | if (!in_array(Change::TYPE_RESTORED, $this->ignoredActions)) { 47 | HistoryStorage::recordChange(ChangesHistory::createChange(Change::TYPE_RESTORED, $model, Auth::user())); 48 | } 49 | } 50 | 51 | public function forceDeleted(Model $model) 52 | { 53 | if (!in_array(Change::TYPE_FORCE_DELETED, $this->ignoredActions)) { 54 | HistoryStorage::recordChange(ChangesHistory::createChange(Change::TYPE_FORCE_DELETED, $model, Auth::user())); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Providers/ModelChangesHistoryServiceProvider.php: -------------------------------------------------------------------------------- 1 | 19 | config_path('model_changes_history.php'), 20 | ]; 21 | 22 | $timestamp = date('Y_m_d_His', time()); 23 | $tableName = config('model_changes_history.stores.database.table', 'model_changes_history'); 24 | 25 | $migrationDir = [ 26 | __DIR__ . '/../../publishable/database/migrations/create_model_changes_history_table.php' => 27 | database_path("/migrations/{$timestamp}_create_{$tableName}_table.php"), 28 | ]; 29 | 30 | $this->publishes($configDir, 'config'); 31 | $this->publishes($migrationDir, 'migrations'); 32 | $this->publishes(array_merge($configDir, $migrationDir), 'model-changes-history'); 33 | } 34 | 35 | /** 36 | * Register the provided classes. 37 | */ 38 | public function register() 39 | { 40 | $this->mergeConfigFrom( 41 | __DIR__ . '/../../publishable/config/model_changes_history.php', 'model_changes_history' 42 | ); 43 | 44 | config([ 45 | 'database.redis.model_changes_history' => 46 | config('model_changes_history.stores.redis.model_changes_history'), 47 | 48 | 'filesystems.disks.model_changes_history' => 49 | config('model_changes_history.stores.file.model_changes_history'), 50 | ]); 51 | 52 | $this->app->bind('changesHistory', ChangesHistoryService::class); 53 | $this->app->bind('historyStorage', HistoryStorageService::class); 54 | 55 | $this->commands(ChangesHistoryClearCommand::class); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Services/ChangesHistoryService.php: -------------------------------------------------------------------------------- 1 | recordStackTrace = config('model_changes_history.record_stack_trace', true); 22 | } 23 | 24 | public function createChange(string $type, Model $model, ?Authenticatable $changer = null): Change 25 | { 26 | return Change::make([ 27 | 'model_type' => $modelType = get_class($model), 28 | 'model_id' => $model->id, 29 | 30 | 'before_changes' => $originalModel = $this->getOriginalModel($model), 31 | 'after_changes' => $model->fresh(), 32 | 'changes' => $this->getAttributesChanges($model, $originalModel), 33 | 'change_type' => $type, 34 | 35 | 'changer_type' => $changer ? get_class($changer) : null, 36 | 'changer_id' => $changer->id ?? null, 37 | 38 | 'stack_trace' => $this->getStackStace(), 39 | 40 | 'created_at' => now(), 41 | ]); 42 | } 43 | 44 | protected function getOriginalModel(Model $model) 45 | { 46 | $originalModel = clone $model; 47 | foreach ($model->getAttributes() as $key => $afterValue) { 48 | $beforeValue = $model->getOriginal($key); 49 | $originalModel->$key = $beforeValue; 50 | } 51 | 52 | return $originalModel; 53 | } 54 | 55 | protected function getAttributesChanges(Model $model, ?Model $originalModel = null): Collection 56 | { 57 | $originalModel = $originalModel ? : $this->getOriginalModel($model); 58 | $hiddenFields = $model->getHidden(); 59 | $attributesChanges = collect(); 60 | 61 | $changes = $model->getChanges(); 62 | foreach ($changes as $key => $afterValue) { 63 | if (!in_array($key, $hiddenFields)) { 64 | $change = [ 65 | 'before' => $originalModel->$key, 66 | 'after' => $model->$key, 67 | ]; 68 | } else { 69 | $change = [ 70 | 'before' => self::VALUE_HIDDEN, 71 | 'after' => self::VALUE_HIDDEN, 72 | ]; 73 | } 74 | 75 | $attributesChanges->put($key, $change); 76 | } 77 | 78 | return $attributesChanges; 79 | } 80 | 81 | protected function getStackStace(): ?Collection 82 | { 83 | return $this->recordStackTrace 84 | ? collect(debug_backtrace()) 85 | ->where('class', 'Illuminate\Database\Eloquent\Model') 86 | ->whereIn('function', ['create', 'update', 'save', 'delete', 'restore', 'forceDelete']) 87 | ->values() 88 | : null; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Services/HistoryStorageService.php: -------------------------------------------------------------------------------- 1 | recordHistoryChanges = config('model_changes_history.record_changes_history', true) 36 | ? $recordChangesOnlyForDebug 37 | : false; 38 | 39 | $this->historyStorage = HistoryStorageRegistry::create() 40 | ->get(config('model_changes_history.storage', HistoryStorageRegistry::STORAGE_DATABASE)); 41 | } 42 | 43 | public function recordChange(Change $change): void 44 | { 45 | if ($this->recordHistoryChanges) { 46 | $this->historyStorage->recordChange($change); 47 | } 48 | } 49 | 50 | public function getHistoryChanges(?Model $model = null): Collection 51 | { 52 | return $this->historyStorage->getHistoryChanges($model); 53 | } 54 | 55 | public function getLatestChange(?Model $model = null): ?Change 56 | { 57 | return $this->historyStorage->getLatestChange($model); 58 | } 59 | 60 | public function deleteHistoryChanges(?Model $model = null): void 61 | { 62 | $this->historyStorage->deleteHistoryChanges($model); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Stores/DatabaseHistoryStorage.php: -------------------------------------------------------------------------------- 1 | tableName = config('model_changes_history.stores.database.table', 'model_changes_history'); 21 | } 22 | 23 | public function recordChange(Change $change): void 24 | { 25 | $change->save(); 26 | } 27 | 28 | public function getHistoryChanges(?Model $model = null): Collection 29 | { 30 | return $model ? $model->historyChangesMorph : Change::latest()->get(); 31 | } 32 | 33 | public function getLatestChange(?Model $model = null): ?Change 34 | { 35 | return $model ? $model->latestChangeMorph : $this->getHistoryChanges()->last(); 36 | } 37 | 38 | public function deleteHistoryChanges(?Model $model = null): void 39 | { 40 | $model ? $model->historyChangesMorph()->delete() : DB::table($this->tableName)->truncate(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Stores/FileHistoryStorage.php: -------------------------------------------------------------------------------- 1 | storage = Storage::disk(config('model_changes_history.stores.file.disk', 'model_changes_history')); 28 | $this->fileName = config('model_changes_history.stores.file.file_name', 'changes_history.txt'); 29 | } 30 | 31 | public function recordChange(Change $change): void 32 | { 33 | $this->storage->append($this->fileName, $change->toJson()); 34 | } 35 | 36 | public function getHistoryChanges(?Model $model = null): Collection 37 | { 38 | return $model ? $this->getModelHistoryChanges($model) : $this->getAllChanges(); 39 | } 40 | 41 | public function getLatestChange(?Model $model = null): ?Change 42 | { 43 | return $this->getHistoryChanges($model)->last(); 44 | } 45 | 46 | public function deleteHistoryChanges(?Model $model = null): void 47 | { 48 | $model ? $this->deleteModelHistoryChanges($model) : $this->storage->delete($this->fileName); 49 | } 50 | 51 | protected function getAllChanges(): Collection 52 | { 53 | try { 54 | $fileContent = $this->storage->get($this->fileName); 55 | } catch (FileNotFoundException $e) { 56 | return collect(); 57 | } 58 | 59 | $historyChanges = collect(); 60 | 61 | $fileLines = explode("\n", $fileContent); 62 | foreach ($fileLines as $change) { 63 | $historyChanges->add(Change::make(json_decode($change, true))); 64 | } 65 | 66 | return $historyChanges; 67 | } 68 | 69 | protected function getModelHistoryChanges(Model $model): Collection 70 | { 71 | $historyChanges = $this->getAllChanges(); 72 | 73 | return $historyChanges->where('model_type', get_class($model)) 74 | ->where('model_id', $model->id) 75 | ->values(); 76 | } 77 | 78 | protected function deleteModelHistoryChanges(Model $model): void 79 | { 80 | $historyChanges = $this->getAllChanges(); 81 | $this->storage->delete($this->fileName); 82 | 83 | $newHistoryChanges = $historyChanges->where('model_type', get_class($model)) 84 | ->where('model_id', '!=', $model->id) 85 | ->values(); 86 | 87 | foreach ($newHistoryChanges as $change) { 88 | $this->storage->append($this->fileName, $change->toJson()); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Stores/HistoryStorageRegistry.php: -------------------------------------------------------------------------------- 1 | DatabaseHistoryStorage::class, 16 | self::STORAGE_REDIS => RedisHistoryStorage::class, 17 | self::STORAGE_FILE => FileHistoryStorage::class, 18 | ]; 19 | 20 | private $container = []; 21 | 22 | /** 23 | * Create the instance of the class 24 | * 25 | * @return static 26 | */ 27 | public static function create(): self 28 | { 29 | return new self(); 30 | } 31 | 32 | /** 33 | * Get the instance of the class history storage from container 34 | * 35 | * @param string $name 36 | * 37 | * @return HistoryStorageInterface 38 | * @throws StorageNotFoundException 39 | */ 40 | public function get(string $name): HistoryStorageInterface 41 | { 42 | if (!isset($this->storagesMap[$name])) { 43 | throw new StorageNotFoundException; 44 | } 45 | 46 | if (!isset($this->container[$name])) { 47 | $this->container[$name] = new $this->storagesMap[$name]; 48 | } 49 | 50 | return $this->container[$name]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Stores/RedisHistoryStorage.php: -------------------------------------------------------------------------------- 1 | connection = Redis::connection(config('model_changes_history.stores.redis.connection', 'model_changes_history')); 27 | $this->key = config('model_changes_history.stores.redis.key', 'model_changes_history'); 28 | } 29 | 30 | public function recordChange(Change $change): void 31 | { 32 | $this->connection->zadd($this->key, [$change->toJson() => now()->timestamp]); 33 | } 34 | 35 | public function getHistoryChanges(?Model $model = null): Collection 36 | { 37 | return $model ? $this->getModelHistoryChanges($model) : $this->getAllChanges(); 38 | } 39 | 40 | public function getLatestChange(?Model $model = null): ?Change 41 | { 42 | return $this->getHistoryChanges($model)->last(); 43 | } 44 | 45 | public function deleteHistoryChanges(?Model $model = null): void 46 | { 47 | $model 48 | ? $this->deleteModelHistoryChanges($model) 49 | : $this->connection->zremrangebyrank($this->key, 0, -1); 50 | } 51 | 52 | protected function getAllChanges(): Collection 53 | { 54 | $changes = $this->connection->zrange($this->key, 0, -1); 55 | 56 | $historyChanges = collect(); 57 | foreach ($changes as $change) { 58 | $historyChanges->add(Change::make(json_decode($change, true))); 59 | } 60 | 61 | return $historyChanges; 62 | } 63 | 64 | protected function getModelHistoryChanges(Model $model): Collection 65 | { 66 | $historyChanges = $this->getAllChanges(); 67 | 68 | return $historyChanges->where('model_type', get_class($model)) 69 | ->where('model_id', $model->id) 70 | ->values(); 71 | } 72 | 73 | protected function deleteModelHistoryChanges(Model $model): void 74 | { 75 | $historyChanges = $this->getModelHistoryChanges($model); 76 | 77 | foreach ($historyChanges as $change) { 78 | $this->connection->zrem($this->key, $change->toJson()); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Traits/HasChangesHistory.php: -------------------------------------------------------------------------------- 1 | morphOne(Change::class, 'model')->latest(); 29 | } 30 | 31 | public function historyChanges(): Collection 32 | { 33 | return HistoryStorage::getHistoryChanges($this); 34 | } 35 | 36 | public function historyChangesMorph(): MorphMany 37 | { 38 | return $this->morphMany(Change::class, 'model')->latest(); 39 | } 40 | 41 | public function clearHistoryChanges(): void 42 | { 43 | HistoryStorage::deleteHistoryChanges($this); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | setupDatabase(); 21 | } 22 | 23 | /** 24 | * Get the package service providers. 25 | * 26 | * @param \Illuminate\Foundation\Application $app 27 | * 28 | * @return array 29 | */ 30 | protected function getPackageProviders($app): array 31 | { 32 | return [ 33 | ModelChangesHistoryServiceProvider::class, 34 | ]; 35 | } 36 | 37 | /** 38 | * Get the package service aliases. 39 | * 40 | * @param \Illuminate\Foundation\Application $app 41 | * 42 | * @return array 43 | */ 44 | protected function getPackageAliases($app): array 45 | { 46 | return [ 47 | 'ChangesHistory' => 'Antonrom\ModelChangesHistory\Facade\HistoryChanges', 48 | 'Storages' => 'Antonrom\ModelChangesHistory\Facade\HistoryStorage', 49 | ]; 50 | } 51 | 52 | /** 53 | * Define environment setup. 54 | * 55 | * @param \Illuminate\Foundation\Application $app 56 | * @return void 57 | */ 58 | protected function getEnvironmentSetUp($app): void 59 | { 60 | // Setup default database to use sqlite :memory: 61 | $app['config']->set('app.debug', true); 62 | $app['config']->set('model_changes_history.storage', 'database'); 63 | $app['config']->set('database.default', 'testbench'); 64 | $app['config']->set('database.connections.testbench', [ 65 | 'driver' => 'sqlite', 66 | 'database' => ':memory:', 67 | 'prefix' => '', 68 | ]); 69 | } 70 | 71 | protected function setupDatabase(): void 72 | { 73 | include_once __DIR__ . '/../publishable/database/migrations/create_model_changes_history_table.php'; 74 | 75 | (new CreateModelChangesHistoryTable())->up(); 76 | 77 | Schema::create('test_models', function (Blueprint $table) { 78 | $table->increments('id'); 79 | $table->string('title')->nullable(); 80 | $table->string('body')->nullable(); 81 | $table->string('password')->nullable(); 82 | $table->softDeletes(); 83 | }); 84 | } 85 | } -------------------------------------------------------------------------------- /tests/Unit/ChangesHistoryServiceTest.php: -------------------------------------------------------------------------------- 1 | 'Test title', 16 | 'body' => 'Test body', 17 | ]); 18 | 19 | $change = ChangesHistory::createChange(Change::TYPE_CREATED, $testModel); 20 | 21 | $this->assertEquals(Change::TYPE_CREATED, $change->change_type); 22 | $this->assertEquals(get_class($testModel), $change->model_type); 23 | $this->assertEquals(collect(), $change->changes); 24 | $this->assertNull($change->changer_type); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Unit/HistoryStorageServiceTest.php: -------------------------------------------------------------------------------- 1 | table = config('model_changes_history.stores.database.table'); 27 | $this->testModel = TestModel::create([ 28 | 'title' => 'Test title', 29 | 'body' => 'Test body', 30 | 'password' => 'Test password', 31 | ]); 32 | } 33 | 34 | public function testRecordChangeCreate() 35 | { 36 | $this->assertDatabaseHas($this->table, [ 37 | 'model_type' => get_class($this->testModel), 38 | 'change_type' => Change::TYPE_CREATED, 39 | ]); 40 | } 41 | 42 | public function testRecordChangeUpdate() 43 | { 44 | $originalModel = clone $this->testModel; 45 | $this->testModel->update([ 46 | 'title' => 'Test title updated', 47 | 'body' => 'Test body updated', 48 | 'password' => 'Test password updated', 49 | ]); 50 | 51 | $this->assertDatabaseHas($this->table, [ 52 | 'model_id' => $this->testModel->id, 53 | 'model_type' => get_class($this->testModel), 54 | 'change_type' => Change::TYPE_UPDATED, 55 | 'changes' => json_encode([ 56 | 'title' => [ 57 | 'before' => $originalModel->title, 58 | 'after' => $this->testModel->title, 59 | ], 60 | 'body' => [ 61 | 'before' => $originalModel->body, 62 | 'after' => $this->testModel->body, 63 | ], 64 | 'password' => [ 65 | 'before' => ChangesHistoryService::VALUE_HIDDEN, 66 | 'after' => ChangesHistoryService::VALUE_HIDDEN, 67 | ], 68 | ]), 69 | ]); 70 | } 71 | 72 | public function testRecordChangeDelete() 73 | { 74 | $this->testModel->delete(); 75 | $this->assertDatabaseHas($this->table, [ 76 | 'model_type' => get_class($this->testModel), 77 | 'change_type' => Change::TYPE_DELETED, 78 | ]); 79 | } 80 | 81 | public function testRecordChangeRestore() 82 | { 83 | $this->testModel->restore(); 84 | $this->assertDatabaseHas($this->table, [ 85 | 'model_type' => get_class($this->testModel), 86 | 'change_type' => Change::TYPE_RESTORED, 87 | ]); 88 | } 89 | 90 | public function testRecordChangeForceDelete() 91 | { 92 | $this->testModel->forceDelete(); 93 | $this->assertDatabaseHas($this->table, [ 94 | 'model_type' => get_class($this->testModel), 95 | 'change_type' => Change::TYPE_FORCE_DELETED, 96 | ]); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/fixtures/TestModel.php: -------------------------------------------------------------------------------- 1 |