diff --git a/app/Domains/Logging/Enums/ValueChangeActorType.php b/app/Domains/Logging/Enums/ValueChangeActorType.php new file mode 100644 index 0000000..6e3cfcc --- /dev/null +++ b/app/Domains/Logging/Enums/ValueChangeActorType.php @@ -0,0 +1,9 @@ +getLoggedAttributes(), + array_keys($model->getChanges()), + )); + + if ($changedAttributes === []) { + return; + } + + $userId = Auth::id(); + $actorType = $userId === null + ? ValueChangeActorType::System + : ValueChangeActorType::User; + + foreach ($changedAttributes as $attribute) { + $model->valueChanges()->create([ + 'attribute' => $attribute, + 'old_value' => $model->getRawOriginal($attribute), + 'new_value' => $model->getAttributes()[$attribute] ?? null, + 'changed_at' => now(), + 'actor_type' => $actorType, + 'user_id' => $userId, + ]); + } + }); + } + + /** @return array */ + public function getLoggedAttributes(): array + { + if (! property_exists($this, 'loggedAttributes')) { + throw new LogicException(sprintf( + 'The [%s] model must define a $loggedAttributes property.', + static::class, + )); + } + + return array_values(array_unique($this->loggedAttributes)); + } + + /** @return MorphMany */ + public function valueChanges(): MorphMany + { + return $this->morphMany(ValueChange::class, 'trackable'); + } +} diff --git a/app/Domains/Logging/Models/ValueChange.php b/app/Domains/Logging/Models/ValueChange.php new file mode 100644 index 0000000..d9a60ea --- /dev/null +++ b/app/Domains/Logging/Models/ValueChange.php @@ -0,0 +1,47 @@ + */ + public function trackable(): MorphTo + { + return $this->morphTo(); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function casts(): array + { + return [ + 'trackable_id' => 'integer', + 'changed_at' => 'datetime', + 'actor_type' => ValueChangeActorType::class, + 'user_id' => 'integer', + ]; + } +} diff --git a/database/migrations/2026_08_03_000200_create_value_changes_table.php b/database/migrations/2026_08_03_000200_create_value_changes_table.php new file mode 100644 index 0000000..ba7ce3f --- /dev/null +++ b/database/migrations/2026_08_03_000200_create_value_changes_table.php @@ -0,0 +1,31 @@ +id(); + $table->morphs('trackable'); + $table->string('attribute'); + $table->text('old_value')->nullable(); + $table->text('new_value')->nullable(); + $table->timestamp('changed_at'); + $table->string('actor_type'); + $table->foreignId('user_id') + ->nullable() + ->constrained('users') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('value_changes'); + } +}; diff --git a/tests/Feature/Logging/LogsValueChangesTest.php b/tests/Feature/Logging/LogsValueChangesTest.php new file mode 100644 index 0000000..94f891d --- /dev/null +++ b/tests/Feature/Logging/LogsValueChangesTest.php @@ -0,0 +1,120 @@ +set('database.default', 'logging_test'); + config()->set('database.connections.logging_test', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'foreign_key_constraints' => true, + ]); + + Schema::create('users', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('logging_test_products', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->unsignedInteger('price'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + $migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php'); + $migration->up(); + } + + public function test_it_creates_one_system_record_per_configured_change(): void + { + $product = LoggingTestProduct::create([ + 'name' => 'Original', + 'price' => 100, + 'description' => 'Old description', + ]); + + $product->update([ + 'name' => 'Updated', + 'price' => 150, + 'description' => 'New description', + ]); + + $this->assertDatabaseCount('value_changes', 2); + $this->assertDatabaseHas('value_changes', [ + 'attribute' => 'name', + 'old_value' => 'Original', + 'new_value' => 'Updated', + 'actor_type' => ValueChangeActorType::System->value, + 'user_id' => null, + ]); + $this->assertDatabaseHas('value_changes', [ + 'attribute' => 'price', + 'old_value' => '100', + 'new_value' => '150', + 'actor_type' => ValueChangeActorType::System->value, + 'user_id' => null, + ]); + $this->assertTrue(ValueChange::firstOrFail()->trackable->is($product)); + } + + public function test_it_associates_an_authenticated_user_with_the_change(): void + { + Schema::getConnection()->table('users')->insert(['id' => 7]); + Auth::shouldReceive('id')->once()->andReturn(7); + + $product = LoggingTestProduct::create([ + 'name' => 'Original', + 'price' => 100, + ]); + + $product->update(['price' => 200]); + + $change = ValueChange::firstOrFail(); + + $this->assertSame(ValueChangeActorType::User, $change->actor_type); + $this->assertSame(7, $change->user_id); + } + + public function test_it_does_not_log_updates_to_unconfigured_attributes(): void + { + $product = LoggingTestProduct::create([ + 'name' => 'Original', + 'price' => 100, + 'description' => 'Old description', + ]); + + $product->update(['description' => 'New description']); + + $this->assertDatabaseCount('value_changes', 0); + } +} + +#[Fillable(['name', 'price', 'description'])] +class LoggingTestProduct extends Model +{ + use LogsValueChanges; + + protected $table = 'logging_test_products'; + + /** @var array */ + protected array $loggedAttributes = [ + 'name', + 'price', + ]; +} diff --git a/tests/Unit/Logging/ValueChangeTest.php b/tests/Unit/Logging/ValueChangeTest.php new file mode 100644 index 0000000..9fa0991 --- /dev/null +++ b/tests/Unit/Logging/ValueChangeTest.php @@ -0,0 +1,38 @@ +setRawAttributes([ + 'trackable_id' => '10', + 'attribute' => 'status', + 'old_value' => 'pending', + 'new_value' => 'paid', + 'changed_at' => '2026-08-03 15:30:00', + 'actor_type' => ValueChangeActorType::User->value, + 'user_id' => '20', + ]); + + $this->assertSame('value_changes', $valueChange->getTable()); + $this->assertFalse($valueChange->usesTimestamps()); + $this->assertSame(10, $valueChange->trackable_id); + $this->assertSame('status', $valueChange->attribute); + $this->assertSame('pending', $valueChange->old_value); + $this->assertSame('paid', $valueChange->new_value); + $this->assertSame('2026-08-03 15:30:00', $valueChange->changed_at->format('Y-m-d H:i:s')); + $this->assertSame(ValueChangeActorType::User, $valueChange->actor_type); + $this->assertSame(20, $valueChange->user_id); + $this->assertInstanceOf(MorphTo::class, $valueChange->trackable()); + $this->assertInstanceOf(User::class, $valueChange->user()->getRelated()); + } +}