Skip to content

Usage

Enabling the Audit Log in any of your table classes is as simple as adding a behavior in the initialize() function:

php
class ArticlesTable extends Table
{
    public function initialize(array $config = []): void
    {
        ...
        $this->addBehavior('AuditStash.AuditLog');
    }
}

Remember to execute the command line each time you change the schema of your table!

Configuring The Behavior

The AuditLog behavior can be configured to ignore certain fields of your table, by default it ignores the created and modified fields:

php
class ArticlesTable extends Table
{
    public function initialize(array $config = []): void
    {
        ...
        $this->addBehavior('AuditStash.AuditLog', [
            'blacklist' => ['created', 'modified', 'another_field_name'],
        ]);
    }
}

If you prefer, you can use a whitelist instead. This means that only the fields listed in that array will be tracked by the behavior:

php
public function initialize(array $config = []): void
{
    ...
    $this->addBehavior('AuditStash.AuditLog', [
        'whitelist' => ['title', 'description', 'author_id'],
    ]);
}

If you have fields that contain sensitive information but still want to track their changes you can use the sensitive configuration:

php
public function initialize(array $config = []): void
{
    ...
    $this->addBehavior('AuditStash.AuditLog', [
        'sensitive' => ['body'],
    ]);
}

Filtering Insignificant Changes

The behavior can be configured to ignore changes that aren't meaningful for your audit trail. This helps reduce noise and storage:

php
public function initialize(array $config = []): void
{
    $this->addBehavior('AuditStash.AuditLog', [
        // Skip logging if only timestamp fields changed (created, modified, updated_at, etc.)
        'ignoreTimestampOnly' => true,

        // Skip logging if only these specific fields changed
        'ignoreFields' => ['last_login', 'view_count'],

        // Ignore whitespace-only changes (e.g., "hello " vs "hello")
        'ignoreWhitespace' => true,

        // Ignore case-only changes (e.g., "Hello" vs "hello")
        'ignoreCase' => true,
    ]);
}

Available options:

OptionDefaultDescription
ignoreEmptytrueSkip logging if no meaningful changes after filtering
ignoreTimestampOnlyfalseSkip logging if only timestamp fields changed (created, modified, updated, updated_at, created_at, modified_at)
ignoreFields[]Skip logging if only these fields changed
ignoreWhitespacefalseIgnore whitespace-only differences in string values
ignoreCasefalseIgnore case-only differences in string values

Example use cases:

  • Enable ignoreTimestampOnly when your ORM auto-updates modified on every save
  • Use ignoreFields for counters or cache fields that change frequently but aren't audit-worthy
  • Enable ignoreWhitespace when form submissions may normalize whitespace differently
  • Enable ignoreCase when case normalization happens but doesn't represent a real change

Logging BelongsToMany (Junction Table) Changes

When working with many-to-many relationships (BelongsToMany), changes happen in the junction table (e.g., articles_tags). To track these changes, you need to add the AuditLogBehavior to both the target table and the junction table.

php
// In ArticlesTable
public function initialize(array $config = []): void
{
    $this->belongsToMany('Tags', [
        'joinTable' => 'articles_tags',
    ]);

    // Audit the Articles table
    $this->addBehavior('AuditStash.AuditLog');
}

// In TagsTable
public function initialize(array $config = []): void
{
    $this->belongsToMany('Articles', [
        'joinTable' => 'articles_tags',
    ]);

    // Audit the Tags table
    $this->addBehavior('AuditStash.AuditLog');
}

// In ArticlesTagsTable (junction table)
public function initialize(array $config = []): void
{
    // Audit the junction table to track tag assignments/removals
    $this->addBehavior('AuditStash.AuditLog');
}

Alternatively, you can add the behavior to the junction table dynamically:

php
// In ArticlesTable::initialize()
$this->Tags->junction()->addBehavior('AuditStash.AuditLog');

With this setup, when you add or remove tags from an article:

php
$article = $this->Articles->get($id, contain: ['Tags']);
$article->tags[] = $this->Tags->get(5); // Add a new tag
$article->setDirty('tags', true);
$this->Articles->save($article);

The following audit events will be created:

  1. Tags - If a new tag was created
  2. ArticlesTags - For each new tag association (create event)
  3. ArticlesTags - For each removed tag association (delete event, if using replace strategy)

All events in a single save operation share the same transaction_key ID, making it easy to see all related changes.

Logging Cascade-Deleted Records

When deleting a parent record with dependent => true associations, the dependent records are also deleted (cascade delete). By default, these cascade-deleted records are NOT logged unless the dependent tables also have AuditLogBehavior attached.

To automatically log cascade-deleted dependent records without adding the behavior to each dependent table, enable the cascadeDeletes option:

php
public function initialize(array $config = []): void
{
    // Set up the association with dependent = true
    $this->hasMany('Comments', [
        'dependent' => true,
    ]);

    // Enable cascade delete logging
    $this->addBehavior('AuditStash.AuditLog', [
        'cascadeDeletes' => true,
    ]);
}

When cascadeDeletes is enabled:

  • Before the parent record is deleted, the behavior queries for all dependent HasMany and HasOne records
  • After deletion, audit events are created for all cascade-deleted records
  • All events share the same transaction ID for traceability
  • The parent_source field indicates which table triggered the cascade delete

This is particularly useful when:

  • You don't want to add AuditLogBehavior to every dependent table
  • You want to avoid using setCascadeCallbacks(true) for performance reasons
  • You need a complete audit trail of all deleted records in a single operation

Note: The cascadeDeletes option is disabled by default for backward compatibility.

Plugin Tables and the source Column

AuditLogBehavior writes the value of $table->getRegistryAlias() into the audit_logs.source column. For app tables that's just the alias (Comments). For plugin tables, the recorded source depends on how the table was loaded into the locator — and CakePHP allows both forms:

  • $this->fetchTable('Comments.Comments') → registry alias Comments.Comments → audit row source = 'Comments.Comments'
  • $this->fetchTable('Comments') → registry alias Comments → audit row source = 'Comments'

Associations from app code without an explicit className may resolve to the bare alias, so plugin-owned tables often end up writing audit rows under the short form. The Audit-Log-Viewer's coverage report checks both forms, but the behavior records whatever the locator gave it at save time.

This is mostly a non-issue, but two situations need attention if your app loads plugin tables:

  1. Mixed load paths produce a fragmented audit trail. If different code paths in the same app load the same plugin table both with and without the prefix, you'll get rows under both Comments.Comments AND Comments. The coverage report surfaces the orphaned form as Empirical so you can spot it. Pick one form and stick with it — using the dotted form everywhere (fetchTable('Comments.Comments'), association className, etc.) keeps the audit attribution stable.

  2. Source collisions across an app and plugin with the same name. If your app has its own App\Model\Table\CommentsTable AND you load a plugin's Comments\Model\Table\CommentsTable, both writing audit rows under the bare alias Comments becomes a real ambiguity — the audit trail can no longer tell the two logical tables apart. The cleanest fix is to alias the plugin table when registering it with the locator, so the source column carries a disambiguated name:

    php
    // In Application::bootstrap() or wherever you set up table associations:
    $this->getTableLocator()->setConfig('PluginComments', [
        'className' => 'Comments\\Model\\Table\\CommentsTable',
    ]);
    // Then load via $this->fetchTable('PluginComments') so the source column
    // says 'PluginComments' instead of competing with the app's 'Comments'.

    Alternatively, always load the plugin table via the dotted form (fetchTable('Comments.Comments')) and the app table via the bare alias — but that requires every association declaration in the app to set className explicitly.

Storing The Logged In User

It is often useful to store the identifier of the user that is triggering the changes in a certain table. For this purpose, AuditStash provides the RequestMetadata listener class, that is capable of storing the current URL, IP and logged in user.

The listener accepts two user-related parameters:

  • userId (optional): The user ID for linking/filtering (string or integer)
  • userDisplay (optional): A human-readable display value (username, email, full name, etc.)

You need to add this listener to your application in the AppController::beforeFilter() method.

Using CakePHP Authentication

If you're using the official CakePHP Authentication plugin:

php
use AuditStash\Meta\RequestMetadata;
use Cake\Event\EventManager;

class AppController extends Controller
{
    public function beforeFilter(EventInterface $event)
    {
        parent::beforeFilter($event);

        $identity = $this->getRequest()->getAttribute('identity');

        EventManager::instance()->on(
            new RequestMetadata(
                request: $this->getRequest(),
                userId: $identity?->getIdentifier(),
                userDisplay: $identity?->get('username'),
            ),
        );
    }
}

This stores:

  • user_id: The user ID (used for linking to user records and filtering)
  • user_display: The display value (shown in the audit viewer as "John Doe" instead of a UUID)

If userDisplay is not provided, IDs will display as "User #id" for UUIDs/numeric values, or the raw value if it looks like a username/email.

Using TinyAuth

If you're using the TinyAuth plugin:

php
use AuditStash\Meta\RequestMetadata;
use Cake\Event\EventManager;

class AppController extends Controller
{
    public function beforeFilter(EventInterface $event)
    {
        parent::beforeFilter($event);

        EventManager::instance()->on(
            new RequestMetadata(
                request: $this->getRequest(),
                userId: $this->AuthUser->id(),
                userDisplay: $this->AuthUser->user('username'),
            ),
        );
    }
}

Attaching Globally vs Per-Table

The above examples use EventManager::instance()->on() which attaches the listener globally. This is recommended if you plan to use multiple Table classes for saving or deleting inside the same controller.

If you only need to track changes for the controller's default Table class, you can attach it to that specific table's event manager:

php
public function beforeFilter(EventInterface $event)
{
    parent::beforeFilter($event);

    $identity = $this->getRequest()->getAttribute('identity');
    $eventManager = $this->fetchTable()->getEventManager();
    $eventManager->on(
        new RequestMetadata(
            request: $this->getRequest(),
            userId: $identity?->getIdentifier(),
            userDisplay: $identity?->get('username'),
        ),
    );
}

Storing Additional User Information

If you need to store more user information beyond userId and userDisplay (e.g., user email, role, etc.), you can add custom metadata using the AuditStash.beforeLog event:

php
use Cake\Event\EventInterface;
use Cake\Event\EventManager;

public function beforeFilter(EventInterface $event)
{
    parent::beforeFilter($event);

    $identity = $this->getRequest()->getAttribute('identity');

    // Add the basic RequestMetadata with user ID and display name
    EventManager::instance()->on(
        new RequestMetadata(
            request: $this->getRequest(),
            userId: $identity?->getIdentifier(),
            userDisplay: $identity?->get('username'),
        ),
    );

    // Optionally add additional user info via custom metadata
    EventManager::instance()->on('AuditStash.beforeLog', function (EventInterface $event, array $logs) use ($identity): void {
        foreach ($logs as $log) {
            $log->setMetaInfo($log->getMetaInfo() + [
                'user_email' => $identity?->get('email'),
                'user_role' => $identity?->get('role'),
            ]);
        }
    });
}

Tracking Request Source (Web, CLI, API)

The EnvironmentMetadata listener automatically detects and records where changes originated from. This is useful for distinguishing between changes made via the web interface, CLI commands, API requests, or background jobs.

Basic Usage

php
use AuditStash\Meta\EnvironmentMetadata;
use Cake\Event\EventManager;

// Auto-detect source (cli, web, or api)
EventManager::instance()->on(new EnvironmentMetadata());

This adds a request_source field to the audit log metadata with one of:

  • cli - Command line (bin/cake commands)
  • web - Standard web request
  • api - API request (detected via Accept/Content-Type headers or /api/ URL prefix)

Explicit Source Override

For queue workers or other contexts where auto-detection won't work:

php
// In your queue worker
EventManager::instance()->on(new EnvironmentMetadata('queue'));

With Extra Metadata

php
EventManager::instance()->on(new EnvironmentMetadata(null, [
    'deployment' => 'production',
    'server' => gethostname(),
    'version' => '1.2.3',
]));

API Detection with Request Object

For more accurate API detection, pass the request object:

php
// In AppController::beforeFilter()
EventManager::instance()->on(new EnvironmentMetadata(
    source: null, // auto-detect
    extraData: [],
    request: $this->getRequest(),
));

API requests are detected by:

  • Accept header containing application/json or application/xml
  • Content-Type header containing application/json or application/xml
  • URL path starting with /api/
  • Authorization header with Bearer or Basic prefix

Capturing forensic request fields

EnvironmentMetadata can also pull a small set of request-derived fields into meta. They are off by default — these fields can carry PII / fingerprintable data and may have GDPR implications, so consumers must opt in explicitly via capture:

php
EventManager::instance()->on(new EnvironmentMetadata(
    request: $this->getRequest(),
    capture: ['user_agent', 'referer', 'session_id'],
));

Supported capture values:

  • user_agentUser-Agent header
  • refererReferer header
  • session_id — PHP session id; only included when a session has actually been started (CLI, queue workers, and anonymous API hits silently skip it)

Empty headers and unknown field names are filtered out so a typo can't smuggle arbitrary values into meta. capture is a no-op when no request is supplied (CLI / queue context).

Combining with RequestMetadata

You can use both listeners together:

php
use AuditStash\Meta\EnvironmentMetadata;
use AuditStash\Meta\RequestMetadata;
use Cake\Event\EventManager;

public function beforeFilter(EventInterface $event)
{
    parent::beforeFilter($event);

    $identity = $this->getRequest()->getAttribute('identity');

    // Track user and request info
    EventManager::instance()->on(new RequestMetadata(
        request: $this->getRequest(),
        userId: $identity?->getIdentifier(),
        userDisplay: $identity?->get('username'),
    ));

    // Track request source
    EventManager::instance()->on(new EnvironmentMetadata(
        request: $this->getRequest(),
    ));
}

Storing Extra Information In Logs

AuditStash is also capable of storing arbitrary data for each of the logged events. You can use the ApplicationMetadata listener or create your own. If you choose to use ApplicationMetadata, your logs will contain the app_name key stored and any extra information your may have provided. You can configure this listener anywhere in your application, such as the bootstrap.php file or, again, directly in your AppController.

php
use AuditStash\Meta\ApplicationMetadata;
use Cake\Event\EventManager;

EventManager::instance()->on(new ApplicationMetadata('my_blog_app', [
    'server' => $theServerID,
    'extra' => $somExtraInformation,
    'moon_phase' => $currentMoonPhase,
]));

Implementing your own metadata listeners is as simple as attaching the listener to the AuditStash.beforeLog event. For example:

php
EventManager::instance()->on('AuditStash.beforeLog', function (EventInterface $event, array $logs): void {
    foreach ($logs as $log) {
        $log->setMetaInfo($log->getMetaInfo() + ['extra' => 'This is extra data to be stored']);
    }
});

AuditStash.beforeLog vs AuditStash.afterLog

The plugin dispatches two events around persistence:

EventFiresUse it for
AuditStash.beforeLogjust before the persister writes the rowsmutating each log (add metadata, redact fields, attach extra context)
AuditStash.afterLogjust after the persister returnsside effects that need the rows already-stored (notifications, cache busts, derived projections)

Both events receive the same array<\AuditStash\EventInterface> $logs payload. afterLog listeners must not mutate the log objects — the rows have already been persisted, so changes have no effect on what's stored.

php
EventManager::instance()->on('AuditStash.afterLog', function (EventInterface $event, array $logs): void {
    foreach ($logs as $log) {
        // logs are already in the database; do post-processing
        Cache::delete('article_' . $log->getId() . '_history');
    }
});

The Monitoring & Alerting feature listens on afterLog internally, which is why monitor rules see the rows in their final, persisted form.

Capturing Reasons/Comments For Changes

You can capture user-provided reasons or comments for changes using the metadata system. This is useful for compliance, audit trails, or understanding why changes were made.

Approach 1: Per-Request Reason (via Request Attribute)

Store the reason in the request and extract it in a listener:

php
// In your Controller action (before saving)
public function edit($id)
{
    $article = $this->Articles->get($id);

    if ($this->request->is(['patch', 'post', 'put'])) {
        // Store reason from form input
        $reason = $this->request->getData('audit_reason') ?? 'No reason provided';
        $this->request = $this->request->withAttribute('audit_reason', $reason);

        $article = $this->Articles->patchEntity($article, $this->request->getData());
        if ($this->Articles->save($article)) {
            $this->Flash->success('Article saved.');
            return $this->redirect(['action' => 'index']);
        }
    }

    $this->set(compact('article'));
}

Then in your AppController::beforeFilter():

php
use Cake\Event\EventInterface;
use Cake\Event\EventManager;

public function beforeFilter(EventInterface $event)
{
    parent::beforeFilter($event);

    // Capture reason from request attribute
    EventManager::instance()->on('AuditStash.beforeLog', function (EventInterface $event, array $logs): void {
        $reason = $this->getRequest()->getAttribute('audit_reason');
        if ($reason !== null) {
            foreach ($logs as $log) {
                $log->setMetaInfo($log->getMetaInfo() + [
                    'reason' => $reason,
                ]);
            }
        }
    });
}

Add the reason field to your forms:

php
// In your template (e.g., templates/Articles/edit.php)
<?= $this->Form->control('audit_reason', [
    'label' => 'Reason for change',
    'type' => 'textarea',
    'rows' => 3,
]) ?>

Approach 2: Per-Save Reason (via Save Options)

Pass the reason directly through save options and extract it in a listener:

php
// In your Controller
public function edit($id)
{
    $article = $this->Articles->get($id);

    if ($this->request->is(['patch', 'post', 'put'])) {
        $article = $this->Articles->patchEntity($article, $this->request->getData());

        $reason = $this->request->getData('audit_reason') ?? 'No reason provided';

        if ($this->Articles->save($article, ['_auditReason' => $reason])) {
            $this->Flash->success('Article saved.');
            return $this->redirect(['action' => 'index']);
        }
    }

    $this->set(compact('article'));
}

Then in your Table's initialize() method or AppController::beforeFilter():

php
use Cake\Event\EventInterface;

// In ArticlesTable::initialize() or globally in AppController
$this->getEventManager()->on('AuditStash.beforeLog', function (EventInterface $event, array $logs): void {
    // Access the reason from the table's event data if available
    $reason = $event->getSubject()->getEventManager()->getEventData('_auditReason') ?? null;

    foreach ($logs as $log) {
        $meta = $log->getMetaInfo();

        // Check if reason was passed in the meta already
        if (!isset($meta['reason']) && $reason !== null) {
            $log->setMetaInfo($meta + ['reason' => $reason]);
        }
    }
});

A simpler approach using entity virtual properties:

php
// In your Entity class (e.g., src/Model/Entity/Article.php)
protected array $_virtual = ['audit_reason'];

// In your Controller
$article->audit_reason = $this->request->getData('audit_reason');
$this->Articles->save($article);

// In your listener (AppController or Table)
EventManager::instance()->on('AuditStash.beforeLog', function (EventInterface $event, array $logs): void {
    foreach ($logs as $log) {
        $entity = $log->getChanged(); // or use reflection to get entity if needed
        // Extract reason from entity if it was set
        if (isset($entity['audit_reason'])) {
            $log->setMetaInfo($log->getMetaInfo() + [
                'reason' => $entity['audit_reason'],
            ]);
        }
    }
});

Approach 3: CLI/Background Job Reasons

For CLI commands or background jobs where there's no request context:

php
// In your Shell/Command
use Cake\Event\EventManager;

public function execute()
{
    // Set a reason for this batch operation
    $reason = 'Automated cleanup job - removing old records';

    EventManager::instance()->on('AuditStash.beforeLog', function ($event, array $logs) use ($reason): void {
        foreach ($logs as $log) {
            $log->setMetaInfo($log->getMetaInfo() + [
                'reason' => $reason,
                'source' => 'cli',
            ]);
        }
    });

    // Perform your operations
    $this->Articles->deleteAll(['created <' => new DateTime('-1 year')]);
}

Storing Reason in Database (Table Persister)

If using the TablePersister, you can extract the reason to a dedicated database column. user_id and user_display are auto-extracted to their dedicated columns out of the box, so only register here for additional custom fields:

php
// In your configuration (e.g., config/app_local.php or bootstrap.php)
$this->addBehavior('AuditStash.AuditLog');
$this->behaviors()->get('AuditLog')->persister()->setConfig([
    'extractMetaFields' => [
        'reason' => 'reason', // Extract 'reason' from meta to 'reason' column
    ],
]);

Then add a reason column to your audit_logs table:

php
// In a migration
$table->addColumn('reason', 'text', [
    'default' => null,
    'null' => true,
]);

Tracking File Uploads (Hashes, not Content)

Audited tables that store file content directly (binary blobs, base64 payloads, raw UploadedFile objects in a virtual field) cause two problems for the audit trail:

  • The audit row balloons to the size of the file — a 10 MB upload becomes a 10 MB changed payload, replicated for every save.
  • The audit table ends up holding sensitive content in plaintext, defeating the typical reason for separating uploads from primary tables.

The fix is to make the entity field that the auditor sees be a fingerprint (hash + size + mime), not the raw content. Two ways to do that, depending on where the file lives in your model.

Approach 1: Computed virtual field on the entity

If the file is uploaded to disk on save and the table only stores a path, expose a small "fingerprint" field that the auditor will pick up via the standard whitelist:

php
// src/Model/Entity/Document.php
namespace App\Model\Entity;

use Cake\ORM\Entity;

class Document extends Entity
{
    protected array $_virtual = ['file_fingerprint'];

    protected function _getFileFingerprint(): ?string
    {
        if ($this->file_path === null || !is_file($this->file_path)) {
            return null;
        }

        return sprintf(
            'sha256:%s size:%d mime:%s',
            hash_file('sha256', $this->file_path),
            filesize($this->file_path),
            mime_content_type($this->file_path) ?: 'application/octet-stream',
        );
    }
}

Then whitelist the virtual field in your behavior config so the audit picks it up alongside the real columns:

php
// src/Model/Table/DocumentsTable.php
$this->addBehavior('AuditStash.AuditLog', [
    'whitelist' => ['title', 'file_path', 'file_fingerprint'],
]);

A re-upload of the same bytes produces the same fingerprint → no audit row (assuming ignoreEmpty defaults). A re-upload of different bytes produces a different fingerprint → audit row showing sha256:abc... → sha256:def... with no file content stored.

Approach 2: Transform via the AuditStash.beforeLog event

When the file content is already in a column you can't easily change (legacy schema, content stored as binary in the row), strip it at the audit boundary instead of at the model boundary. The AuditStash.beforeLog event fires once per save with all events for that transaction, and the listener can rewrite each event's payload before persistence:

php
// in Application::bootstrap() or a dedicated listener
EventManager::instance()->on(
    'AuditStash.beforeLog',
    function (EventInterface $event, array $logs): void {
        foreach ($logs as $log) {
            foreach (['changed', 'original'] as $side) {
                $payload = $log->{'get' . ucfirst($side)}() ?? [];
                if (isset($payload['file_blob']) && is_string($payload['file_blob'])) {
                    $payload['file_blob'] = sprintf(
                        'sha256:%s size:%d',
                        hash('sha256', $payload['file_blob']),
                        strlen($payload['file_blob']),
                    );
                }
                if ($side === 'changed') {
                    // BaseEvent has no public setter for changed/original, so this
                    // approach requires either (a) an event subclass that exposes
                    // setters, or (b) a custom persister wrapping the default one.
                }
            }
        }
    },
);

WARNING

BaseEvent exposes getChanged() / getOriginal() but no public setters (the audit row is a value object). For Approach 2 to work end-to-end without a fork, wrap your persister: write a thin PersisterInterface decorator that walks each event, rebuilds it with the redacted payload, and forwards to the real persister. For most apps Approach 1 is simpler and avoids that detour.

Bonus: redact PII fields in the same hook

The same beforeLog listener (or the sensitive behavior config) is the right place to redact non-file PII inputs (passport numbers, ID photos as data URIs, etc.) — keep the audit row showing that the field changed, but never what it was:

php
// In your Table:
$this->addBehavior('AuditStash.AuditLog', [
    'sensitive' => ['passport_scan_b64', 'id_photo_b64'],
]);
// → values appear as '****' in changed/original.

Implementing Your Own Persister Strategies

There are valid reasons for wanting to use a different persist engine for your audit logs. Luckily, this plugin allows you to implement your own storage engines. It is as simple as implementing the PersisterInterface interface:

php
use AuditStash\PersisterInterface;

class MyPersister implements PersisterInterface
{
    /**
     * @param array<\AuditStash\EventInterface> $auditLogs
     */
    public function logEvents(array $auditLogs): void
    {
        foreach ($auditLogs as $log) {
            $eventType = $log->getEventType();
            $data = [
                'timestamp' => $log->getTimestamp(),
                'transaction_key' => $log->getTransactionId(),
                'type' => $log->getEventType(),
                'primary_key' => $log->getId(),
                'source' => $log->getSourceName(),
                'parent_source' => $log->getParentSourceName(),
                'original' => json_encode($log->getOriginal()),
                'changed' => $eventType === 'delete' ? null : json_encode($log->getChanged()),
                'meta' => json_encode($log->getMetaInfo())
            ];
            $storage = new MyStorage();
            $storage->save($data);
        }
    }
}

Finally, you need to configure AuditStash to use your new persister. In the config/app.php file add the following lines:

php
'AuditStash' => [
    'persister' => 'App\Namespace\For\Your\Persister',
]

or if you are using as standalone via

php
\Cake\Core\Configure::write('AuditStash.persister', 'App\Namespace\For\Your\DatabasePersister');

The configuration contains the fully namespaced class name of your persister.

Working With Transactional Queries

Occasionally, you may want to wrap a number of database changes in a transaction, so that it can be rolled back if one part of the process fails. There are two ways to accomplish this. The easiest is to change your save strategy to use afterSave instead of afterCommit. In your applications configuration, such as config/app.php:

php
'AuditStash' => [
    'saveType' => 'afterSave',
]

That's it if you use afterSave. You should read up on the difference between the two as there are drawbacks: https://book.cakephp.org/5/en/orm/table-objects.html#aftersave

If you are using the default afterCommit, in order to create audit logs during a transaction, some additional setup is required. First create the file src/Model/Audit/AuditTrail.php with the following:

php
<?php
namespace App\Model\Audit;

use Cake\Utility\Text;
use SplObjectStorage;

class AuditTrail
{
    protected SplObjectStorage $_auditQueue;
    protected string $_auditTransaction;

    public function __construct()
    {
        $this->_auditQueue = new SplObjectStorage;
        $this->_auditTransaction = Text::uuid();
    }

    public function toSaveOptions(): array
    {
        return [
            '_auditQueue' => $this->_auditQueue,
            '_auditTransaction' => $this->_auditTransaction,
        ];
    }
}

Anywhere you wish to use Connection::transactional(), you will need to first include the following at the top of the file:

php
use App\Model\Audit\AuditTrail;
use ArrayObject;
use Cake\Event\Event;

Your transaction should then look similar to this example of a BookmarksController:

php
$trail = new AuditTrail();
$success = $this->Bookmarks->connection()->transactional(function () use ($trail) {
    $bookmark = $this->Bookmarks->newEntity();
    $bookmark1->save($data1, $trail->toSaveOptions());
    $bookmark2 = $this->Bookmarks->newEntity();
    $bookmark2->save($data2, $trail->toSaveOptions());
    ...
    $bookmarkN = $this->Bookmarks->newEntity();
    $bookmarkN->save($dataN, $trail->toSaveOptions());
});

if ($success) {
    $event = new Event('Model.afterCommit', $this->Bookmarks);
    $this->Bookmarks->behaviors()->get('AuditLog')->afterCommit(
        $event,
        $result,
        new ArrayObject($trail->toSaveOptions()),
    );
}

This will save all audit info for your objects, as well as audits for any associated data. Please note, $result must be an instance of an Object. Do not change the text "Model.afterCommit".

Released under the MIT License.