├── .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 | [](https://github.com/richan-fongdasen/eloquent-blameable/actions/workflows/main.yml)
2 | [](https://codecov.io/gh/richan-fongdasen/eloquent-blameable)
3 | [](https://packagist.org/packages/richan-fongdasen/eloquent-blameable)
4 | [](https://packagist.org/packages/richan-fongdasen/eloquent-blameable)
5 | [](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 |
--------------------------------------------------------------------------------