Sources
Sources own where entries come from. The Relaticle\ActivityLog\Timeline\TimelineBuilder composes one or more sources, asks each to resolve() inside a shared Window, and merges the streams. This page covers every built-in source plus the addSource() extension hook. For dedup mechanics, source-priority defaults, and the entry-type taxonomy, see /concepts/how-it-works.
fromActivityLog()
Registers Relaticle\ActivityLog\Timeline\Sources\ActivityLogSource — the subject's own spatie activity log.
public function fromActivityLog(?int $priority = null, ?string $mergedRenderer = null): self
$entries = $record->timeline()
->fromActivityLog()
->get();
Reads from the activity_log table where subject_type and subject_id match the subject. Each row becomes a TimelineEntry with type='activity_log'.
ActivityLogSource::resolve() throws a DomainException when $subject->getKey() === null. Don't call timeline() from a creating model event or on a fresh, unsaved instance.$priority overrides the default of 10 (see source_priorities.activity_log).
Same-save merge (opt-in)
One save can produce several activity rows — e.g. a native updated row plus a separate custom-field row. Pass $mergedRenderer to collapse rows that share a non-empty batch_uuid into one TimelineEntry:
$entries = $record->timeline()
->fromActivityLog(mergedRenderer: 'merged-activity')
->get();
What you get for a merged group:
- One entry per batch. Rows sharing a
batch_uuidbecome a single entry; rows without abatch_uuideach stay their own entry. - Combined
properties. The merged entry'spropertiesis the union of every grouped row's data (nativeattribute_changes+ customproperties), so no payload is lost. - Explicit
renderer. The merged entry'srendereris set to your string.RendererRegistryresolvesrendererbefore event/type, so the merge picks a renderer without overriding any global event renderer. Register a renderer for that key (see /essentials/customization). - Read-side only. This never changes how activities are written/logged — it only groups on read.
batch_uuid column. Rows are grouped by spatie's batch_uuid. The package only reads this column — the host app owns it. If your activity_log table doesn't have it, add it:Schema::table('activity_log', function (Blueprint $table): void {
$table->uuid('batch_uuid')->nullable()->index();
});
batch_uuid per save so related rows group together — via spatie's Activity::beforeLogging hook. Rows with a null/empty batch_uuid are never merged.$mergedRenderer = null keeps the original per-row behaviour — merge is fully opt-in and backward-compatible. fromActivityLogOf() does not merge yet (tracked as follow-up).Wiring it end-to-end
Four steps. The merge itself is one argument — the surrounding work is giving rows a shared batch_uuid (write side, host-owned) and binding a renderer to the key (read side).
1. Add the batch_uuid column (see the migration above).
2. Stamp one batch_uuid per save so the rows from a single save share it. This is host-owned — the package only reads the column. Spatie writes batch_uuid for every activity logged inside one batch; use whatever batching mechanism your app already has so all rows from a save get the same uuid. Rows left with a null/empty batch_uuid are never merged and render one-per-row as before.
3. Register a renderer for the merge key. The string you pass as mergedRenderer becomes $entry->renderer, which the registry resolves first — before any event/type binding (see renderer resolution order). Bind it through any channel:
use Relaticle\ActivityLog\Facades\Timeline;
Timeline::registerRenderer('merged-activity', \App\Timeline\Renderers\MergedActivityRenderer::class);
4. Enable the merge on the builder:
$entries = $record->timeline()
->fromActivityLog(mergedRenderer: 'merged-activity')
->get();
What the renderer receives
A merged entry's properties is the union of every grouped row's payload — native attribute_changes (the attributes / old maps) plus each row's custom properties, keyed as each row had them. Repeated array-valued keys accumulate instead of overwriting: if one save logs a row per field all under the same key (e.g. custom_field_changes), every row's list is concatenated under that key, so nothing is lost.
The rest of the entry comes from a representative base row — the first grouped row that carries non-empty attribute_changes, else the first row — so event, title, occurredAt, and causer reflect that row. Write your renderer against $entry->properties (mixed native + custom keys); the built-in ActivityLogSummary helper still works for the native portion (see customization).
fromActivityLogOf(array $relations)
Registers Relaticle\ActivityLog\Timeline\Sources\RelatedActivityLogSource — spatie activity-log entries that belong to the subject's related models.
public function fromActivityLogOf(array $relations, ?int $priority = null): self
$entries = $opportunity->timeline()
->fromActivityLogOf(['comments', 'tasks'])
->get();
For each named relation, the source loads the related rows, then queries activity_log for matching (subject_type, subject_id) pairs.
RelatedActivityLogSource calls $subject->{$relation}()->get() with no limit to discover IDs to look up. For relations with thousands of rows this is a real cost — every timeline render loads every related row into memory. Tracked by issue #14. Workaround: use addSource() with a custom-scoped source if the relation is large.type='related_activity_log' and dedup-collide with the matching RelatedModelSource event when both fire at the same second. The built-in ActivityLogRenderer is auto-registered for both activity_log and related_activity_log, so spatie diffs render automatically for related-model logs too.fromRelation(string $relation, Closure $configure)
Registers Relaticle\ActivityLog\Timeline\Sources\RelatedModelSource — synthetic events derived from timestamp columns on related rows. Use this when you don't log the related model with spatie but still want its lifecycle on the timeline.
public function fromRelation(string $relation, Closure $configure, ?int $priority = null): self
use Filament\Support\Icons\Heroicon;
use Relaticle\ActivityLog\Timeline\Sources\RelatedModelSource;
$record->timeline()
->fromRelation('invoices', function (RelatedModelSource $source): void {
$source
->event('issued_at', 'invoice.issued', icon: Heroicon::DocumentText->value, color: 'info')
->event('paid_at', 'invoice.paid', icon: Heroicon::CheckCircle->value, color: 'success')
->event('voided_at', 'invoice.voided', color: 'danger', when: fn ($invoice): bool => $invoice->total > 0)
->with(['customer'])
->using(fn ($query) => $query->where('archived', false))
->title(fn ($invoice): string => "Invoice #{$invoice->number}")
->description(fn ($invoice): string => "{$invoice->total} {$invoice->currency}")
->causer('createdBy');
});
RelatedModelSource API
| Method | Purpose |
|---|---|
event($column, $event, $icon = null, $color = null, $when = null) | Register one event per timestamp column. $when is an optional row-level filter returning bool. |
with(array $relations) | Eager-loads relations on every event query. Prevents N+1 in renderers. |
using(Closure $modifier) | SQL-level query modifier — receives the query builder. Use for tenant scopes, soft-delete, archived flags. |
title(Closure $resolver) | Per-row resolver for the entry title. Receives the related row, returns string. |
description(Closure $resolver) | Per-row resolver for the entry description. Receives the related row, returns string. |
causer(Closure|string $resolver) | Relation name as a string, or a Closure returning Model|null. |
event(when: ...) runs post-fetch in PHP. It filters yielded entries one-by-one after the SQL query has already loaded the rows — it does not reduce the row count fetched from the database. For SQL-level filtering (tenant scope, soft-delete, archived flag), use using() instead so the predicate becomes a WHERE clause.fromCustom(Closure $resolver)
Registers Relaticle\ActivityLog\Timeline\Sources\CustomEventSource — yields Relaticle\ActivityLog\Timeline\TimelineEntry instances directly, with no assumptions about where they come from.
public function fromCustom(Closure $resolver, ?int $priority = null): self
use Carbon\CarbonImmutable;
use Relaticle\ActivityLog\Timeline\TimelineEntry;
use Relaticle\ActivityLog\Timeline\Window;
$record->timeline()
->fromCustom(function ($subject, Window $window) {
$query = $subject->stripeCharges()
->orderByDesc('created_at')
->limit($window->cap);
if ($window->from instanceof CarbonImmutable) {
$query->where('created_at', '>=', $window->from);
}
if ($window->to instanceof CarbonImmutable) {
$query->where('created_at', '<=', $window->to);
}
foreach ($query->cursor() as $charge) {
yield new TimelineEntry(
id: "stripe:charge:{$charge->id}",
type: 'custom',
event: 'charge.succeeded',
occurredAt: CarbonImmutable::parse($charge->created_at),
dedupKey: "stripe:charge:{$charge->id}",
sourcePriority: 30,
subject: $subject,
title: "Charge {$charge->amount_formatted}",
);
}
});
The closure receives the subject and the active Window — respect cap, from, and to to keep memory bounded and honor ->between(...) filters.
CustomEventSource validates yield types. Yielding anything other than a TimelineEntry instance throws TypeError with the offending type name. Don't yield arrays, raw models, or DTOs.addSource(TimelineSource $source)
For reusable source classes. Implement the Relaticle\ActivityLog\Contracts\TimelineSource contract and register the instance directly:
namespace App\Timeline\Sources;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Relaticle\ActivityLog\Contracts\TimelineSource;
use Relaticle\ActivityLog\Timeline\TimelineEntry;
use Relaticle\ActivityLog\Timeline\Window;
final class StripePaymentSource implements TimelineSource
{
public function __construct(private readonly int $priority = 30) {}
public function priority(): int
{
return $this->priority;
}
public function resolve(Model $subject, Window $window): iterable
{
$query = $subject->stripePayments()
->orderByDesc('created_at')
->limit($window->cap);
if ($window->from instanceof CarbonImmutable) {
$query->where('created_at', '>=', $window->from);
}
foreach ($query->cursor() as $payment) {
yield new TimelineEntry(
id: "stripe:payment:{$payment->id}",
type: 'custom',
event: "payment.{$payment->status}",
occurredAt: CarbonImmutable::parse($payment->created_at),
dedupKey: "stripe:payment:{$payment->id}",
sourcePriority: $this->priority,
subject: $subject,
title: "Payment {$payment->amount_formatted}",
);
}
}
}
$record->timeline()
->fromActivityLog()
->addSource(new StripePaymentSource());
Prefer addSource() over fromCustom() when the source is reusable across models, has its own constructor dependencies, or needs to be tested in isolation.
Per-call $priority override
Every from*() method accepts an optional $priority argument that overrides the default from config('activity-log.source_priorities.*'):
$record->timeline()
->fromActivityLog(priority: 50)
->fromActivityLogOf(['comments'], priority: 60)
->fromRelation('invoices', $configure, priority: 80)
->fromCustom($resolver, priority: 100);
Useful when one resource needs a non-default priority without touching config/activity-log.php. See /concepts/how-it-works for how priority resolves dedup ties.