├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── model-auditlog.php ├── docker-compose.yml ├── src ├── AuditLogServiceProvider.php ├── Console │ └── Commands │ │ └── MakeModelAuditLogTable.php ├── EventType.php ├── Models │ └── BaseModel.php ├── Observers │ └── AuditLogObserver.php └── Traits │ ├── AuditLoggable.php │ └── AuditLoggablePivot.php └── stubs ├── migration.stub └── model.stub /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 ORIS Intelligence, LLC 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Model Auditlog 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/orisintel/laravel-model-auditlog.svg?style=flat-square)](https://packagist.org/packages/orisintel/laravel-model-auditlog) 4 | [![Build Status](https://img.shields.io/github/workflow/status/orisintel/laravel-model-auditlog/tests?style=flat-square)](https://github.com/orisintel/laravel-model-auditlog/actions?query=workflow%3Atests) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/orisintel/laravel-model-auditlog.svg?style=flat-square)](https://packagist.org/packages/orisintel/laravel-model-auditlog) 6 | 7 | When modifying a model record, it is nice to have a log of the changes made and who made those changes. There are many packages around this already, but this one is different in that it logs those changes to individual tables for performance and supports real foreign keys. 8 | 9 | ## Installation 10 | 11 | You can install the package via composer: 12 | 13 | ```bash 14 | composer require orisintel/laravel-model-auditlog 15 | ``` 16 | 17 | ## Configuration 18 | 19 | ``` php 20 | php artisan vendor:publish --provider="\OrisIntel\AuditLog\AuditLogServiceProvider" 21 | ``` 22 | 23 | Running the above command will publish the config file. 24 | 25 | ## Usage 26 | 27 | After adding the proper fields to your table, add the trait to your model. 28 | 29 | ``` php 30 | // User model 31 | class User extends Model 32 | { 33 | use \OrisIntel\AuditLog\Traits\AuditLoggable; 34 | 35 | ``` 36 | 37 | To generate an auditlog model / migration for your models, use the following command: 38 | 39 | ```sh 40 | php artisan make:model-auditlog "\App\User" 41 | ``` 42 | 43 | Replace `\App\User` with your own model name. Model / table options can be tweaked in the config file. 44 | 45 | If you need to ignore specific fields on your model, extend the `getAuditLogIgnoredFields()` method and return an array of fields. 46 | 47 | ```php 48 | public function getAuditLogIgnoredFields() : array 49 | { 50 | return ['posted_at']; 51 | } 52 | ``` 53 | 54 | Using that functionality, you can add more custom logic around what should be logged. An example might be to not log the title changes of a post if the post has not been published yet. 55 | ```php 56 | public function getAuditLogIgnoredFields() : array 57 | { 58 | if ($this->postHasBeenPublished()) { 59 | return ['title']; 60 | } 61 | 62 | return []; 63 | } 64 | ``` 65 | 66 | ### Working with Pivot Tables 67 | 68 | Audit log can also support changes on pivot models as well. 69 | 70 | In this example we have a `posts` and `tags` table with a `post_tags` pivot table containing a `post_id` and `tag_id`. 71 | 72 | Modify the audit log migration replacing the `subject_id` column to use the two pivot columns. 73 | ```php 74 | Schema::create('post_tag_auditlog', function (Blueprint $table) { 75 | $table->bigIncrements('id'); 76 | $table->unsignedInteger('post_id')->index(); 77 | $table->unsignedInteger('tag_id')->index(); 78 | $table->unsignedTinyInteger('event_type')->index(); 79 | $table->unsignedInteger('user_id')->nullable()->index(); 80 | $table->string('field_name')->index(); 81 | $table->text('field_value_old')->nullable(); 82 | $table->text('field_value_new')->nullable(); 83 | $table->timestamp('occurred_at')->index()->default('CURRENT_TIMESTAMP'); 84 | }); 85 | ``` 86 | 87 | Create a model for the pivot table that extends Laravel's Pivot class. This class must use the AuditLoggablePivot trait and have a defined `$audit_loggable_keys` variable, which is used to map the pivot to the audit log table. 88 | 89 | ```php 90 | class PostTag extends Pivot 91 | { 92 | use AuditLoggablePivot; 93 | 94 | /** 95 | * The array keys are the composite key in the audit log 96 | * table while the pivot table columns are the values. 97 | * 98 | * @var array 99 | */ 100 | protected $audit_loggable_keys = [ 101 | 'post_id' => 'post_id', 102 | 'tag_id' => 'tag_id', 103 | ]; 104 | } 105 | ``` 106 | Side note: if a column shares the same name in the pivot and a column already in the audit log table (ex: `user_id`), change the name of the column in the audit log table (ex: `audit_user_id`) and define the relationship as `'audit_user_id' => 'user_id'`. 107 | 108 | The two models that are joined by the pivot will need to be updated so that events fire on the pivot model. Currently Laravel doesn't support pivot events so a third party package is required. 109 | ```php 110 | composer require fico7489/laravel-pivot 111 | ``` 112 | 113 | Have both models use the PivotEventTrait 114 | ```php 115 | use Fico7489\Laravel\Pivot\Traits\PivotEventTrait; 116 | use Illuminate\Database\Eloquent\Model; 117 | 118 | class Post extends Model 119 | { 120 | use PivotEventTrait; 121 | ``` 122 | 123 | Modify the belongsToMany join on both related models to include the using function along with the pivot model. 124 | In the Post model: 125 | ```php 126 | public function tags() 127 | { 128 | return $this->belongsToMany(Tag::class) 129 | ->using(PostTag::class); 130 | } 131 | ``` 132 | In the Tag model: 133 | ```php 134 | public function posts() 135 | { 136 | return $this->belongsToMany(Post::class) 137 | ->using(PostTag::class); 138 | } 139 | ``` 140 | 141 | When a pivot record is deleted through `detach` or `sync`, an audit log record for each of the keys (ex: `post_id` and `tag_id`) will added to the audit log table. The `field_value_old` will be the id of the record and the `field_value_new` will be null. The records will have an event type of `PIVOT_DELETED` (id: 6). 142 | 143 | If you need to pull the audit logs through the `auditLogs` relationship (ex: $post_tag->auditLogs()->get()), support for composite keys is required. 144 | ```php 145 | composer require awobaz/compoships 146 | ``` 147 | Then use the trait on the pivot audit log model: 148 | ```php 149 | use Awobaz\Compoships\Compoships; 150 | use OrisIntel\AuditLog\Models\BaseModel; 151 | 152 | class PostTagAuditLog extends BaseModel 153 | { 154 | use Compoships; 155 | ``` 156 | 157 | For a working example of pivots with the audit log, see `laravel-model-auditlog/tests/Fakes`, which contains working migrations and models. 158 | 159 | Note: 160 | Both models must use the AuditLoggable trait (ex: Post and Tag) so that `$post->tags()->sync([...])` will work. 161 | 162 | ### Testing 163 | 164 | ``` bash 165 | composer test 166 | ``` 167 | 168 | ### Using Docker 169 | All assets are set up under the docker-compose.yml file. The first time you run the docker image you must build it with 170 | the following command: 171 | ```bash 172 | docker-compose build 173 | ``` 174 | 175 | Then you can bring it up in the background using: 176 | ```bash 177 | docker-compose up -d 178 | ``` 179 | 180 | And the image is aliased so you can access its command line via: 181 | ```bash 182 | docker exec -it processes-stamp-app /bin/bash 183 | ``` 184 | 185 | From there you can run the tests within an isolated environment 186 | 187 | ## Contributing 188 | 189 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 190 | 191 | ### Security 192 | 193 | If you discover any security related issues, please email [security@orisintel.com](mailto:security@orisintel.com) instead of using the issue tracker. 194 | 195 | ## Credits 196 | 197 | - [Tom Schlick](https://github.com/tomschlick) 198 | - [All Contributors](../../contributors) 199 | 200 | ## License 201 | 202 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 203 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orisintel/laravel-model-auditlog", 3 | "description": "Tracks changes made to models and logs them to individual tables. ", 4 | "keywords": [ 5 | "orisintel", 6 | "auditlog", 7 | "laravel", 8 | "logging" 9 | ], 10 | "homepage": "https://github.com/orisintel/laravel-model-auditlog", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Tom Schlick", 15 | "email": "tschlick@orisintel.com", 16 | "role": "Developer" 17 | }, 18 | { 19 | "name": "ORIS Intelligence", 20 | "email": "developers@orisintel.com", 21 | "homepage": "https://orisintel.com", 22 | "role": "Organization" 23 | } 24 | ], 25 | "require": { 26 | "php": "^7.3|^8.0", 27 | "awobaz/compoships": "^2.0.3", 28 | "fico7489/laravel-pivot": "^3.0.1", 29 | "laravel/framework": "^8.0", 30 | "orisintel/laravel-process-stamps": "^3.0" 31 | }, 32 | "require-dev": { 33 | "doctrine/dbal": "^2.9", 34 | "larapack/dd": "^1.0", 35 | "mockery/mockery": "~1.0", 36 | "orchestra/testbench": "^6.0", 37 | "phpunit/phpunit": "^9.0" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "OrisIntel\\AuditLog\\": "src" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "OrisIntel\\AuditLog\\Tests\\": "tests" 47 | } 48 | }, 49 | "scripts": { 50 | "test": "vendor/bin/phpunit", 51 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 52 | }, 53 | "config": { 54 | "sort-packages": true 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "OrisIntel\\AuditLog\\AuditLogServiceProvider" 60 | ] 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /config/model-auditlog.php: -------------------------------------------------------------------------------- 1 | '_auditlog', 13 | 14 | /* 15 | * This is the default suffix applied to models' class names. 16 | * 17 | * Example: The User model would have an audit log model of UserAuditLog. 18 | */ 19 | 'model_suffix' => 'AuditLog', 20 | 21 | 'model_path' => app_path(), 22 | 23 | 'model_stub' => __DIR__ . '/../stubs/model.stub', 24 | 25 | 'migration_path' => database_path('migrations'), 26 | 27 | 'migration_stub' => __DIR__ . '/../stubs/migration.stub', 28 | 29 | /* 30 | * Enable foreign keys between the audit tables and the subject model's primary key. 31 | */ 32 | 'enable_subject_foreign_keys' => true, 33 | 34 | /* 35 | * Enable foreign keys between the audit tables' user fields and the users model. 36 | */ 37 | 'enable_user_foreign_keys' => true, 38 | 39 | 'user_model' => '', 40 | 41 | /* 42 | * Enable the process stamps (sub) package to log which process/url/job invoked a change. 43 | */ 44 | 'enable_process_stamps' => true, 45 | 46 | /* 47 | * Fields that should be ignored in the audit logs for every model. 48 | */ 49 | 'global_ignored_fields' => [ 50 | 'id', 51 | 'created_at', 52 | 'updated_at', 53 | ], 54 | 55 | /* 56 | * Function on the auth service provider that will return the user id editing a model 57 | */ 58 | 'auth_id_function' => 'id', 59 | ]; 60 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | app: 4 | build: 5 | context: ./ 6 | dockerfile: ./docker/Dockerfile 7 | image: auditlog 8 | container_name: auditlog-app 9 | restart: unless-stopped 10 | working_dir: /var/www/ 11 | volumes: 12 | - ./:/var/www 13 | -------------------------------------------------------------------------------- /src/AuditLogServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 16 | $this->publishes([ 17 | __DIR__.'/../config/model-auditlog.php' => config_path('model-auditlog.php'), 18 | ], 'config'); 19 | } 20 | } 21 | 22 | /** 23 | * Register the application services. 24 | */ 25 | public function register() 26 | { 27 | $this->mergeConfigFrom(__DIR__.'/../config/model-auditlog.php', 'model-auditlog'); 28 | 29 | if ($this->app->runningInConsole()) { 30 | $this->commands([ 31 | MakeModelAuditLogTable::class, 32 | ]); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeModelAuditLogTable.php: -------------------------------------------------------------------------------- 1 | argument('existing-model-class'); 37 | 38 | if (! class_exists($class)) { 39 | $this->error("Class {$class} could not be found"); 40 | 41 | return; 42 | } 43 | 44 | $this->line("Generating audit log model and table migration for: $class"); 45 | 46 | $subject_model = new $class(); 47 | $config = config('model-auditlog'); 48 | 49 | $this->line("Audit Table will be {$this->generateAuditTableName($subject_model, $config)}"); 50 | $this->createMigration($subject_model, $config); 51 | 52 | $this->line("Audit Model will be {$this->generateAuditModelName($subject_model, $config)}"); 53 | $this->createModel($subject_model, $config); 54 | } 55 | 56 | /** 57 | * @param Model $subject_model 58 | * @param array $config 59 | * 60 | * @return string 61 | */ 62 | public function generateAuditTableName($subject_model, array $config): string 63 | { 64 | return $subject_model->getTable() . $config['table_suffix']; 65 | } 66 | 67 | /** 68 | * @param Model $subject_model 69 | * @param array $config 70 | * 71 | * @return string 72 | */ 73 | public function generateAuditModelName($subject_model, array $config): string 74 | { 75 | return class_basename($subject_model) . $config['model_suffix']; 76 | } 77 | 78 | /** 79 | * @param Model $subject_model 80 | * 81 | * @throws \ReflectionException 82 | * 83 | * @return string 84 | */ 85 | public function getModelNamespace($subject_model): string 86 | { 87 | return (new ReflectionClass($subject_model))->getNamespaceName(); 88 | } 89 | 90 | /** 91 | * @param Model $subject_model 92 | * @param array $config 93 | * 94 | * @throws \ReflectionException 95 | */ 96 | public function createModel($subject_model, array $config): void 97 | { 98 | $modelname = $this->generateAuditModelName($subject_model, $config); 99 | 100 | $stub = $this->getStubWithReplacements($config['model_stub'], [ 101 | '{TABLE_NAME}' => $this->generateAuditTableName($subject_model, $config), 102 | '{CLASS_NAME}' => $modelname, 103 | '{NAMESPACE}' => $this->getModelNamespace($subject_model), 104 | ]); 105 | 106 | $filename = $config['model_path'] . DIRECTORY_SEPARATOR . $modelname . '.php'; 107 | 108 | if (file_put_contents($filename, $stub)) { 109 | $this->info("Model successfully created at: $filename"); 110 | } 111 | } 112 | 113 | /** 114 | * @param Model $subject_model 115 | * @param array $config 116 | */ 117 | public function createMigration($subject_model, array $config): void 118 | { 119 | $tablename = $this->generateAuditTableName($subject_model, $config); 120 | $fileslug = "create_{$tablename}_table"; 121 | 122 | $stub = $this->getStubWithReplacements($config['migration_stub'], [ 123 | '{TABLE_NAME}' => $tablename, 124 | '{CLASS_NAME}' => $this->generateMigrationClassname($fileslug), 125 | '{PROCESS_IDS_SETUP}' => $this->generateMigrationProcessStamps($config), 126 | '{FOREIGN_KEY_SUBJECT}' => $this->generateMigrationSubjectForeignKeys($subject_model, $config), 127 | '{FOREIGN_KEY_USER}' => $this->generateMigrationUserForeignKeys($config), 128 | ]); 129 | 130 | $filename = $config['migration_path'] . DIRECTORY_SEPARATOR . $this->generateMigrationFilename($fileslug); 131 | 132 | if (file_put_contents($filename, $stub)) { 133 | $this->info("Migration successfully created at: $filename"); 134 | } 135 | } 136 | 137 | /** 138 | * @param string $fileslug 139 | * 140 | * @return string 141 | */ 142 | public function generateMigrationFilename(string $fileslug): string 143 | { 144 | return Str::snake(Str::lower(date('Y_m_d_His') . ' ' . $fileslug . '.php')); 145 | } 146 | 147 | /** 148 | * @param string $fileslug 149 | * 150 | * @return string 151 | */ 152 | public function generateMigrationClassname(string $fileslug): string 153 | { 154 | return Str::studly($fileslug); 155 | } 156 | 157 | /** 158 | * @param string $file 159 | * @param array $replacements 160 | * 161 | * @return string 162 | */ 163 | public function getStubWithReplacements(string $file, array $replacements): string 164 | { 165 | return str_replace( 166 | array_keys($replacements), 167 | array_values($replacements), 168 | file_get_contents(realpath($file)) 169 | ); 170 | } 171 | 172 | /** 173 | * @param Model $subject_model 174 | * @param array $config 175 | * 176 | * @return string 177 | */ 178 | public function generateMigrationSubjectForeignKeys($subject_model, array $config): string 179 | { 180 | if (Arr::get($config, 'enable_subject_foreign_keys') === true) { 181 | return '$table->foreign(\'subject_id\') 182 | ->references(\'' . $subject_model->getKeyName() . '\') 183 | ->on(\'' . $subject_model->getTable() . '\');'; 184 | } 185 | 186 | return ''; 187 | } 188 | 189 | /** 190 | * @param array $config 191 | * 192 | * @return string 193 | */ 194 | public function generateMigrationUserForeignKeys(array $config): string 195 | { 196 | $user_model = new $config['user_model'](); 197 | if (Arr::get($config, 'enable_user_foreign_keys') === true && ! empty($user_model)) { 198 | $user_table = $user_model->getTable(); 199 | $user_primary = $user_model->getKeyName(); 200 | 201 | return '$table->foreign(\'user_id\') 202 | ->references(\'' . $user_primary . '\') 203 | ->on(\'' . $user_table . '\');'; 204 | } 205 | 206 | return ''; 207 | } 208 | 209 | /** 210 | * @param array $config 211 | * 212 | * @return string 213 | */ 214 | public function generateMigrationProcessStamps(array $config): string 215 | { 216 | if (Arr::get($config, 'enable_process_stamps') === true) { 217 | return '$table->processIds();'; 218 | } 219 | 220 | return ''; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/EventType.php: -------------------------------------------------------------------------------- 1 | saveChanges( 29 | $this->passingChanges($changes, $model), 30 | $event_type, 31 | $model 32 | ); 33 | } 34 | 35 | /** 36 | * @param array $changes 37 | * @param $model 38 | * 39 | * @return Collection 40 | */ 41 | public function passingChanges(array $changes, $model): Collection 42 | { 43 | return collect($changes) 44 | ->except(config('model-auditlog.global_ignored_fields')) 45 | ->except($model->getAuditLogIgnoredFields()) 46 | ->except([ 47 | $model->getKeyName(), // Ignore the current model's primary key 48 | 'created_at', 49 | 'updated_at', 50 | 'date_created', 51 | 'date_modified', 52 | ]); 53 | } 54 | 55 | /** 56 | * @param Collection $passing_changes 57 | * @param int $event_type 58 | * @param Model $model 59 | */ 60 | public function saveChanges(Collection $passing_changes, int $event_type, $model): void 61 | { 62 | $passing_changes 63 | ->each(function ($change, $key) use ($event_type, $model) { 64 | $log = new static(); 65 | $log->event_type = $event_type; 66 | $log->occurred_at = now(); 67 | 68 | foreach ($model->getAuditLogForeignKeyColumns() as $k => $v) { 69 | $log->setAttribute($k, $model->$v); 70 | } 71 | 72 | if (config('model-auditlog.enable_user_foreign_keys')) { 73 | $log->user_id = Auth::{config('model-auditlog.auth_id_function', 'id')}(); 74 | } 75 | 76 | $log->setAttribute('field_name', $key); 77 | if ($event_type !== EventType::DELETED and $model->getRawOriginal($key) !== $change) { 78 | $log->setAttribute('field_value_old', $model->getRawOriginal($key)); 79 | } 80 | $log->setAttribute('field_value_new', $change); 81 | 82 | $log->attributes; 83 | $log->save(); 84 | }); 85 | } 86 | 87 | /** 88 | * @param int $event_type 89 | * @param $model 90 | * @param string $relationName 91 | * @param array $pivotIds 92 | */ 93 | public function recordPivotChanges(int $event_type, $model, string $relationName, array $pivotIds): void 94 | { 95 | $pivot = $model->{$relationName}()->getPivotClass(); 96 | 97 | $changes = $this->getPivotChanges($pivot, $model, $pivotIds); 98 | 99 | foreach ($changes as $change) { 100 | $this->savePivotChanges( 101 | $this->passingChanges($change, $model), 102 | $event_type, 103 | (new $pivot()) 104 | ); 105 | } 106 | } 107 | 108 | /** 109 | * @param $pivot 110 | * @param $model 111 | * @param $pivotIds 112 | * 113 | * @return array 114 | */ 115 | public function getPivotChanges($pivot, $model, array $pivotIds): array 116 | { 117 | $columns = (new $pivot())->getAuditLogForeignKeyColumns(); 118 | $key = in_array($model->getForeignKey(), $columns) ? $model->getForeignKey() : $model->getKeyName(); 119 | 120 | $changes = []; 121 | foreach ($pivotIds as $id => $pivotId) { 122 | foreach ($columns as $auditColumn => $pivotColumn) { 123 | if ($pivotColumn !== $key) { 124 | $changes[$id][$auditColumn] = $pivotId; 125 | } else { 126 | $changes[$id][$auditColumn] = $model->getKey(); 127 | } 128 | } 129 | } 130 | 131 | return $changes; 132 | } 133 | 134 | /** 135 | * @param Collection $passing_changes 136 | * @param int $event_type 137 | * @param $pivot 138 | */ 139 | public function savePivotChanges(Collection $passing_changes, int $event_type, $pivot): void 140 | { 141 | $passing_changes 142 | ->each(function ($change, $key) use ($event_type, $passing_changes, $pivot) { 143 | $log = $pivot->getAuditLogModelInstance(); 144 | $log->event_type = $event_type; 145 | $log->occurred_at = now(); 146 | 147 | foreach ($passing_changes as $k => $v) { 148 | $log->setAttribute($k, $v); 149 | } 150 | 151 | if (config('model-auditlog.enable_user_foreign_keys')) { 152 | $log->user_id = \Auth::{config('model-auditlog.auth_id_function', 'id')}(); 153 | } 154 | 155 | $log->setAttribute('field_name', $key); 156 | $log->setAttribute('field_value_old', $change); 157 | $log->setAttribute('field_value_new', null); 158 | 159 | $log->attributes; 160 | $log->save(); 161 | }); 162 | } 163 | 164 | /** 165 | * @param int $event_type 166 | * @param Model $model 167 | * 168 | * @return array 169 | */ 170 | public static function getChangesByType(int $event_type, $model): array 171 | { 172 | switch ($event_type) { 173 | case EventType::CREATED: 174 | return $model->getAttributes(); 175 | break; 176 | case EventType::RESTORED: 177 | return $model->getChanges(); 178 | break; 179 | case EventType::FORCE_DELETED: 180 | return []; // if force deleted we want to stop execution here as there would be nothing to correlate records to 181 | break; 182 | case EventType::DELETED: 183 | if (method_exists($model, 'getDeletedAtColumn')) { 184 | return $model->only($model->getDeletedAtColumn()); 185 | } 186 | 187 | return []; 188 | break; 189 | case EventType::UPDATED: 190 | default: 191 | return $model->getDirty(); 192 | break; 193 | } 194 | } 195 | 196 | /** 197 | * @return BelongsTo|null 198 | */ 199 | public function subject(): ?BelongsTo 200 | { 201 | return $this->belongsTo($this->getSubjectModelClassname(), 'subject_id'); 202 | } 203 | 204 | /** 205 | * @return string 206 | */ 207 | public function getSubjectModelClassname(): string 208 | { 209 | return str_replace(config('model-auditlog.model_suffix'), '', get_class($this)); 210 | } 211 | 212 | /** 213 | * Gets an instance of the audit log for this model. 214 | * 215 | * @return mixed 216 | */ 217 | public function getSubjectModelClassInstance() 218 | { 219 | $class = $this->getSubjectModelClassname(); 220 | 221 | return new $class(); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Observers/AuditLogObserver.php: -------------------------------------------------------------------------------- 1 | getAuditLogModel($model) 16 | ->recordChanges(EventType::CREATED, $model); 17 | } 18 | 19 | /** 20 | * @param Model $model 21 | */ 22 | public function updated($model): void 23 | { 24 | $this->getAuditLogModel($model) 25 | ->recordChanges(EventType::UPDATED, $model); 26 | } 27 | 28 | /** 29 | * @param Model $model 30 | */ 31 | public function deleted($model): void 32 | { 33 | /* 34 | * If a model is hard deleting, either via a force delete or that model does not implement 35 | * the SoftDeletes trait we should tag it as such so logging doesn't occur down the pipe. 36 | */ 37 | if ((! method_exists($model, 'isForceDeleting') || $model->isForceDeleting())) { 38 | $event = EventType::FORCE_DELETED; 39 | } 40 | 41 | $this->getAuditLogModel($model) 42 | ->recordChanges($event ?? EventType::DELETED, $model); 43 | } 44 | 45 | /** 46 | * @param Model $model 47 | */ 48 | public function restored($model): void 49 | { 50 | $this->getAuditLogModel($model) 51 | ->recordChanges(EventType::RESTORED, $model); 52 | } 53 | 54 | /** 55 | * @param Model $model 56 | * @param string $relationName 57 | * @param array $pivotIds 58 | */ 59 | public function pivotDetached($model, string $relationName, array $pivotIds) 60 | { 61 | $this->getAuditLogModel($model) 62 | ->recordPivotChanges(EventType::PIVOT_DELETED, $model, $relationName, $pivotIds); 63 | } 64 | 65 | /** 66 | * Returns an instance of the AuditLogModel for the specific 67 | * model you provide. 68 | * 69 | * @param Model $model 70 | * 71 | * @return mixed 72 | */ 73 | protected function getAuditLogModel($model) 74 | { 75 | return $model->getAuditLogModelInstance(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Traits/AuditLoggable.php: -------------------------------------------------------------------------------- 1 | getAuditLogModelName(); 34 | 35 | return new $class(); 36 | } 37 | 38 | /** 39 | * @return string 40 | */ 41 | public function getAuditLogTableName(): string 42 | { 43 | return $this->getTable() . config('model-auditlog.table_suffix'); 44 | } 45 | 46 | /** 47 | * Get fields that should be ignored from the auditlog for this model. 48 | * 49 | * @return array 50 | */ 51 | public function getAuditLogIgnoredFields(): array 52 | { 53 | return []; 54 | } 55 | 56 | /** 57 | * Get fields that should be used as keys on the auditlog for this model. 58 | * 59 | * @return array 60 | */ 61 | public function getAuditLogForeignKeyColumns(): array 62 | { 63 | return ['subject_id' => $this->getKeyName()]; 64 | } 65 | 66 | /** 67 | * Get the columns used in the foreign key on the audit log table. 68 | * 69 | * @return array 70 | */ 71 | public function getAuditLogForeignKeyColumnKeys(): array 72 | { 73 | return array_keys($this->getAuditLogForeignKeyColumns()); 74 | } 75 | 76 | /** 77 | * Get the columns used in the unique index on the model table. 78 | * 79 | * @return array 80 | */ 81 | public function getAuditLogForeignKeyColumnValues(): array 82 | { 83 | return array_values($this->getAuditLogForeignKeyColumns()); 84 | } 85 | 86 | /** 87 | * Get the audit logs for this model. 88 | * 89 | * @return HasMany|null 90 | */ 91 | public function auditLogs(): ?HasMany 92 | { 93 | return $this->hasMany($this->getAuditLogModelName(), 'subject_id'); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Traits/AuditLoggablePivot.php: -------------------------------------------------------------------------------- 1 | audit_loggable_keys; 21 | } 22 | 23 | /** 24 | * Get the audit logs for this model. 25 | * 26 | * @return HasMany|null 27 | */ 28 | public function auditLogs(): ?HasMany 29 | { 30 | return $this->hasMany( 31 | $this->getAuditLogModelName(), 32 | $this->getAuditLogForeignKeyColumnKeys(), 33 | $this->getAuditLogForeignKeyColumnValues() 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /stubs/migration.stub: -------------------------------------------------------------------------------- 1 | increments('id')->unsigned(); 18 | $table->unsignedInteger('subject_id')->index(); 19 | $table->unsignedInteger('user_id')->nullable()->index(); 20 | $table->unsignedTinyInteger('event_type')->index(); 21 | $table->string('field_name')->index(); 22 | $table->string('field_value_old')->nullable()->index(); 23 | $table->string('field_value_new')->nullable()->index(); 24 | $table->timestamp('occurred_at')->index(); 25 | $table->timestamps(); 26 | {PROCESS_IDS_SETUP} 27 | 28 | {FOREIGN_KEY_SUBJECT} 29 | 30 | {FOREIGN_KEY_USER} 31 | }); 32 | } 33 | 34 | /** 35 | * Reverse the migrations. 36 | * 37 | * @return void 38 | */ 39 | public function down() 40 | { 41 | Schema::dropIfExists('{TABLE_NAME}'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /stubs/model.stub: -------------------------------------------------------------------------------- 1 |