diff --git a/config/activity-log.php b/config/activity-log.php
index 5e6c337..f9591ef 100644
--- a/config/activity-log.php
+++ b/config/activity-log.php
@@ -3,8 +3,13 @@
declare(strict_types=1);
return [
- 'activity_model' => \Spatie\Activitylog\Models\Activity::class,
-
+ // Activity model for the read (timeline) path. Leave null to inherit
+ // Spatie's `activitylog.activity_model` — the same model used for writes —
+ // so a tenant-scoped Activity subclass applies to both. Set explicitly only
+ // to override that.
+ 'activity_model' => null,
+
+
'default_per_page' => 20,
'pagination_buffer' => 2,
'deduplicate_by_default' => true,
diff --git a/resources/views/timeline.blade.php b/resources/views/timeline.blade.php
index 7801d71..71091f8 100644
--- a/resources/views/timeline.blade.php
+++ b/resources/views/timeline.blade.php
@@ -2,7 +2,7 @@
@if ($entries->isEmpty())
diff --git a/src/Timeline/Sources/AbstractTimelineSource.php b/src/Timeline/Sources/AbstractTimelineSource.php
index 4df8a4d..cf43e55 100644
--- a/src/Timeline/Sources/AbstractTimelineSource.php
+++ b/src/Timeline/Sources/AbstractTimelineSource.php
@@ -19,6 +19,25 @@ public function priority(): int
return $this->priority;
}
+ /**
+ * Resolve the Activity model used for the read path.
+ *
+ * Prefers the plugin's own key, then falls back to Spatie's canonical
+ * `activitylog.activity_model` — the same key that governs writes — so the
+ * rendered timeline reads through whatever (possibly tenant-scoped) Activity
+ * subclass the host configured, even when the hyphenated plugin config was
+ * never published.
+ *
+ * @return class-string<\Spatie\Activitylog\Contracts\Activity&\Illuminate\Database\Eloquent\Model>
+ */
+ protected function getActivityModelClass(): string
+ {
+ /** @var class-string<\Spatie\Activitylog\Contracts\Activity&\Illuminate\Database\Eloquent\Model> */
+ return config('activity-log.activity_model')
+ ?? config('activitylog.activity_model')
+ ?? \Spatie\Activitylog\Models\Activity::class;
+ }
+
protected function dedupKeyFor(string $class, int|string $id, CarbonImmutable $occurredAt): string
{
return sprintf(
@@ -29,6 +48,16 @@ protected function dedupKeyFor(string $class, int|string $id, CarbonImmutable $o
);
}
+ /**
+ * Dedup key for an activity-log row. Unlike the second-precision key above,
+ * this includes the activity id so several distinct activities written for
+ * the same subject in the same second (e.g. a multi-field save) stay separate.
+ */
+ protected function dedupKeyForActivity(string $class, int|string $id, CarbonImmutable $occurredAt, int|string $activityId): string
+ {
+ return $this->dedupKeyFor($class, $id, $occurredAt).':'.$activityId;
+ }
+
/**
* @return Relation
*/
diff --git a/src/Timeline/Sources/ActivityLogSource.php b/src/Timeline/Sources/ActivityLogSource.php
index 4c7e0e5..a885a56 100644
--- a/src/Timeline/Sources/ActivityLogSource.php
+++ b/src/Timeline/Sources/ActivityLogSource.php
@@ -13,14 +13,6 @@
final class ActivityLogSource extends AbstractTimelineSource
{
- /**
- * Get the activity model class from config or use default
- */
- private function getActivityModelClass(): string
- {
- return config('activity-log.activity_model', ActivityModel::class);
- }
-
public function resolve(Model $subject, Window $window): iterable
{
throw_if($subject->getKey() === null, DomainException::class, 'ActivityLogSource cannot resolve entries for an unsaved subject.');
@@ -56,7 +48,7 @@ private function makeEntry(Model $subject, ActivityModel $activity): TimelineEnt
type: 'activity_log',
event: $event,
occurredAt: $occurredAt,
- dedupKey: $this->dedupKeyFor($subject->getMorphClass(), (string) $subject->getKey(), $occurredAt),
+ dedupKey: $this->dedupKeyForActivity($subject->getMorphClass(), (string) $subject->getKey(), $occurredAt, (string) $activity->getKey()),
sourcePriority: $this->priority,
subject: $subject,
causer: $activity->causer,
diff --git a/src/Timeline/Sources/RelatedActivityLogSource.php b/src/Timeline/Sources/RelatedActivityLogSource.php
index 7a01a38..061ca71 100644
--- a/src/Timeline/Sources/RelatedActivityLogSource.php
+++ b/src/Timeline/Sources/RelatedActivityLogSource.php
@@ -14,14 +14,6 @@
final class RelatedActivityLogSource extends AbstractTimelineSource
{
- /**
- * Get the activity model class from config or use default
- */
- private function getActivityModelClass(): string
- {
- return config('activity-log.activity_model', ActivityModel::class);
- }
-
/**
* @param array $relations
*/
@@ -54,7 +46,7 @@ public function resolve(Model $subject, Window $window): iterable
}
$modelClass = $this->getActivityModelClass();
-
+
$query = $modelClass::query()
->with(['causer', 'subject'])
->where(function (Builder $q) use ($subjectPairs): void {
diff --git a/tests/Feature/ActivityModelResolutionTest.php b/tests/Feature/ActivityModelResolutionTest.php
new file mode 100644
index 0000000..3277867
--- /dev/null
+++ b/tests/Feature/ActivityModelResolutionTest.php
@@ -0,0 +1,40 @@
+set('activity-log.activity_model', ScopedActivity::class);
+
+ $person = Person::factory()->create();
+
+ $entries = collect((new ActivityLogSource(priority: 10))->resolve($person, new Window(cap: 10)));
+
+ expect($entries)->toBeEmpty();
+});
+
+it('falls back to Spatie activitylog.activity_model when the plugin key is null', function (): void {
+ config()->set('activity-log.activity_model', null);
+ config()->set('activitylog.activity_model', ScopedActivity::class);
+
+ $person = Person::factory()->create();
+
+ $entries = collect((new ActivityLogSource(priority: 10))->resolve($person, new Window(cap: 10)));
+
+ expect($entries)->toBeEmpty();
+});
+
+it('uses the base Spatie Activity when nothing is configured', function (): void {
+ config()->set('activity-log.activity_model', null);
+ config()->set('activitylog.activity_model', null);
+
+ $person = Person::factory()->create();
+
+ $entries = collect((new ActivityLogSource(priority: 10))->resolve($person, new Window(cap: 10)));
+
+ expect($entries)->toHaveCount(1);
+});
diff --git a/tests/Feature/BuilderDedupTest.php b/tests/Feature/BuilderDedupTest.php
index 45eb5e8..2647a4c 100644
--- a/tests/Feature/BuilderDedupTest.php
+++ b/tests/Feature/BuilderDedupTest.php
@@ -65,6 +65,31 @@
expect($entries)->toHaveCount(1);
});
+it('keeps distinct own-activity entries that land in the same second (multi-change save)', function (): void {
+ $person = Person::factory()->create();
+ $person->update(['name' => 'First change']);
+ $person->update(['name' => 'Second change']);
+
+ // Simulate one save producing several activity rows in the same second.
+ $stamp = CarbonImmutable::parse('2026-04-17T10:00:00Z');
+ Activity::query()
+ ->where('subject_type', $person->getMorphClass())
+ ->where('subject_id', $person->id)
+ ->update(['created_at' => $stamp]);
+
+ $count = Activity::query()
+ ->where('subject_type', $person->getMorphClass())
+ ->where('subject_id', $person->id)
+ ->count();
+
+ $entries = TimelineBuilder::make($person)
+ ->fromActivityLog()
+ ->deduplicate()
+ ->get();
+
+ expect($entries)->toHaveCount($count);
+});
+
it('deduplicate(false) skips dedup entirely', function (): void {
$person = Person::factory()->create();
$email = Email::factory()->for($person)->create();
diff --git a/tests/Fixtures/Models/ScopedActivity.php b/tests/Fixtures/Models/ScopedActivity.php
new file mode 100644
index 0000000..a228b6c
--- /dev/null
+++ b/tests/Fixtures/Models/ScopedActivity.php
@@ -0,0 +1,22 @@
+whereRaw('1 = 0');
+ });
+ }
+}