Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f346330
Add activity log backend
riasvdv Aug 27, 2026
dbf697e
Refine activity logging architecture
riasvdv Aug 27, 2026
7a986c0
Fall back to email for unnamed activity actors
riasvdv Aug 27, 2026
e9c6cf3
Compare activity JSON without key order
riasvdv Aug 27, 2026
ef76688
Merge branch '6.x' into feature/activity-logs-base
riasvdv Aug 27, 2026
c873abd
Define activity changes with a DTO
riasvdv Aug 27, 2026
17c31a1
Return activity changes as DTOs
riasvdv Aug 27, 2026
fd71158
Merge origin/6.x into feature/activity-logs-base
riasvdv Aug 29, 2026
cf4df90
Merge branch '6.x' into feature/activity-logs-base
riasvdv Aug 31, 2026
7767882
Merge origin/6.x into feature/activity-logs-base
riasvdv Sep 1, 2026
1488f97
Document activity logging
riasvdv Sep 1, 2026
270c64a
Merge origin/feature/activity-logs-base
riasvdv Sep 1, 2026
e5358d3
Remove activity event validation rules
riasvdv Sep 1, 2026
0215cab
Remove activity payload validation
riasvdv Sep 1, 2026
166b691
Remove activity change type and ID
riasvdv Sep 1, 2026
9b24915
Refine activity ordering and icons
riasvdv Sep 3, 2026
a87e937
Merge remote-tracking branch 'origin/6.x' into feature/activity-logs-…
riasvdv Sep 3, 2026
83c1f15
Refine activity event data handling
riasvdv Sep 3, 2026
459ed5d
Move draft activity recording to event subscriber
riasvdv Sep 3, 2026
c6827e7
Move element write activity to event subscriber
riasvdv Sep 3, 2026
f69cb04
Subscribe to draft lifecycle activity
riasvdv Sep 3, 2026
77c475b
Index activity event actors
riasvdv Sep 3, 2026
94d1f3c
Fix activity scope generics
riasvdv Sep 3, 2026
993a1e1
Allow custom activity actor types
riasvdv Sep 3, 2026
799bc54
Merge remote-tracking branch 'origin/6.x' into feature/activity-logs-…
riasvdv Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
"CraftCms\\Cms\\Providers\\CraftServiceProvider"
],
"aliases": {
"Activities": "CraftCms\\Cms\\Support\\Facades\\Activities",
"Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses",
"AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer",
"Assets": "CraftCms\\Cms\\Support\\Facades\\Assets",
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

299 changes: 299 additions & 0 deletions docs/activity-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
# Activity logging

Craft records durable activity events for actions that users may need to inspect later, such as creating an entry, applying a draft, moving an element, or replacing an asset file. Each event records what happened, who caused it, what it affected, and when it occurred.

Activity events are application data stored in the `activityevents` database table. They are not application log messages, so do not write them with Laravel's `Log` facade.

## How an event is recorded

Core and plugin code describe an action with an activity event type, then pass an instance to the `Activities` facade. Recording is synchronous.

```mermaid
sequenceDiagram
participant Action as Business action
participant Type as Activity event type
participant Activities
participant Recorder as ActivityEventRecorder
participant DB as activityevents

Action->>Type: Construct after the action succeeds
Action->>Activities: record($event)
Activities->>Recorder: record($event)
Recorder->>Recorder: Resolve actor, subject, site, and labels
Recorder->>DB: Insert event and snapshots
DB-->>Action: ActivityEvent model
```

The recorder performs these steps:

1. Reads the event type's source, subject, actor, site, data, and changes.
2. Resolves the actor when the event type did not supply one.
3. Captures labels for the source, event, actor, subject, and site.
4. Inserts an `ActivityEvent` with the current time.

The insert uses the caller's database transaction. If the action and activity event run in one transaction, rolling back the action also removes the event. Record an event only after the corresponding action has succeeded, but before committing its transaction.

Craft records its built-in events at the shared write and lifecycle boundaries. For example, entry writes compare the saved entry with its previous state, omit no-op saves, and record only JSON-safe field values. Draft, element lifecycle, structural, and asset replacement operations record their events at their own successful completion points.

## What an event stores

An event separates identifiers that support queries from descriptive data that preserves history.

```mermaid
flowchart LR
Event[ActivityEvent]
Event --> Identity[Queryable identity]
Identity --> EventType[eventType]
Identity --> Source[source]
Identity --> Actor[actorType + actorId]
Identity --> Subject[subjectType + subjectId]
Identity --> Site[siteId]
Event --> Payload[JSON payload]
Payload --> Snapshots[snapshots]
Payload --> Changes[changes]
Payload --> Data[event-specific data]
Event --> Time[occurredAt]
```

| Value | Purpose |
| -------------------------- | ----------------------------------------------------- |
| `eventType` | Fully qualified event type class name |
| `source` | Stable source ID, normally `craft` or a plugin handle |
| `actorType`, `actorId` | User, system, or anonymous actor identity |
| `subjectType`, `subjectId` | Stable identity of the affected object |
| `siteId` | Site context, or `null` for a site-neutral event |
| `payload.snapshots` | Labels captured when the event occurred |
| `payload.changes` | Structured old and new values |
| `payload.data` | Data defined by the event type |
| `occurredAt` | Time the action occurred |

Snapshots keep an event readable after a user, subject, site, or plugin has been removed. If the event type class is no longer available, Craft returns the captured event label.

### Actors

When an event does not provide an actor, Craft resolves one from the current execution context:

| Context | Actor |
| ------------------------------- | ---------------------- |
| Authenticated request | Current user |
| Unauthenticated HTTP request | Anonymous |
| Console command or queue worker | Craft CMS system actor |

Pass an actor explicitly when the execution context does not identify the person responsible. A queued job started by a user is a common case. Event types may accept either a saved `User` element or an `ActivityActor`.

### Subjects

An element subject is normalized to its canonical element. Craft stores the element class and UID, not its numeric database ID. Draft activity therefore remains attached to the canonical element.

Plugins can describe a non-element subject with a stable type, ID, and label:

```php
use CraftCms\Cms\Activity\Data\ActivitySubject;

$subject = new ActivitySubject(
type: Campaign::class,
id: (string) $campaign->id,
label: $campaign->name,
);
```

Do not use a translated label, mutable handle, or array index as the subject ID. The ID must continue to identify the same object after its label changes.

### Data and changes

`data()` returns event-specific values used to describe or inspect the action. It must return a JSON object represented by an associative PHP array. Event type constructors should use specific parameter types; validate untrusted values before constructing the event.

Use `ActivityChange` when consumers need a consistent old-versus-new representation:

```php
use CraftCms\Cms\Activity\Data\ActivityChange;

new ActivityChange(
label: $field->name,
old: 'Draft',
new: 'Approved',
);
```

The label is captured for display. Old and new values must be JSON-encodable. Laravel throws while applying the payload cast if encoding fails. Avoid secrets, access tokens, full request bodies, and other data that should not remain in an audit history.

## Logging activity from a plugin

A plugin owns its activity event classes. The stored class name identifies the event type, while the plugin handle identifies its source.

The following example comes from a campaign plugin that sends an entry through an email provider. It records the campaign entry, site, provider response, recipient count, and responsible user.

### Define the plugin source once

Create a base event type so each plugin event reports the same source and translation category:

```php
<?php

declare(strict_types=1);

namespace Acme\Campaigns\Activity;

use Acme\Campaigns\Campaigns;
use CraftCms\Cms\Activity\ActivityEventType;
use CraftCms\Cms\Activity\Data\ActivitySource;

abstract class CampaignActivityEventType extends ActivityEventType
{
public static function source(): ActivitySource
{
return ActivitySource::fromPlugin(Campaigns::getInstance());
}
}
```

`ActivitySource::fromPlugin()` uses the plugin handle, name, and translation category. The recorder snapshots the source label, so old events remain identifiable after the plugin is uninstalled.

### Define an event type

```php
<?php

declare(strict_types=1);

namespace Acme\Campaigns\Activity;

use CraftCms\Cms\Activity\Data\ActivityActor;
use CraftCms\Cms\Activity\Models\ActivityEvent;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Site\Data\Site;
use CraftCms\Cms\User\Contracts\CraftUser;

use function CraftCms\Cms\t;

class CampaignSent extends CampaignActivityEventType
{
protected const string LABEL = 'Campaign sent';

protected const string ICON = 'paper-plane';

public function __construct(
Entry $subject,
?Site $site,
private readonly string $provider,
private readonly string $deliveryId,
private readonly int $recipientCount,
CraftUser|ActivityActor|null $actor = null,
) {
parent::__construct(subject: $subject, actor: $actor, site: $site);
}

public function data(): array
{
return [
'provider' => $this->provider,
'deliveryId' => $this->deliveryId,
'recipientCount' => $this->recipientCount,
];
}

public static function format(ActivityEvent $event): string
{
return t(
'Sent with {provider} to {count} recipients.',
[
'provider' => $event->data['provider'],
'count' => $event->data['recipientCount'],
],
category: self::source()->translationCategory,
);
}
}
```

`LABEL` is the short fallback description. Craft translates it using the source's translation category. `format()` may return a string, an `Htmlable`, or `null`. Returning `null` tells Craft to use the translated label. Craft sanitizes strings and HTML before returning them from `Activities::format()`.

### Record the event at the action boundary

Record the event where the plugin knows that the operation succeeded:

```php
use Acme\Campaigns\Activity\CampaignSent;
use CraftCms\Cms\Support\Facades\Activities;
use CraftCms\Cms\Support\Facades\Sites;

$delivery = $campaignClient->send($entry);

Activities::record(new CampaignSent(
subject: $entry,
site: Sites::getSiteById($entry->siteId),
provider: $delivery->provider,
deliveryId: $delivery->id,
recipientCount: $delivery->recipientCount,
));
```

An authenticated request supplies the actor automatically. A queued job should pass the user who requested the send when that attribution is available:

```php
Activities::record(new CampaignSent(
subject: $entry,
site: Sites::getSiteById($entry->siteId),
provider: $delivery->provider,
deliveryId: $delivery->id,
recipientCount: $delivery->recipientCount,
actor: $requestedBy,
));
```

Do not insert an `ActivityEvent` model directly. The facade supplies actor resolution, validation, snapshots, translation metadata, and a consistent occurrence time.

## Querying activity

`Activities::query()` returns an Eloquent builder ordered by `occurredAt` and then `id`, both newest first. The ID tie-breaker makes cursor pagination stable when events share a timestamp.

```php
use Acme\Campaigns\Activity\CampaignSent;
use CraftCms\Cms\Activity\Data\ActivitySubject;
use CraftCms\Cms\Support\Facades\Activities;

$events = Activities::query()
->subject(ActivitySubject::fromElement($entry))
->site($site)
->source('campaigns')
->eventTypes(CampaignSent::class)
->occurredFrom(now()->subMonth())
->cursorPaginate(50);

foreach ($events as $event) {
$label = Activities::format($event);
$icon = Activities::icon($event);
}
```

Available query scopes are:

| Scope | Matches |
| ---------------------------------------- | ----------------------------------------------- |
| `subject(ActivitySubject $subject)` | One subject type and ID |
| `site($site)` | One `Site` or site ID, plus site-neutral events |
| `eventTypes($eventTypes)` | One event type class name or an array of names |
| `actor(ActivityActor $actor)` | One actor type and ID |
| `source(string $source)` | One source ID |
| `occurredFrom(DateTimeInterface $date)` | Events on or after the date |
| `occurredUntil(DateTimeInterface $date)` | Events on or before the date |
| `newestFirst()` | Newest timestamp and ID first |

Use the formatter instead of calling an event type's `format()` method yourself. `Activities::format()` handles translation, sanitization, missing event classes, and formatter failures. `Activities::icon()` returns the event type's icon.

## Retention

Craft keeps activity indefinitely by default. Set `activityRetentionDuration` to let garbage collection delete older events:

```php
// config/general.php

use CraftCms\Cms\Cms;

return Cms::config()
->activityRetentionDuration('P90D');
```

The `CRAFT_ACTIVITY_RETENTION_DURATION` environment variable accepts the same duration values. Set the value to `0` for unlimited retention. Garbage collection deletes events older than the configured cutoff in chunks.

Choose a retention period based on the history users need and the data included in plugin payloads. Changing the period affects future garbage collection; it does not archive events before deleting them.
2 changes: 1 addition & 1 deletion resources/js/modules/auth-method-setup/auth.scss
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@

ul.auth-method-recovery-codes-list {
font-family:
SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace;
SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 0.9em !important;
max-width: 20em;
margin-inline: auto;
Expand Down
Loading
Loading