An audit log for Craft CMS. Anyone you give permission to can answer "who changed this, when, from where, and what did it say before?" — and "who signed in, who failed to, and who was given access?" — from the control panel, without reading log files or the database.
- Every content change, with its before and after. Entries, assets, users, categories, tags, Craft Commerce orders and products, and any element type a plugin adds: created, saved, deleted, restored and moved, with the value each changed field had before and after. Commerce's catalog pricing rules and discounts too.
- Every security event in the same log. Sign-ins, failed sign-ins, lock-outs, password and email changes, two-step and passkey methods, changes to someone's groups or permissions, admin rights given or taken, and one person signing in as another.
- Wherever the change came from. The control panel, a form on the site, a console command or a queue job, each tagged so you can tell them apart.
- Readable at scale. A resave of every entry or an import of a thousand products is one row describing the batch, not a thousand, and each element's History still shows it was included.
- Built to cost almost nothing. A page that changes nothing does no auditing work at all. Changes are written once, together, at the end of the request, and only if they were committed. If recording ever fails, the save or sign-in carries on regardless.
- Requirements
- Installation
- Editions
- What is recorded
- Configuration
- Permissions
- The control panel
- Console commands
- Extension points
- Personal data
- Performance
- Troubleshooting
- Uninstalling
- Craft CMS 5.6 or later
- PHP 8.2 or later
- MySQL 8 (PostgreSQL is not yet tested)
- Craft Commerce 5, only to record orders, products, variants, catalog pricing rules and discounts
From the Plugin Store: search for "Auditor" and install it.
With Composer:
composer require webdna/auditor
php craft plugin/install auditorRecording starts as soon as the plugin is installed. Nothing is recorded from before then.
Both editions record exactly the same things. They differ in where you can read them.
| Lite | Pro | |
|---|---|---|
| The log screen, its filters and each row's detail | ✓ | ✓ |
| Retention, redaction and deleted-user handling | ✓ | ✓ |
| History on each entry, asset, product or user | ✓ | |
| Activity on each user: what they changed, and what happened to their account | ✓ |
Moving from Lite to Pro shows History and Activity for everything already recorded.
Every row says who did it (the actor), what it happened to (the subject), when, from which context — control panel, site, console or queue, with the route, command or job (or, for a PHP script that boots Craft itself, the script's file name) — and, for web requests, the IP address and browser. Rows written in one request share a request id, so the log can show "everything else this request did".
With nobody signed in, the actor is Visitor on a site request (a form a visitor posts) and System everywhere else (a command, a queue job, the control panel's sign-in screen).
| Event | Recorded |
|---|---|
| Created | The new element's non-empty field values and attributes |
| Saved | Each field and attribute that changed, with its value before and after |
| Deleted, Restored | The element and who did it |
| Moved | A structure move: where it went |
| Batch | One row for a large operation (see below) |
A save that changed nothing writes no row. The before value is read from what Craft stored, so a form that re-posts every field is recorded as changing only what actually changed.
Alongside custom fields, these attributes are tracked:
| Element type | Attributes |
|---|---|
| Entries | title, slug, enabled, enabled for site, post date, expiry date, authors, entry type, section |
| Users | email, username, full name, first name, last name, admin, photo |
| Assets | title, filename, folder, volume, alt text |
| Categories, tags | title, slug, enabled |
| Commerce orders | order status, paid status, email, coupon code |
| Commerce products | title, enabled |
| Commerce variants | title, enabled, SKU, base price, base promotional price |
Not recorded: drafts, provisional drafts, autosaves, revisions, and the copies Craft makes when it propagates a save to other sites. Publishing a draft is recorded — it is the change people care about. A nested entry's change rolls up under its owner, so publishing an entry with three edited blocks reads as one change with three beneath it.
An operation is recorded as one batch row when it is a resave (resave/* commands, or a resave
Craft queues after a settings change), a batched queue job, an element-index action on more than one
element, or any single operation touching more than bulkThreshold top-level elements (10 by
default). A nested entry counts as the top-level element it belongs to, however deep its blocks go,
so re-saving two pages is a re-save of 2 entries. The row says what the operation was, the element
type, how many, and who ran it; every element it covered is listed, so each one's History reads
"Included in a re-save of 1,240 entries" — whether the resave ran as a command or as Craft's queue job.
A batch takes no before-snapshots and records no per-element changes, which is what keeps a large
resave fast. Set recordBulkDiffs to record each element in full instead.
A batched job that Craft splits over several queue runs writes one batch row per run.
Imports. A feed or script that calls saveElement() in a loop of its own, outside a Craft bulk
operation, is a run of single saves: each element gets its own row, with its before-snapshot. Most
import plugins work this way. To record the run as one batch, wrap the loop in a bulk operation:
$elements = Craft::$app->getElements();
$key = $elements->beginBulkOp();
try {
foreach ($variants as $variant) {
$elements->saveElement($variant);
}
} finally {
$elements->endBulkOp($key);
}A run of more than bulkThreshold elements is then one batch row.
Saving an element that owns others — a product and its variants, an entry and its Matrix blocks — reads the before-state of all the nested elements it saves in one query per table, however many there are, so a product with sixteen variants costs the log as many queries as one with a single variant.
Signed in · signed out · failed sign-in (the attempted username and Craft's real reason — never the password) · locked · unlocked · suspended · unsuspended · activated · deactivated · email changed · email verified · password changed · password reset required · authenticator app added or removed · passkey added or removed.
Sign-ins by a "Keep me signed in" cookie are not recorded unless recordCookieLogins is on.
Two-step methods that other plugins add are not recorded.
Groups added or removed · permissions changed · admin rights given or taken · a user group created, changed (name, handle, description, permissions) or deleted · one user signing in as another.
When someone signs in as another user, that row names both, and every row written in that session carries the original user as well.
A user group changed by applying project config on a deploy is recorded too, with the console as its context.
Orders are recorded once they are completed. Carts never are, and filling a cart costs the log nothing. A completed order's save row shows its status and email changes as any element's do, and also its line items added, removed or changed (purchasable, SKU, quantity, sale price, options, notes, status) and its adjustments (type, name, amount, included, and the line item each applies to).
Stock movements are not recorded: Commerce moves stock through inventory transactions, which save no element.
A catalog pricing rule or a discount is recorded when it is created (with its values), saved (each setting that changed, before and after: name, description, store, dates, enabled, how and by how much the price changes, its conditions, and a discount's own settings such as its limits and whether it stops later discounts) and deleted. They are not elements, so a row about one has no link and is in no element's History; it is in the log, under its own type in the filter, and in its actor's Activity. Saving one with nothing changed records nothing.
A discount's coupon codes are counted, never recorded: its row says how many were added and removed. The catalog prices Commerce regenerates from a rule, the customers its condition matches, and a discount's order and count of uses are not recorded.
Settings are code, deployed with the rest of the site. There is no settings screen. Copy
examples/config/auditor.php to config/auditor.php and change what
you need; every value it shows is the default. As with any Craft config file, it may be keyed by
environment.
| Setting | Default | Does |
|---|---|---|
enabled |
true |
false stops all recording. Nothing already recorded is removed. |
contexts |
['cp', 'site', 'console', 'queue'] |
The contexts recorded. Leave one out to stop recording from it. |
excludeElementTypes |
[] |
Element classes never recorded, e.g. [\craft\elements\Asset::class]. |
redact |
['password', 'newPassword', 'verificationCode', 'authKey'] |
Field handles and attribute names recorded as "changed" without their value. |
retentionDays |
365 |
Days a row is kept. 0 keeps every row. |
bulkThreshold |
10 |
Top-level elements one operation may touch before it is recorded as a batch. |
recordBulkDiffs |
false |
Record each element in a batch as its own row, with its changes. Slows resaves and large bulk actions. |
recordNoChangeSaves |
false |
Record a save even when no value changed. |
recordCookieLogins |
false |
Record sign-ins made by a "Keep me signed in" cookie. |
<?php
return [
'*' => [
// A nightly import is noise in the log.
'contexts' => ['cp', 'site', 'console'],
'redact' => ['password', 'newPassword', 'verificationCode', 'authKey', 'taxNumber'],
'retentionDays' => 180,
],
'dev' => [
'enabled' => false,
],
];Redaction applies everywhere a value would appear: a new element's values, a save's changes, and the payload of sign-in and access rows. A redacted value is recorded as having changed, so the log still shows that a tax number was edited, and by whom, without keeping the number.
A title Craft generates from a title format (an entry type, or a Commerce product or variant type, with no title field) can carry any value it is made from. So when such an element's field layout holds a redacted field, or its type a redacted attribute, its title and slug are redacted too, and its rows name it by type and id, such as "Entry #123", rather than by its title.
| Permission | Lets someone |
|---|---|
| Access Auditor (Craft's own plugin permission) | Open the log and each row's detail |
| See the address and browser each event came from | See IP addresses and browsers. Everyone else sees "—". |
| See the history of what they can view (Pro) | Open History on an element they can view |
| See what each user changed and what happened to their account (Pro) | Open a user's Activity. Another user's also needs Craft's View users. |
Admins hold all of them. No permission lets anyone edit or delete a row — an audit log that can be edited is not an audit log.
The log is the Auditor item in the control panel's navigation. Filter by kind (content, sign-in, access), event, element type, person, context, site and date range; search matches the subject's and the person's names. Open any row for its detail: a Field / Before / After table for a save, the line items and adjustments for an order, or the payload for a sign-in or access event, with a link to everything else the same request did. A field whose value is an array or object is shown key by key, each key that changed marked; an order's status reads by name, a variant as "Product — Variant (SKU)". Long values are cut at 500 characters with a control to show the rest. Nothing logged is ever rendered as HTML.
History (Pro) is in each element's action menu (the ⋯ button on its edit screen). It lists the element's changes newest first, one row per publish with its nested entries' changes beneath, and the batches it was part of. It loads only when opened, so it adds nothing to the edit screen.
Activity (Pro) is a screen on each user, beside their profile and permissions, with two tabs:
- Changes made — everything that user did.
- Account events — what happened to their account, whoever did it: sign-ins, failed sign-ins, lock-outs, group and permission changes, someone signing in as them.
# Remove rows older than retentionDays now, instead of waiting for garbage collection
php craft auditor/prune
# Remove rows older than 30 days, once, whatever retentionDays says
php craft auditor/prune --days=30
# Count what would be removed, removing nothing
php craft auditor/prune --dry-run
# Anonymise a deleted user's rows (see Personal data)
php craft auditor/anonymise-actor --user=123--days below 1 is refused. Each command exits non-zero on failure.
use webdna\auditor\events\DefineTrackedAttributesEvent;
use webdna\auditor\services\Differ;
use yii\base\Event;
Event::on(Differ::class, Differ::EVENT_DEFINE_TRACKED_ATTRIBUTES, function(DefineTrackedAttributesEvent $event) {
if ($event->elementType === \craft\elements\Entry::class) {
unset($event->attributes['postDate']);
}
});Each attribute maps to where its stored value is read: 'table.column' for a column (elements
and elements_sites are the element's own rows; any other table is joined on its id), or a
Closure(ElementInterface $element, Differ $differ): mixed that returns the stored value itself. A
closure that reads through $differ->readStored($element, $key, fn(array $ids) => [...]), returning
values by id, is read once for all the nested elements an owner saves together. The new value is
always read as $element->{$name}. The event is raised once per element type.
Auditor records any element type's saves, but for one it does not know — a plugin's reviews, say —
it tracks only the custom fields on its layout. Values the element keeps in its own table are not
fields, so name them. Here a review's status and rating sit in the plugin's reviews table, one row
per review keyed on the element id, as Craft's own element tables are:
use myplugin\elements\Review;
use webdna\auditor\events\DefineTrackedAttributesEvent;
use webdna\auditor\services\Differ;
use yii\base\Event;
Event::on(Differ::class, Differ::EVENT_DEFINE_TRACKED_ATTRIBUTES, function(DefineTrackedAttributesEvent $event) {
if ($event->elementType === Review::class) {
$event->attributes['reviewStatus'] = 'reviews.status';
$event->attributes['rating'] = 'reviews.rating';
}
});Approving a review then records "Review status: pending → approved", the old value read from
reviews before the save and the new one from $review->reviewStatus; a new review's row holds
both values. The key is the element's property, the value its table and column without the table
prefix. A row's label is the element's getAttributeLabel(), so give the property one in
attributeLabels(). Track title or enabled the same way, with 'elements_sites.title' and
'elements.enabled' — a plugin's element type has no attribute tracked until you name it. A table
keyed any other way, or with a row per site, needs a closure instead of a column.
Differ::EVENT_DEFINE_ELEMENT_SNAPSHOT lets any code record state that is neither a field nor an
attribute. It is raised before a save, with $event->before null, for the handler to read each key's
stored state into $event->values; and after the save, with $event->before holding what was
read, for the handler to give each key's state now. A key whose two values differ is recorded like a
field. To word the change yourself, set $event->changes[$key] to {label, old, new}, or to a list
of items — {label, fields, added, removed, changed} — which the detail screen draws as a table. The
Commerce order differ is built on this event. It is raised only while something listens.
use webdna\auditor\events\DefineElementSnapshotEvent;
Event::on(Differ::class, Differ::EVENT_DEFINE_ELEMENT_SNAPSHOT, function(DefineElementSnapshotEvent $event) {
if (!$event->element instanceof \craft\elements\Entry) {
return;
}
$event->values['bookings'] = (int)(new \craft\db\Query())
->from('{{%my_bookings}}')
->where(['entryId' => $event->element->id])
->count();
});Read the before state from the database, not by loading the element. Values you add are redacted by
the redact setting like any other.
use webdna\auditor\events\RecordEvent;
use webdna\auditor\services\Recorder;
Event::on(Recorder::class, Recorder::EVENT_BEFORE_RECORD, function(RecordEvent $event) {
if ($event->row->contextDetail === 'my-plugin/sync/run') {
$event->isValid = false;
}
});$event->row is a webdna\auditor\models\Event: family, event, the subject (elementId,
elementType, siteId, elementLabel), the actor (actorId, actorLabel), context,
contextDetail and data.
use webdna\auditor\Auditor;
use webdna\auditor\models\Event as AuditEvent;
Auditor::getInstance()->getRecorder()->record(new AuditEvent([
'family' => AuditEvent::FAMILY_ACCESS,
'event' => 'apiKeyIssued',
'data' => ['key' => 'Mobile app'],
]));The person, context, request id and address are filled in for anything you leave unset, and the row is written with the request's others, after its transaction commits.
The log holds personal data: IP addresses, browsers, email addresses, and the values people typed into fields. Auditor gives you the controls; your site's privacy notice should say that it keeps an audit log, what it holds and for how long.
- Retention. Rows older than
retentionDaysare removed by Craft's garbage collection, in batches of 10,000, and on demand byauditor/prune. - Redaction. List any field whose value should never be kept.
- Deleted people. When a user is deleted, they are taken out of the log in both directions. The
rows they made are rewritten to read "Deleted user #123" as who did it. The rows about them —
their account's creation and saves, their sign-ins and failed sign-ins, their email, group and
permission changes, their deletion — are named "Deleted user #123" too, and every value in them
is replaced by "value not recorded", so a row still says which fields changed and when, but not
to what. Rows about what they owned, such as their addresses, are treated the same way and named
by type and id ("Address #456"). Ids, dates, IP addresses and browsers stay, and age out with the
row. This happens when the user goes to the trash, so restoring them does not bring anything
back. Any other deleted subject, which is not a person, keeps its name and values.
auditor/anonymise-actor --user=<id>does the same for a user deleted before Auditor was installed, or by a version that left the rows about them as they were; it refuses a user who still exists, because it cannot be undone. - Addresses are shown only to people with the permission to see them.
Auditor sends nothing anywhere: no email, notification or webhook.
- A request that saves nothing and raises no sign-in or access event runs no extra queries.
- A save reads its stored values once, from the rows Craft already keeps, and never loads the element. The request's rows are written in one insert at its end.
- A resave of 1,000 entries measured 0.3–0.8% slower with Auditor enabled than without (the limit it is built to is 10%).
- History and Activity load when opened, never as part of an edit screen.
Nothing is being recorded. Check enabled and contexts in config/auditor.php for the current
environment, and that the element type is not in excludeElementTypes. Drafts and autosaves are
never recorded; publish the draft.
A save I made is missing. If the save was rolled back — a validation error, or an exception later in the same transaction — it was never committed and is not recorded. A savepoint rolled back inside a transaction that then commits cannot be detected; its changes are recorded as if they stood.
A resave shows one row, not one per entry. That is a batch. Open it to see the operation and
count, or set recordBulkDiffs to record each element.
History or Activity is missing. Both are Pro, and each needs its permission. History is not offered on an unpublished draft.
Recording failed. Auditor never lets a failure stop a save or sign-in. Failures are logged to
the auditor log category: look in storage/logs/.
Uninstalling drops Auditor's two tables, auditor_events and auditor_event_elements, and every row
in them. To keep the log, back up those tables first.
Auditor's schema starts at 2.0.0 and its tables are also created by a migration, so it can replace an
older plugin with the handle auditor. That plugin's own tables are never read, changed or dropped.
Report a problem at github.com/webdna/auditor/issues.