├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── tests ├── Supports │ ├── Models │ │ ├── PostOverrideAttributes.php │ │ ├── PostWithoutAttributes.php │ │ ├── Post.php │ │ ├── News.php │ │ ├── Article.php │ │ ├── Comment.php │ │ ├── User.php │ │ ├── Admin.php │ │ └── Extensions │ │ │ └── AuthenticatableTrait.php │ ├── Factories │ │ ├── CommentFactory.php │ │ ├── NewsFactory.php │ │ ├── PostFactory.php │ │ ├── ArticleFactory.php │ │ ├── AdminFactory.php │ │ └── UserFactory.php │ └── Migrations │ │ ├── 2017_06_30_000000_create_users_table.php │ │ ├── 2018_06_17_000000_create_admins_table.php │ │ ├── 2019_06_14_000000_create_news_table.php │ │ ├── 2017_06_30_000000_create_posts_table.php │ │ ├── 2018_06_17_000000_create_articles_table.php │ │ └── 2017_06_30_000000_create_comments_table.php ├── HelperTests.php ├── ServiceTests.php ├── TestCase.php ├── TraitTests.php └── ObservedModelTests.php ├── .gitattributes ├── phpunit.xml ├── .gitignore ├── src ├── helpers.php ├── ServiceProvider.php ├── BlameableService.php ├── BlameableObserver.php └── BlameableTrait.php ├── phpstan.neon ├── LICENSE.md ├── config └── blameable.php ├── .phpmd.cleancode.xml ├── composer.json └── readme.md /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "08:00" 8 | timezone: Asia/Jakarta 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /tests/Supports/Models/PostOverrideAttributes.php: -------------------------------------------------------------------------------- 1 | 'creator_id', 12 | 'updatedBy' => 'updater_id', 13 | 'deletedBy' => 'eraser_id', 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Define the line ending behavior of the different file extensions 2 | # Set default behaviour, in case users don't have core.autocrlf set. 3 | * text=auto 4 | * text eol=lf 5 | 6 | # Explicitly declare text files we want to always be normalized and converted 7 | # to native line endings on checkout. 8 | *.default text 9 | *.example text 10 | *.json text 11 | *.md text 12 | *.php text 13 | *.txt text 14 | *.xml text 15 | *.yaml text 16 | *.yml text 17 | .gitignore text 18 | -------------------------------------------------------------------------------- /tests/Supports/Models/PostWithoutAttributes.php: -------------------------------------------------------------------------------- 1 | 'App\User', 12 | 'createdBy' => null, 13 | 'updatedBy' => null, 14 | 'deletedBy' => null, 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /tests/Supports/Models/Post.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/ 6 | 7 | 8 | 9 | 10 | src/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/Supports/Models/News.php: -------------------------------------------------------------------------------- 1 | 'admin', 16 | 'user' => Admin::class, 17 | 'deletedBy' => null, 18 | ]; 19 | 20 | protected $fillable = [ 21 | 'title', 22 | 'content' 23 | ]; 24 | 25 | protected $table = 'news'; 26 | } 27 | -------------------------------------------------------------------------------- /tests/Supports/Factories/CommentFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->paragraph, 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Supports/Models/Article.php: -------------------------------------------------------------------------------- 1 | 'admin', 18 | 'user' => Admin::class, 19 | ]; 20 | 21 | protected $fillable = [ 22 | 'title', 23 | 'content' 24 | ]; 25 | 26 | protected $table = 'posts'; 27 | } 28 | -------------------------------------------------------------------------------- /tests/Supports/Factories/NewsFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->sentence, 26 | 'content' => $this->faker->paragraph, 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Supports/Factories/PostFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->sentence, 26 | 'content' => $this->faker->paragraph, 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Supports/Factories/ArticleFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->sentence, 26 | 'content' => $this->faker->paragraph, 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Supports/Factories/AdminFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 27 | 'email' => $this->faker->email, 28 | 'password' => bcrypt(Str::random(12)), 29 | 'remember_token' => Str::random(12), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Supports/Factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 27 | 'email' => $this->faker->safeEmail, 28 | 'password' => bcrypt(Str::random(12)), 29 | 'remember_token' => Str::random(12), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Supports/Models/Comment.php: -------------------------------------------------------------------------------- 1 | 'user_id', 18 | 'updatedBy' => 'updater_id', 19 | 'deletedBy' => 'eraser_id', 20 | ]; 21 | 22 | protected $fillable = [ 23 | 'content' 24 | ]; 25 | 26 | protected $touches = [ 27 | 'post' 28 | ]; 29 | 30 | public function post() 31 | { 32 | return $this->belongsTo(Post::class); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | # ----------------------------------------------------------------------- # 3 | 4 | .DS_Store 5 | .DS_Store? 6 | ._* 7 | .Spotlight-V100 8 | .Trashes 9 | Icon? 10 | ehthumbs.db 11 | Thumbs.db 12 | 13 | 14 | # Vagrant files 15 | # ----------------------------------------------------------------------- # 16 | 17 | Vagrantfile 18 | .vagrant 19 | *.log 20 | 21 | 22 | # Code Editor / IDE generated files 23 | # ----------------------------------------------------------------------- # 24 | 25 | .idea 26 | *.sublime-project 27 | *.sublime-workspace 28 | 29 | 30 | # Composer & Vendor Files 31 | # ----------------------------------------------------------------------- # 32 | 33 | composer.phar 34 | composer.lock 35 | /vendor 36 | 37 | 38 | # Cache Files 39 | # ----------------------------------------------------------------------- # 40 | 41 | *.cache 42 | 43 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | getConfiguration($model, 'guard'); 17 | 18 | $user = ($guard === null) ? app('auth')->user() : app('auth')->guard($guard)->user(); 19 | $userClass = (string) app(BlameableService::class)->getConfiguration($model, 'user'); 20 | 21 | if (($user instanceof Model) && ($user instanceof $userClass)) { 22 | $userId = $user->getKey(); 23 | 24 | return (is_int($userId) || is_string($userId)) ? $userId : null; 25 | } 26 | 27 | return null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Supports/Models/User.php: -------------------------------------------------------------------------------- 1 | 'admin', 21 | 'user' => self::class 22 | ]; 23 | 24 | protected $fillable = [ 25 | 'name', 26 | 'email', 27 | 'password' 28 | ]; 29 | 30 | /** 31 | * Get the name of the password attribute for the user. 32 | * 33 | * @return string 34 | */ 35 | public function getAuthPasswordName() 36 | { 37 | return 'password'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Supports/Migrations/2017_06_30_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name'); 19 | $table->string('email')->unique(); 20 | $table->string('password'); 21 | $table->string('remember_token'); 22 | $table->bigInteger('created_by')->nullable(); 23 | $table->bigInteger('updated_by')->nullable(); 24 | $table->bigInteger('deleted_by')->nullable(); 25 | $table->softDeletes(); 26 | $table->timestamps(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::dropIfExists('users'); 38 | } 39 | } -------------------------------------------------------------------------------- /tests/Supports/Migrations/2018_06_17_000000_create_admins_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name'); 19 | $table->string('email')->unique(); 20 | $table->string('password'); 21 | $table->string('remember_token'); 22 | $table->bigInteger('created_by')->nullable(); 23 | $table->bigInteger('updated_by')->nullable(); 24 | $table->bigInteger('deleted_by')->nullable(); 25 | $table->softDeletes(); 26 | $table->timestamps(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::dropIfExists('admins'); 38 | } 39 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Richan Fongdasen 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 | > 13 | > The above copyright notice and this permission notice shall be included in all 14 | > copies or substantial portions of the Software. 15 | > 16 | > 17 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | > SOFTWARE. 24 | 25 | -------------------------------------------------------------------------------- /tests/Supports/Migrations/2019_06_14_000000_create_news_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('title'); 19 | $table->text('content'); 20 | $table->bigInteger('created_by')->nullable(); 21 | $table->bigInteger('updated_by')->nullable(); 22 | $table->timestamps(); 23 | 24 | $table->foreign('created_by') 25 | ->references('id')->on('users') 26 | ->onDelete('cascade'); 27 | 28 | $table->foreign('updated_by') 29 | ->references('id')->on('users') 30 | ->onDelete('cascade'); 31 | }); 32 | } 33 | 34 | /** 35 | * Reverse the migrations. 36 | * 37 | * @return void 38 | */ 39 | public function down() 40 | { 41 | Schema::dropIfExists('news'); 42 | } 43 | } -------------------------------------------------------------------------------- /tests/Supports/Migrations/2017_06_30_000000_create_posts_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('title'); 19 | $table->text('content'); 20 | $table->bigInteger('created_by')->nullable(); 21 | $table->bigInteger('updated_by')->nullable(); 22 | $table->bigInteger('deleted_by')->nullable(); 23 | $table->softDeletes(); 24 | $table->timestamps(); 25 | 26 | $table->foreign('created_by') 27 | ->references('id')->on('users') 28 | ->onDelete('cascade'); 29 | 30 | $table->foreign('updated_by') 31 | ->references('id')->on('users') 32 | ->onDelete('cascade'); 33 | }); 34 | } 35 | 36 | /** 37 | * Reverse the migrations. 38 | * 39 | * @return void 40 | */ 41 | public function down() 42 | { 43 | Schema::dropIfExists('posts'); 44 | } 45 | } -------------------------------------------------------------------------------- /tests/Supports/Migrations/2018_06_17_000000_create_articles_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('title'); 19 | $table->text('content'); 20 | $table->bigInteger('created_by')->nullable(); 21 | $table->bigInteger('updated_by')->nullable(); 22 | $table->bigInteger('deleted_by')->nullable(); 23 | $table->softDeletes(); 24 | $table->timestamps(); 25 | 26 | $table->foreign('created_by') 27 | ->references('id')->on('users') 28 | ->onDelete('cascade'); 29 | 30 | $table->foreign('updated_by') 31 | ->references('id')->on('users') 32 | ->onDelete('cascade'); 33 | }); 34 | } 35 | 36 | /** 37 | * Reverse the migrations. 38 | * 39 | * @return void 40 | */ 41 | public function down() 42 | { 43 | Schema::dropIfExists('articles'); 44 | } 45 | } -------------------------------------------------------------------------------- /tests/Supports/Migrations/2017_06_30_000000_create_comments_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->bigInteger('post_id'); 19 | $table->bigInteger('user_id')->nullable(); 20 | $table->text('content'); 21 | $table->bigInteger('updater_id')->nullable(); 22 | $table->bigInteger('eraser_id')->nullable(); 23 | $table->softDeletes(); 24 | $table->timestamps(); 25 | 26 | $table->foreign('post_id') 27 | ->references('id')->on('posts') 28 | ->onDelete('cascade'); 29 | 30 | $table->foreign('user_id') 31 | ->references('id')->on('users') 32 | ->onDelete('cascade'); 33 | 34 | $table->foreign('updater_id') 35 | ->references('id')->on('users') 36 | ->onDelete('cascade'); 37 | }); 38 | } 39 | 40 | /** 41 | * Reverse the migrations. 42 | * 43 | * @return void 44 | */ 45 | public function down() 46 | { 47 | Schema::dropIfExists('comments'); 48 | } 49 | } -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->version(), 'Lumen') !== false; 17 | } 18 | 19 | /** 20 | * Bootstrap the application services. 21 | * 22 | * @return void 23 | */ 24 | public function boot(): void 25 | { 26 | if (!$this->isLumen()) { 27 | $this->publishes([ 28 | realpath(dirname(__DIR__).'/config/blameable.php') => config_path('blameable.php'), 29 | ], 'config'); 30 | } 31 | } 32 | 33 | /** 34 | * Register the application services. 35 | * 36 | * @return void 37 | */ 38 | public function register(): void 39 | { 40 | $configPath = realpath(dirname(__DIR__).'/config/blameable.php'); 41 | 42 | if ($configPath !== false) { 43 | $this->mergeConfigFrom($configPath, 'blameable'); 44 | } 45 | 46 | $this->app->singleton(BlameableObserver::class, function (): BlameableObserver { 47 | return new BlameableObserver(); 48 | }); 49 | 50 | $this->app->singleton(BlameableService::class, function (): BlameableService { 51 | return new BlameableService(); 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/BlameableService.php: -------------------------------------------------------------------------------- 1 | blameable() : []; 20 | 21 | return array_merge((array) config('blameable'), $modelConfigurations); 22 | } 23 | 24 | /** 25 | * Get current configuration value for the given attributes. 26 | * 27 | * @param \Illuminate\Database\Eloquent\Model $model 28 | * @param string $key 29 | * 30 | * @return string|null 31 | */ 32 | public function getConfiguration(Model $model, string $key): ?string 33 | { 34 | $value = data_get($this->getConfigurations($model), $key); 35 | 36 | return is_string($value) ? $value : null; 37 | } 38 | 39 | /** 40 | * Set Model's attribute value for the given key. 41 | * 42 | * @param Model $model 43 | * @param string $key 44 | * @param int|string|null $userId 45 | * 46 | * @return bool 47 | */ 48 | public function setAttribute(Model $model, string $key, $userId): bool 49 | { 50 | $attribute = $this->getConfiguration($model, $key); 51 | 52 | if ($attribute !== null) { 53 | $model->setAttribute($attribute, $userId); 54 | } 55 | 56 | return $model->isDirty($attribute); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/HelperTests.php: -------------------------------------------------------------------------------- 1 | assertNull(blameable_user(new Post)); 16 | } 17 | 18 | #[Test] 19 | public function it_returns_current_user_identifier_when_calling_as_user() 20 | { 21 | $this->impersonateUser(); 22 | 23 | $this->assertEquals($this->user->getKey(), blameable_user(new Post)); 24 | } 25 | 26 | #[Test] 27 | public function it_returns_current_user_identifier_when_calling_as_other_user() 28 | { 29 | $this->impersonateOtherUser(); 30 | 31 | $this->assertEquals($this->otherUser->getKey(), blameable_user(new Post)); 32 | } 33 | 34 | #[Test] 35 | public function it_returns_null_as_current_user_identifier_when_current_user_is_other_class() 36 | { 37 | $this->impersonateUser(); 38 | 39 | $this->assertNull(blameable_user(new PostWithoutAttributes)); 40 | } 41 | 42 | #[Test] 43 | public function it_returns_current_impersonated_admin_id() 44 | { 45 | $this->impersonateAdmin(); 46 | 47 | $this->assertEquals($this->admin->getKey(), blameable_user(new Article)); 48 | } 49 | 50 | #[Test] 51 | public function it_returns_null_as_current_user_for_the_unauthenticated_user() 52 | { 53 | $this->assertNull(blameable_user(new Article)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/BlameableObserver.php: -------------------------------------------------------------------------------- 1 | setAttribute($model, 'createdBy', blameable_user($model)); 19 | } 20 | 21 | /** 22 | * Listening to any deleted events. 23 | * 24 | * @param \Illuminate\Database\Eloquent\Model $model 25 | * 26 | * @return void 27 | */ 28 | public function deleted(Model $model): void 29 | { 30 | app(BlameableService::class)->setAttribute($model, 'deletedBy', blameable_user($model)); 31 | 32 | if ( 33 | method_exists($model, 'useSoftDeletes') && method_exists($model, 'silentUpdate') && 34 | $model->useSoftDeletes() && $model->isDirty() 35 | ) { 36 | $model->silentUpdate(); 37 | } 38 | } 39 | 40 | /** 41 | * Listening to any restoring events. 42 | * 43 | * @param \Illuminate\Database\Eloquent\Model $model 44 | * 45 | * @return void 46 | */ 47 | public function restoring(Model $model): void 48 | { 49 | app(BlameableService::class)->setAttribute($model, 'deletedBy', null); 50 | } 51 | 52 | /** 53 | * Listening to any saving events. 54 | * 55 | * @param \Illuminate\Database\Eloquent\Model $model 56 | * 57 | * @return void 58 | */ 59 | public function saving(Model $model): void 60 | { 61 | app(BlameableService::class)->setAttribute($model, 'updatedBy', blameable_user($model)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Supports/Models/Extensions/AuthenticatableTrait.php: -------------------------------------------------------------------------------- 1 | getKeyName(); 22 | } 23 | 24 | /** 25 | * Get the unique identifier for the user. 26 | * 27 | * @return mixed 28 | */ 29 | public function getAuthIdentifier() 30 | { 31 | return $this->{$this->getAuthIdentifierName()}; 32 | } 33 | 34 | /** 35 | * Get the password for the user. 36 | * 37 | * @return string 38 | */ 39 | public function getAuthPassword() 40 | { 41 | return $this->password; 42 | } 43 | 44 | /** 45 | * Get the token value for the "remember me" session. 46 | * 47 | * @return string 48 | */ 49 | public function getRememberToken() 50 | { 51 | if (! empty($this->getRememberTokenName())) { 52 | return $this->{$this->getRememberTokenName()}; 53 | } 54 | } 55 | 56 | /** 57 | * Set the token value for the "remember me" session. 58 | * 59 | * @param string $value 60 | * @return void 61 | */ 62 | public function setRememberToken($value) 63 | { 64 | if (! empty($this->getRememberTokenName())) { 65 | $this->{$this->getRememberTokenName()} = $value; 66 | } 67 | } 68 | 69 | /** 70 | * Get the column name for the "remember me" token. 71 | * 72 | * @return string 73 | */ 74 | public function getRememberTokenName() 75 | { 76 | return $this->rememberTokenName; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /config/blameable.php: -------------------------------------------------------------------------------- 1 | null, 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | User Model Definition 26 | |-------------------------------------------------------------------------- 27 | | 28 | | Please specify a user model that should be used to setup `creator` 29 | | and `updater` relationship. 30 | | 31 | */ 32 | 33 | 'user' => \App\User::class, 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | The `createdBy` attribute 38 | |-------------------------------------------------------------------------- 39 | | 40 | | Please define an attribute to use when recording the creator 41 | | identifier. 42 | | 43 | */ 44 | 45 | 'createdBy' => 'created_by', 46 | 47 | /* 48 | |-------------------------------------------------------------------------- 49 | | The `updatedBy` attribute 50 | |-------------------------------------------------------------------------- 51 | | 52 | | Please define an attribute to use when recording the updater 53 | | identifier. 54 | | 55 | */ 56 | 57 | 'updatedBy' => 'updated_by', 58 | 59 | /* 60 | |-------------------------------------------------------------------------- 61 | | The `deletedBy` attribute 62 | |-------------------------------------------------------------------------- 63 | | 64 | | Please define an attribute to use when recording the user 65 | | identifier who deleted the record. This feature would only work 66 | | if you are using SoftDeletes in your model. 67 | | 68 | */ 69 | 70 | 'deletedBy' => 'deleted_by', 71 | ]; 72 | -------------------------------------------------------------------------------- /.phpmd.cleancode.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | The Clean Code ruleset contains rules that enforce a clean code base. This includes rules from SOLID and object calisthenics. 11 | 12 | 13 | 18 | 19 | 25 | 26 | 1 27 | 28 | 29 | 35 | 36 | 37 | 38 | 43 | 44 | 51 | 52 | 1 53 | 54 | 55 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "richan-fongdasen/eloquent-blameable", 3 | "description": "Blameable behavior implementation for your Eloquent Model in Laravel", 4 | "type": "library", 5 | "license": "MIT", 6 | "homepage": "https://github.com/richan-fongdasen/eloquent-blameable", 7 | "keywords": [ 8 | "laravel", 9 | "laravel-package", 10 | "eloquent", 11 | "blameable" 12 | ], 13 | "authors": [ 14 | { 15 | "name": "Richan Fongdasen", 16 | "email": "richan.fongdasen@gmail.com" 17 | } 18 | ], 19 | "support": { 20 | "issues": "https://github.com/richan-fongdasen/eloquent-blameable/issues", 21 | "source": "https://github.com/richan-fongdasen/eloquent-blameable" 22 | }, 23 | "require": { 24 | "php": "^8.0", 25 | "illuminate/database": "^8.0|^9.0|^10.0|^11.0|^12.0", 26 | "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0" 27 | }, 28 | "require-dev": { 29 | "ekino/phpstan-banned-code": "^1.0", 30 | "larastan/larastan": "^1.0|^2.0|^3.0", 31 | "mockery/mockery": "^1.4", 32 | "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", 33 | "phpmd/phpmd": "^2.11", 34 | "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", 35 | "phpstan/phpstan-strict-rules": "^1.0|^2.0", 36 | "phpunit/phpunit": "^9.5|^10.0|^11.0" 37 | }, 38 | "config": { 39 | "sort-packages": true 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "RichanFongdasen\\EloquentBlameable\\": "src/" 44 | }, 45 | "files": [ 46 | "src/helpers.php" 47 | ] 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "RichanFongdasen\\EloquentBlameableTest\\": "tests/", 52 | "Database\\Factories\\": "tests/Supports/Factories/" 53 | } 54 | }, 55 | "extra": { 56 | "laravel": { 57 | "providers": [ 58 | "RichanFongdasen\\EloquentBlameable\\ServiceProvider" 59 | ] 60 | } 61 | }, 62 | "scripts": { 63 | "analyse": [ 64 | "composer check-syntax", 65 | "composer phpstan-analysis", 66 | "composer phpmd-analysis" 67 | ], 68 | "check-syntax": [ 69 | "! find src -type f -name \"*.php\" -exec php -l {} \\; | grep -v 'No syntax errors'", 70 | "! find tests -type f -name \"*.php\" -exec php -l {} \\; | grep -v 'No syntax errors'" 71 | ], 72 | "phpstan-analysis": [ 73 | "vendor/bin/phpstan analyse -c phpstan.neon --no-progress" 74 | ], 75 | "phpmd-analysis": [ 76 | "vendor/bin/phpmd src text codesize,controversial,design,naming,unusedcode,.phpmd.cleancode.xml" 77 | ], 78 | "cov-text": [ 79 | "phpdbg -qrr vendor/bin/phpunit --coverage-text" 80 | ], 81 | "cov-html": [ 82 | "phpdbg -qrr vendor/bin/phpunit --coverage-html coverage" 83 | ], 84 | "test": [ 85 | "vendor/bin/phpunit" 86 | ] 87 | }, 88 | "minimum-stability": "dev", 89 | "prefer-stable": true 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: ["*.md"] 6 | push: 7 | paths-ignore: ["*.md"] 8 | 9 | jobs: 10 | analysis: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | php: [8.3] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, gd, exif, iconv 22 | coverage: none 23 | - name: Get Composer Cache Directory 24 | id: composer-cache 25 | run: | 26 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 27 | - name: Cache composer dependencies 28 | uses: actions/cache@v4 29 | env: 30 | cache-name: eloquent-blameable-analysis 31 | with: 32 | path: ${{ steps.composer-cache.outputs.dir }} 33 | key: php-${{ matrix.php }}-build-${{ env.cache-name }}-${{ hashFiles('**/composer.json') }} 34 | restore-keys: | 35 | php-${{ matrix.php }}-build-${{ env.cache-name }}- 36 | - name: Install composer dependencies 37 | run: composer install --prefer-dist 38 | - name: Run static analysis 39 | run: composer analyse 40 | test: 41 | name: Test (PHP ${{ matrix.php }}) 42 | needs: [analysis] 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | php: [8.0, 8.1, 8.2, 8.3, 8.4] 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | - name: Set up PHP 51 | uses: shivammathur/setup-php@v2 52 | with: 53 | php-version: ${{ matrix.php }} 54 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, gd, exif, iconv 55 | coverage: none 56 | - name: Get Composer Cache Directory 57 | id: composer-cache 58 | run: | 59 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 60 | - name: Cache composer dependencies 61 | uses: actions/cache@v4 62 | env: 63 | cache-name: eloquent-blameable-test 64 | with: 65 | path: ${{ steps.composer-cache.outputs.dir }} 66 | key: php-${{ matrix.php }}-build-${{ env.cache-name }}-${{ hashFiles('**/composer.json') }} 67 | restore-keys: | 68 | php-${{ matrix.php }}-build-${{ env.cache-name }}- 69 | - name: Install composer dependencies 70 | run: composer install --no-interaction --prefer-dist 71 | - name: Run PHPUnit tests 72 | run: vendor/bin/phpunit 73 | test-coverage: 74 | name: Test (PHP ${{ matrix.php }}) 75 | needs: [analysis] 76 | runs-on: ubuntu-latest 77 | strategy: 78 | matrix: 79 | php: [8.3] 80 | steps: 81 | - name: Checkout repository 82 | uses: actions/checkout@v4 83 | - name: Set up PHP 84 | uses: shivammathur/setup-php@v2 85 | with: 86 | php-version: ${{ matrix.php }} 87 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, gd, exif, iconv 88 | - name: Get Composer Cache Directory 89 | id: composer-cache 90 | run: | 91 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 92 | - name: Cache composer dependencies 93 | uses: actions/cache@v4 94 | env: 95 | cache-name: eloquent-blameable-test 96 | with: 97 | path: ${{ steps.composer-cache.outputs.dir }} 98 | key: php-${{ matrix.php }}-build-${{ env.cache-name }}-${{ hashFiles('**/composer.json') }} 99 | restore-keys: | 100 | php-${{ matrix.php }}-build-${{ env.cache-name }}- 101 | - name: Install composer dependencies 102 | run: composer install --no-interaction --prefer-dist 103 | - name: Run PHPUnit tests and generate code coverage 104 | run: vendor/bin/phpunit --coverage-clover=clover.xml 105 | - name: Upload code coverage results 106 | run: bash <(curl -s https://codecov.io/bash) 107 | -------------------------------------------------------------------------------- /tests/ServiceTests.php: -------------------------------------------------------------------------------- 1 | service = new BlameableService(); 27 | } 28 | 29 | #[Test] 30 | public function it_returns_configurations_correctly1() 31 | { 32 | $expected = [ 33 | 'user' => User::class, 34 | 'createdBy' => 'created_by', 35 | 'updatedBy' => 'updated_by', 36 | 'deletedBy' => 'deleted_by', 37 | 'guard' => null 38 | ]; 39 | 40 | $result = $this->invokeMethod($this->service, 'getConfigurations', [new Post()]); 41 | 42 | $this->assertEquals($expected, $result); 43 | } 44 | 45 | #[Test] 46 | public function it_returns_configurations_correctly2() 47 | { 48 | $expected = [ 49 | 'user' => User::class, 50 | 'createdBy' => 'creator_id', 51 | 'updatedBy' => 'updater_id', 52 | 'deletedBy' => 'eraser_id', 53 | 'guard' => null 54 | ]; 55 | 56 | $result = $this->invokeMethod($this->service, 'getConfigurations', [new PostOverrideAttributes()]); 57 | 58 | $this->assertEquals($expected, $result); 59 | } 60 | 61 | #[Test] 62 | public function it_returns_configurations_correctly3() 63 | { 64 | $expected = [ 65 | 'user' => 'App\User', 66 | 'createdBy' => null, 67 | 'updatedBy' => null, 68 | 'deletedBy' => null, 69 | 'guard' => null 70 | ]; 71 | 72 | $result = $this->invokeMethod($this->service, 'getConfigurations', [new PostWithoutAttributes()]); 73 | 74 | $this->assertEquals($expected, $result); 75 | } 76 | 77 | #[Test] 78 | public function it_returns_configuration_value_correctly1() 79 | { 80 | $model = new Comment(); 81 | $result = $this->invokeMethod($this->service, 'getConfiguration', [$model, 'user']); 82 | 83 | $this->assertEquals(User::class, $result); 84 | } 85 | 86 | #[Test] 87 | public function it_returns_configuration_value_correctly2() 88 | { 89 | $model = new Comment(); 90 | $result = $this->invokeMethod($this->service, 'getConfiguration', [$model, 'createdBy']); 91 | 92 | $this->assertEquals('user_id', $result); 93 | } 94 | 95 | #[Test] 96 | public function it_returns_configuration_value_correctly3() 97 | { 98 | $model = new Comment(); 99 | $result = $this->invokeMethod($this->service, 'getConfiguration', [$model, 'updatedBy']); 100 | 101 | $this->assertEquals('updater_id', $result); 102 | } 103 | 104 | #[Test] 105 | public function it_would_set_attribute_value_correctly1() 106 | { 107 | $this->impersonateUser(); 108 | $model = new Post(); 109 | $result = $this->service->setAttribute($model, 'createdBy', blameable_user($model)); 110 | 111 | $this->assertEquals($this->user->getKey(), $model->getAttribute('created_by')); 112 | $this->assertTrue($result); 113 | } 114 | 115 | #[Test] 116 | public function it_would_set_attribute_value_correctly2() 117 | { 118 | $this->impersonateUser(); 119 | $model = new Post(); 120 | $result = $this->service->setAttribute($model, 'createdBy', null); 121 | 122 | $this->assertNull($model->getAttribute('created_by')); 123 | $this->assertTrue($result); 124 | } 125 | 126 | #[Test] 127 | public function it_would_set_attribute_value_correctly3() 128 | { 129 | $this->impersonateUser(); 130 | $model = new PostOverrideAttributes(); 131 | $result = $this->service->setAttribute($model, 'createdBy', blameable_user($model)); 132 | 133 | $this->assertEquals($this->user->getKey(), $model->getAttribute('creator_id')); 134 | $this->assertTrue($result); 135 | } 136 | 137 | #[Test] 138 | public function it_would_set_attribute_value_correctly4() 139 | { 140 | $this->impersonateUser(); 141 | $model = new PostWithoutAttributes(); 142 | $result = $this->service->setAttribute($model, 'createdBy', blameable_user($model)); 143 | 144 | $this->assertNull($model->getAttribute('created_by')); 145 | $this->assertNull($model->getAttribute('creator_id')); 146 | $this->assertFalse($result); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/BlameableTrait.php: -------------------------------------------------------------------------------- 1 | getKey(); 50 | } 51 | 52 | return $query->where($this->getTable().'.'.app(BlameableService::class)->getConfiguration($this, $key), $userId); 53 | } 54 | 55 | /** 56 | * Get the user who created the record. 57 | * 58 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 59 | */ 60 | public function creator(): BelongsTo 61 | { 62 | return $this->belongsTo( 63 | app(BlameableService::class)->getConfiguration($this, 'user'), 64 | app(BlameableService::class)->getConfiguration($this, 'createdBy') 65 | ); 66 | } 67 | 68 | /** 69 | * Get the user who updated the record for the last time. 70 | * 71 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 72 | */ 73 | public function updater(): BelongsTo 74 | { 75 | return $this->belongsTo( 76 | app(BlameableService::class)->getConfiguration($this, 'user'), 77 | app(BlameableService::class)->getConfiguration($this, 'updatedBy') 78 | ); 79 | } 80 | 81 | /** 82 | * createdBy Query Scope. 83 | * 84 | * @param \Illuminate\Database\Eloquent\Builder $query 85 | * @param mixed $userId 86 | * 87 | * @return \Illuminate\Database\Eloquent\Builder 88 | */ 89 | public function scopeCreatedBy(Builder $query, $userId): Builder 90 | { 91 | return $this->buildBlameableScope($query, $userId, 'createdBy'); 92 | } 93 | 94 | /** 95 | * updatedBy Query Scope. 96 | * 97 | * @param \Illuminate\Database\Eloquent\Builder $query 98 | * @param mixed $userId 99 | * 100 | * @return \Illuminate\Database\Eloquent\Builder 101 | */ 102 | public function scopeUpdatedBy(Builder $query, $userId): Builder 103 | { 104 | return $this->buildBlameableScope($query, $userId, 'updatedBy'); 105 | } 106 | 107 | /** 108 | * Silently update the model without firing any 109 | * events. 110 | * 111 | * @return int 112 | */ 113 | public function silentUpdate(): int 114 | { 115 | return $this->newQueryWithoutScopes() 116 | ->where($this->getKeyName(), $this->getKey()) 117 | ->getQuery() 118 | ->update($this->getDirty()); 119 | } 120 | 121 | /** 122 | * Confirm if the current model uses SoftDeletes. 123 | * 124 | * @return bool 125 | */ 126 | public function useSoftDeletes(): bool 127 | { 128 | return in_array(SoftDeletes::class, class_uses_recursive($this), true); 129 | } 130 | 131 | /** 132 | * Define an inverse one-to-one or many relationship. 133 | * 134 | * @param string $related 135 | * @param string|null $foreignKey 136 | * @param string|null $otherKey 137 | * @param string|null $relation 138 | * 139 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 140 | */ 141 | abstract public function belongsTo($related, $foreignKey = null, $otherKey = null, $relation = null); 142 | 143 | /** 144 | * Get the attributes that have been changed since last sync. 145 | * 146 | * @return array 147 | */ 148 | abstract public function getDirty(); 149 | 150 | /** 151 | * Get the primary key for the model. 152 | * 153 | * @return string 154 | */ 155 | abstract public function getKeyName(); 156 | 157 | /** 158 | * Get the value of the model's primary key. 159 | * 160 | * @return mixed 161 | */ 162 | abstract public function getKey(); 163 | 164 | /** 165 | * Get a new query builder that doesn't have any global scopes. 166 | * 167 | * @return \Illuminate\Database\Eloquent\Builder|static 168 | */ 169 | abstract public function newQueryWithoutScopes(); 170 | } 171 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testbench'); 42 | $app['config']->set('database.connections.testbench', [ 43 | 'driver' => 'sqlite', 44 | 'database' => ':memory:', 45 | 'prefix' => '', 46 | ]); 47 | $app['config']->set('auth.guards', [ 48 | 'user' => [ 49 | 'driver' => 'session', 50 | 'provider' => 'user', 51 | ], 52 | 'admin' => [ 53 | 'driver' => 'session', 54 | 'provider' => 'admin', 55 | ] 56 | ]); 57 | $app['config']->set('auth.providers', [ 58 | 'user' => [ 59 | 'driver' => 'eloquent', 60 | 'model' => User::class, 61 | ], 62 | 'admin' => [ 63 | 'driver' => 'eloquent', 64 | 'model' => Admin::class, 65 | ] 66 | ]); 67 | $app['config']->set('auth.defaults.guard', 'user'); 68 | $app['config']->set('blameable.user', User::class); 69 | } 70 | 71 | /** 72 | * Define package service provider 73 | * 74 | * @param \Illuminate\Foundation\Application $app 75 | * @return array 76 | */ 77 | protected function getPackageProviders($app) 78 | { 79 | return [ 80 | \RichanFongdasen\EloquentBlameable\ServiceProvider::class, 81 | ]; 82 | } 83 | 84 | /** 85 | * Impersonate an admin before updating a blameable model 86 | * 87 | * @return void 88 | */ 89 | protected function impersonateAdmin(): void 90 | { 91 | $this->admin = Admin::factory()->create([ 92 | 'id' => rand(300, 900) 93 | ]); 94 | $this->actingAs($this->admin, 'admin'); 95 | } 96 | 97 | /** 98 | * Impersonate another user before updating a blameable model 99 | * 100 | * @return void 101 | * @throws \Exception 102 | */ 103 | protected function impersonateOtherUser(): void 104 | { 105 | $this->otherUser = User::factory()->create([ 106 | 'id' => random_int(1000, 2000) 107 | ]); 108 | $this->actingAs($this->otherUser); 109 | } 110 | 111 | /** 112 | * Impersonate a user before updating a blameable model 113 | * 114 | * @return void 115 | * @throws \Exception 116 | */ 117 | protected function impersonateUser(): void 118 | { 119 | $this->user = User::factory()->create([ 120 | 'id' => random_int(200, 900) 121 | ]); 122 | $this->actingAs($this->user); 123 | } 124 | 125 | /** 126 | * Invoke protected / private method of the given object 127 | * 128 | * @param Object $object 129 | * @param String $methodName 130 | * @param array $parameters 131 | * @return mixed 132 | * @throws \Exception 133 | */ 134 | protected function invokeMethod($object, $methodName, array $parameters = []) 135 | { 136 | $reflection = new \ReflectionClass(get_class($object)); 137 | $method = $reflection->getMethod($methodName); 138 | $method->setAccessible(true); 139 | 140 | return $method->invokeArgs($object, $parameters); 141 | } 142 | 143 | /** 144 | * Prepare database requirements 145 | * to perform any tests. 146 | * 147 | * @param string $migrationPath 148 | * @return void 149 | */ 150 | protected function prepareDatabase($migrationPath): void 151 | { 152 | $this->loadMigrationsFrom($migrationPath); 153 | } 154 | 155 | /** 156 | * Prepare to get an exception in a test 157 | * 158 | * @param mixed $exception 159 | * @return void 160 | */ 161 | protected function prepareException($exception): void 162 | { 163 | if (method_exists($this, 'expectException')) { 164 | $this->expectException($exception); 165 | } elseif (method_exists($this, 'setExpectedException')) { 166 | $this->setExpectedException($exception); 167 | } 168 | } 169 | 170 | /** 171 | * Setup the test environment 172 | * 173 | * @return void 174 | */ 175 | public function setUp(): void 176 | { 177 | parent::setUp(); 178 | 179 | $this->prepareDatabase(realpath(__DIR__ . '/Supports/Migrations')); 180 | 181 | Factory::guessFactoryNamesUsing(function (string $modelName) { 182 | return 'Database\\Factories\\' . class_basename($modelName) . 'Factory'; 183 | }); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /tests/TraitTests.php: -------------------------------------------------------------------------------- 1 | impersonateAdmin(); 34 | $this->articleA = Article::factory()->create(); 35 | $this->articleB = Article::factory()->create(); 36 | $this->articleC = Article::factory()->create(); 37 | 38 | $this->impersonateUser(); 39 | $this->postA = Post::factory()->create(); 40 | $this->postB = Post::factory()->create(); 41 | $this->postC = Post::factory()->create(); 42 | 43 | $this->impersonateOtherUser(); 44 | $this->postD = Post::factory()->create(); 45 | $this->postE = Post::factory()->create(); 46 | $this->postB->save(); 47 | } 48 | 49 | #[Test] 50 | public function post_model_would_accept_created_by_scope1() 51 | { 52 | $collections = Post::createdBy($this->user->getKey())->get(); 53 | 54 | $this->assertTrue($collections->contains($this->postA)); 55 | $this->assertTrue($collections->contains($this->postB)); 56 | $this->assertTrue($collections->contains($this->postC)); 57 | } 58 | 59 | #[Test] 60 | public function post_model_would_accept_created_by_scope2() 61 | { 62 | $collections = Post::createdBy($this->otherUser)->get(); 63 | 64 | $this->assertTrue($collections->contains($this->postD)); 65 | $this->assertTrue($collections->contains($this->postE)); 66 | } 67 | 68 | #[Test] 69 | public function post_model_would_accept_updated_by_scope1() 70 | { 71 | $collections = Post::updatedBy($this->user->getKey())->get(); 72 | 73 | $this->assertTrue($collections->contains($this->postA)); 74 | $this->assertTrue($collections->contains($this->postC)); 75 | } 76 | 77 | #[Test] 78 | public function post_model_would_accept_updated_by_scope2() 79 | { 80 | $collections = Post::updatedBy($this->otherUser)->get(); 81 | 82 | $this->assertTrue($collections->contains($this->postB)); 83 | $this->assertTrue($collections->contains($this->postD)); 84 | $this->assertTrue($collections->contains($this->postE)); 85 | } 86 | 87 | #[Test] 88 | public function it_returns_creator_user_object_correctly() 89 | { 90 | $this->assertInstanceOf(User::class, $this->postA->creator); 91 | $this->assertInstanceOf(User::class, $this->postB->creator); 92 | $this->assertInstanceOf(User::class, $this->postC->creator); 93 | $this->assertInstanceOf(User::class, $this->postD->creator); 94 | $this->assertInstanceOf(User::class, $this->postE->creator); 95 | 96 | $this->assertEquals($this->user->getKey(), $this->postA->creator->getKey()); 97 | $this->assertEquals($this->user->getKey(), $this->postB->creator->getKey()); 98 | $this->assertEquals($this->user->getKey(), $this->postC->creator->getKey()); 99 | $this->assertEquals($this->otherUser->getKey(), $this->postD->creator->getKey()); 100 | $this->assertEquals($this->otherUser->getKey(), $this->postE->creator->getKey()); 101 | } 102 | 103 | #[Test] 104 | public function it_returns_updater_user_object_correctly() 105 | { 106 | $this->assertInstanceOf(User::class, $this->postA->updater); 107 | $this->assertInstanceOf(User::class, $this->postB->updater); 108 | $this->assertInstanceOf(User::class, $this->postC->updater); 109 | $this->assertInstanceOf(User::class, $this->postD->updater); 110 | $this->assertInstanceOf(User::class, $this->postE->updater); 111 | 112 | $this->assertEquals($this->user->getKey(), $this->postA->updater->getKey()); 113 | $this->assertEquals($this->user->getKey(), $this->postC->updater->getKey()); 114 | $this->assertEquals($this->otherUser->getKey(), $this->postB->updater->getKey()); 115 | $this->assertEquals($this->otherUser->getKey(), $this->postD->updater->getKey()); 116 | $this->assertEquals($this->otherUser->getKey(), $this->postE->updater->getKey()); 117 | } 118 | 119 | #[Test] 120 | public function article_model_would_accept_created_by_scope() 121 | { 122 | $collections = Article::createdBy($this->admin)->get(); 123 | 124 | $this->assertTrue($collections->contains($this->articleA)); 125 | $this->assertTrue($collections->contains($this->articleB)); 126 | $this->assertTrue($collections->contains($this->articleC)); 127 | } 128 | 129 | #[Test] 130 | public function article_model_would_accept_updated_by_scope() 131 | { 132 | $collections = Article::updatedBy($this->admin)->get(); 133 | 134 | $this->assertTrue($collections->contains($this->articleA)); 135 | $this->assertTrue($collections->contains($this->articleB)); 136 | $this->assertTrue($collections->contains($this->articleC)); 137 | } 138 | 139 | #[Test] 140 | public function it_returns_creator_admin_object_correctly() 141 | { 142 | $this->assertInstanceOf(Admin::class, $this->articleA->creator); 143 | $this->assertInstanceOf(Admin::class, $this->articleB->creator); 144 | $this->assertInstanceOf(Admin::class, $this->articleC->creator); 145 | 146 | $this->assertEquals($this->admin->getKey(), $this->articleA->creator->getKey()); 147 | $this->assertEquals($this->admin->getKey(), $this->articleB->creator->getKey()); 148 | $this->assertEquals($this->admin->getKey(), $this->articleC->creator->getKey()); 149 | } 150 | 151 | #[Test] 152 | public function it_returns_updater_admin_object_correctly() 153 | { 154 | $this->assertInstanceOf(Admin::class, $this->articleA->updater); 155 | $this->assertInstanceOf(Admin::class, $this->articleB->updater); 156 | $this->assertInstanceOf(Admin::class, $this->articleC->updater); 157 | 158 | $this->assertEquals($this->admin->getKey(), $this->articleA->updater->getKey()); 159 | $this->assertEquals($this->admin->getKey(), $this->articleB->updater->getKey()); 160 | $this->assertEquals($this->admin->getKey(), $this->articleC->updater->getKey()); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/richan-fongdasen/eloquent-blameable/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/richan-fongdasen/eloquent-blameable/actions/workflows/main.yml) 2 | [![codecov](https://codecov.io/gh/richan-fongdasen/eloquent-blameable/branch/master/graph/badge.svg)](https://codecov.io/gh/richan-fongdasen/eloquent-blameable) 3 | [![Total Downloads](https://poser.pugx.org/richan-fongdasen/eloquent-blameable/d/total.svg)](https://packagist.org/packages/richan-fongdasen/eloquent-blameable) 4 | [![Latest Stable Version](https://poser.pugx.org/richan-fongdasen/eloquent-blameable/v/stable.svg)](https://packagist.org/packages/richan-fongdasen/eloquent-blameable) 5 | [![License: MIT](https://poser.pugx.org/laravel/framework/license.svg)](https://opensource.org/licenses/MIT) 6 | 7 | # Eloquent Blameable 8 | 9 | > Blameable behavior implementation for your Eloquent Model in Laravel 10 | 11 | ## Synopsis 12 | 13 | This package would help you to track the creator and updater of each database record. It would be done by filling the specified attributes with the current user ID automatically. By default, those attributes would be filled when you are saving the Eloquent Model object. 14 | 15 | ## Table of contents 16 | 17 | - [Setup](#setup) 18 | - [Configuration](#configuration) 19 | - [Usage](#usage) 20 | - [License](#license) 21 | 22 | ## Setup 23 | 24 | Install the package via Composer : 25 | 26 | ```sh 27 | $ composer require richan-fongdasen/eloquent-blameable 28 | ``` 29 | 30 | ### Laravel version compatibility 31 | 32 | | Laravel version | Blameable version | 33 | | :-------------- | :---------------- | 34 | | 5.1.x | 1.0.x | 35 | | 5.2.x - 5.4.x | 1.1.x - 1.2.x | 36 | | 5.5.x - 5.8.x | 1.3.x | 37 | | 6.x | 1.4.x | 38 | | 7.x | 1.5.x | 39 | | 8.x | 1.6.x | 40 | | 9.x | 1.8.x | 41 | | 10.x | 1.9.x | 42 | | 10.x | 1.10.x | 43 | 44 | > If you are using Laravel version 5.5+ then you can skip registering the service provider in your Laravel application. 45 | 46 | ### Service Provider 47 | 48 | Add the package service provider in your `config/app.php` 49 | 50 | ```php 51 | 'providers' => [ 52 | // ... 53 | RichanFongdasen\EloquentBlameable\ServiceProvider::class, 54 | ]; 55 | ``` 56 | 57 | ## Configuration 58 | 59 | Publish configuration file using `php artisan` command 60 | 61 | ```sh 62 | $ php artisan vendor:publish --provider="RichanFongdasen\EloquentBlameable\ServiceProvider" 63 | ``` 64 | 65 | The command above would copy a new configuration file to `/config/blameable.php` 66 | 67 | ```php 68 | return [ 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Authentication Guard 73 | |-------------------------------------------------------------------------- 74 | | 75 | | Please specify your default authentication guard to be used by blameable 76 | | service. You can leave this to null if you're using the default Laravel 77 | | authentication guard. 78 | | 79 | | You can also override this value in model classes to use a different 80 | | authentication guard for your specific models. 81 | | IE: Some of your models can only be created / updated by specific users 82 | | who logged in from a specific authentication guard. 83 | | 84 | */ 85 | 86 | 'guard' => null, 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | User Model Definition 91 | |-------------------------------------------------------------------------- 92 | | 93 | | Please specify a user model that should be used to setup `creator` 94 | | and `updater` relationship. 95 | | 96 | */ 97 | 98 | 'user' => \App\User::class, 99 | 100 | /* 101 | |-------------------------------------------------------------------------- 102 | | The `createdBy` attribute 103 | |-------------------------------------------------------------------------- 104 | | 105 | | Please define an attribute to use when recording the creator 106 | | identifier. 107 | | 108 | */ 109 | 110 | 'createdBy' => 'created_by', 111 | 112 | /* 113 | |-------------------------------------------------------------------------- 114 | | The `updatedBy` attribute 115 | |-------------------------------------------------------------------------- 116 | | 117 | | Please define an attribute to use when recording the updater 118 | | identifier. 119 | | 120 | */ 121 | 122 | 'updatedBy' => 'updated_by', 123 | 124 | /* 125 | |-------------------------------------------------------------------------- 126 | | The `deletedBy` attribute 127 | |-------------------------------------------------------------------------- 128 | | 129 | | Please define an attribute to use when recording the user 130 | | identifier who deleted the record. This feature would only work 131 | | if you are using SoftDeletes in your model. 132 | | 133 | */ 134 | 135 | 'deletedBy' => 'deleted_by', 136 | ]; 137 | ``` 138 | 139 | ## Usage 140 | 141 | ### Add some blameable attributes to your migrations 142 | 143 | ```php 144 | Schema::create('some_tables', function (Blueprint $table) { 145 | // ... 146 | 147 | $table->integer('created_by')->nullable(); 148 | $table->integer('updated_by')->nullable(); 149 | $table->integer('deleted_by')->nullable(); 150 | 151 | // ... 152 | 153 | 154 | 155 | /** 156 | * You can also create foreign key constrains 157 | * for the blameable attributes. 158 | */ 159 | $table->foreign('created_by') 160 | ->references('id')->on('users') 161 | ->onDelete('cascade'); 162 | 163 | $table->foreign('updated_by') 164 | ->references('id')->on('users') 165 | ->onDelete('cascade'); 166 | }); 167 | ``` 168 | 169 | ### Attach Blameable behavior into your Model 170 | 171 | ```php 172 | use Illuminate\Database\Eloquent\Model; 173 | use RichanFongdasen\EloquentBlameable\BlameableTrait; 174 | 175 | class Post extends Model 176 | { 177 | use BlameableTrait; 178 | 179 | // ... 180 | } 181 | ``` 182 | 183 | ### Override default configuration using static property 184 | 185 | ```php 186 | /** 187 | * You can override the default configuration 188 | * by defining this static property in your Model 189 | */ 190 | protected static $blameable = [ 191 | 'guard' => 'customGuard', 192 | 'user' => \App\User::class, 193 | 'createdBy' => 'user_id', 194 | 'updatedBy' => null 195 | ]; 196 | ``` 197 | 198 | ### Override default configuration using public method 199 | 200 | ```php 201 | /** 202 | * You can override the default configuration 203 | * by defining this method in your Model 204 | */ 205 | public function blameable() 206 | { 207 | return [ 208 | 'guard' => 'customGuard', 209 | 'user' => \App\User::class, 210 | 'createdBy' => 'user_id', 211 | 'updatedBy' => null 212 | ]; 213 | } 214 | ``` 215 | 216 | ### Using Blameable Query Scopes 217 | 218 | ```php 219 | // Get all posts which have created by the given user id 220 | Post::createdBy($userId)->get(); 221 | 222 | // Get all posts which have updated by the given user object 223 | $user = User::findOrFail(1); 224 | Post::updatedBy($user)->get(); 225 | ``` 226 | 227 | ### Accessing Creator / Updater Object 228 | 229 | ```php 230 | // Get the creator user object 231 | Post::findOrFail($postId)->creator; 232 | 233 | // Get the updater user object 234 | Post::findOrFail($postId)->updater; 235 | ``` 236 | 237 | ## License 238 | 239 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 240 | -------------------------------------------------------------------------------- /tests/ObservedModelTests.php: -------------------------------------------------------------------------------- 1 | impersonateUser(); 17 | $post = Post::factory()->create(); 18 | 19 | $this->assertFalse($post->isDirty('created_by')); 20 | $this->assertFalse($post->isDirty('updated_by')); 21 | $this->assertEquals($this->user->getKey(), $post->getAttribute('created_by')); 22 | $this->assertEquals($this->user->getKey(), $post->getAttribute('updated_by')); 23 | } 24 | 25 | #[Test] 26 | public function it_works_perfectly_on_updating_existing_post() 27 | { 28 | $this->impersonateUser(); 29 | $post = Post::factory()->create(); 30 | 31 | $this->impersonateOtherUser(); 32 | $post->setAttribute('title', 'Another Title'); 33 | $post->save(); 34 | 35 | $this->assertFalse($post->isDirty('created_by')); 36 | $this->assertFalse($post->isDirty('updated_by')); 37 | $this->assertEquals($this->user->getKey(), $post->getAttribute('created_by')); 38 | $this->assertEquals($this->otherUser->getKey(), $post->getAttribute('updated_by')); 39 | } 40 | 41 | #[Test] 42 | public function it_works_perfectly_on_deleting_post1() 43 | { 44 | $this->impersonateUser(); 45 | $post = Post::factory()->create(); 46 | 47 | $post->delete(); 48 | $deletedPost = Post::onlyTrashed()->where('id', $post->getKey())->first(); 49 | 50 | $this->assertEquals($this->user->getKey(), $deletedPost->getAttribute('deleted_by')); 51 | } 52 | 53 | #[Test] 54 | public function it_works_perfectly_on_deleting_post2() 55 | { 56 | $this->impersonateUser(); 57 | $post = Post::factory()->create(); 58 | 59 | $this->impersonateOtherUser(); 60 | $post->delete(); 61 | $deletedPost = Post::onlyTrashed()->where('id', $post->getKey())->first(); 62 | 63 | $this->assertEquals($this->user->getKey(), $deletedPost->getAttribute('updated_by')); 64 | $this->assertEquals($this->otherUser->getKey(), $deletedPost->getAttribute('deleted_by')); 65 | } 66 | 67 | #[Test] 68 | public function it_works_perfectly_on_restoring_deleted_post() 69 | { 70 | $this->impersonateUser(); 71 | $post = Post::factory()->create(); 72 | 73 | $this->impersonateOtherUser(); 74 | $post->delete(); 75 | Post::onlyTrashed()->where('id', $post->getKey())->first()->restore(); 76 | $restoredPost = Post::where('id', $post->getKey())->first(); 77 | 78 | $this->assertNull($restoredPost->getAttribute('deleted_by')); 79 | } 80 | 81 | #[Test] 82 | public function it_works_perfectly_on_creating_a_new_comment() 83 | { 84 | $this->impersonateUser(); 85 | $comment = Comment::factory()->create([ 86 | 'post_id' => 100 87 | ]); 88 | 89 | $this->assertFalse($comment->isDirty('created_by')); 90 | $this->assertFalse($comment->isDirty('updated_by')); 91 | $this->assertEquals($this->user->getKey(), $comment->getAttribute('user_id')); 92 | $this->assertEquals($this->user->getKey(), $comment->getAttribute('updater_id')); 93 | } 94 | 95 | #[Test] 96 | public function it_works_perfectly_on_updating_existing_comment() 97 | { 98 | $this->impersonateUser(); 99 | $post = Post::factory()->create(); 100 | $comment = Comment::factory()->create([ 101 | 'post_id' => $post->getKey() 102 | ]); 103 | 104 | $this->impersonateOtherUser(); 105 | $comment->setAttribute('content', 'Another Content'); 106 | $comment->save(); 107 | $updatedPost = Post::where('id', $post->getKey())->first(); 108 | 109 | $this->assertFalse($comment->isDirty('created_by')); 110 | $this->assertFalse($comment->isDirty('updated_by')); 111 | $this->assertEquals($this->user->getKey(), $comment->getAttribute('user_id')); 112 | $this->assertEquals($this->otherUser->getKey(), $comment->getAttribute('updater_id')); 113 | 114 | // blameable attributes should not be updated on any touched models 115 | $this->assertEquals($this->user->getKey(), $updatedPost->getAttribute('created_by')); 116 | $this->assertEquals($this->user->getKey(), $updatedPost->getAttribute('updated_by')); 117 | } 118 | 119 | #[Test] 120 | public function it_works_perfectly_on_deleting_comment1() 121 | { 122 | $this->impersonateUser(); 123 | $comment = Comment::factory()->create([ 124 | 'post_id' => 100 125 | ]); 126 | 127 | $comment->delete(); 128 | $deletedComment = Comment::onlyTrashed()->where('id', $comment->getKey())->first(); 129 | 130 | $this->assertEquals($this->user->getKey(), $deletedComment->getAttribute('eraser_id')); 131 | } 132 | 133 | #[Test] 134 | public function it_works_perfectly_on_deleting_comment2() 135 | { 136 | $this->impersonateUser(); 137 | $comment = Comment::factory()->create([ 138 | 'post_id' => 100 139 | ]); 140 | 141 | $this->impersonateOtherUser(); 142 | $comment->delete(); 143 | $deletedComment = Comment::onlyTrashed()->where('id', $comment->getKey())->first(); 144 | 145 | $this->assertEquals($this->user->getKey(), $deletedComment->getAttribute('updater_id')); 146 | $this->assertEquals($this->otherUser->getKey(), $deletedComment->getAttribute('eraser_id')); 147 | } 148 | 149 | #[Test] 150 | public function it_works_perfectly_on_restoring_deleted_comment() 151 | { 152 | $this->impersonateUser(); 153 | $comment = Comment::factory()->create([ 154 | 'post_id' => 100 155 | ]); 156 | 157 | $this->impersonateOtherUser(); 158 | $comment->delete(); 159 | Comment::onlyTrashed()->where('id', $comment->getKey())->first()->restore(); 160 | $restoredComment = Comment::where('id', $comment->getKey())->first(); 161 | 162 | $this->assertNull($restoredComment->getAttribute('deleted_by')); 163 | } 164 | 165 | #[Test] 166 | public function it_works_perfectly_on_creating_a_new_user1() 167 | { 168 | $this->impersonateUser(); 169 | 170 | $this->assertFalse($this->user->isDirty('created_by')); 171 | $this->assertFalse($this->user->isDirty('updated_by')); 172 | $this->assertNull($this->user->getAttribute('created_by')); 173 | $this->assertNull($this->user->getAttribute('updated_by')); 174 | } 175 | 176 | #[Test] 177 | public function it_works_perfectly_on_creating_a_new_user2() 178 | { 179 | $this->impersonateUser(); 180 | $this->impersonateOtherUser(); 181 | 182 | $this->assertFalse($this->otherUser->isDirty('created_by')); 183 | $this->assertFalse($this->otherUser->isDirty('updated_by')); 184 | $this->assertEquals($this->user->getKey(), $this->otherUser->getAttribute('created_by')); 185 | $this->assertEquals($this->user->getKey(), $this->otherUser->getAttribute('updated_by')); 186 | } 187 | 188 | #[Test] 189 | public function it_works_perfectly_on_updating_existing_user() 190 | { 191 | $this->impersonateUser(); 192 | $user = User::factory()->create(); 193 | 194 | $this->impersonateOtherUser(); 195 | $user->setAttribute('email', 'another@email.com'); 196 | $user->save(); 197 | 198 | $this->assertFalse($user->isDirty('created_by')); 199 | $this->assertFalse($user->isDirty('updated_by')); 200 | $this->assertEquals($this->user->getKey(), $user->getAttribute('created_by')); 201 | $this->assertEquals($this->otherUser->getKey(), $user->getAttribute('updated_by')); 202 | } 203 | 204 | #[Test] 205 | public function it_works_perfectly_on_deleting_user1() 206 | { 207 | $this->impersonateUser(); 208 | $user = User::factory()->create(); 209 | 210 | $user->delete(); 211 | $deletedUser = User::onlyTrashed()->where('id', $user->getKey())->first(); 212 | 213 | $this->assertEquals($this->user->getKey(), $deletedUser->getAttribute('deleted_by')); 214 | } 215 | 216 | #[Test] 217 | public function it_works_perfectly_on_deleting_user2() 218 | { 219 | $this->impersonateUser(); 220 | $user = User::factory()->create(); 221 | 222 | $this->impersonateOtherUser(); 223 | $user->delete(); 224 | $deletedUser = User::onlyTrashed()->where('id', $user->getKey())->first(); 225 | 226 | $this->assertEquals($this->user->getKey(), $deletedUser->getAttribute('updated_by')); 227 | $this->assertEquals($this->otherUser->getKey(), $deletedUser->getAttribute('deleted_by')); 228 | } 229 | 230 | #[Test] 231 | public function it_works_perfectly_on_restoring_deleted_user() 232 | { 233 | $this->impersonateUser(); 234 | $user = User::factory()->create(); 235 | 236 | $this->impersonateOtherUser(); 237 | $user->delete(); 238 | User::onlyTrashed()->where('id', $user->getKey())->first()->restore(); 239 | $restoredUser = User::where('id', $user->getKey())->first(); 240 | 241 | $this->assertNull($restoredUser->getAttribute('deleted_by')); 242 | } 243 | 244 | #[Test] 245 | public function it_will_set_null_creator_and_null_updater_on_unauthenticated_user() 246 | { 247 | $post = Post::factory()->create(); 248 | 249 | $this->assertNull($post->getAttribute('created_by')); 250 | $this->assertNull($post->getAttribute('updated_by')); 251 | } 252 | 253 | #[Test] 254 | public function it_wont_cause_any_error_when_deleting_model_without_soft_deletes() 255 | { 256 | $this->impersonateAdmin(); 257 | 258 | $news = News::factory()->create(); 259 | $news->delete(); 260 | $news->fresh(); 261 | 262 | $this->assertEquals($this->admin->getKey(), $news->getAttribute('created_by')); 263 | $this->assertEquals($this->admin->getKey(), $news->getAttribute('updated_by')); 264 | $this->assertFalse($news->exists); 265 | $this->assertCount(0, $news->getDirty()); 266 | } 267 | } 268 | --------------------------------------------------------------------------------