Usage
Enabling the Audit Log in any of your table classes is as simple as adding a behavior in the initialize() function:
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:
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:
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:
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:
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:
| Option | Default | Description |
|---|---|---|
ignoreEmpty | true | Skip logging if no meaningful changes after filtering |
ignoreTimestampOnly | false | Skip logging if only timestamp fields changed (created, modified, updated, updated_at, created_at, modified_at) |
ignoreFields | [] | Skip logging if only these fields changed |
ignoreWhitespace | false | Ignore whitespace-only differences in string values |
ignoreCase | false | Ignore case-only differences in string values |
Example use cases:
- Enable
ignoreTimestampOnlywhen your ORM auto-updatesmodifiedon every save - Use
ignoreFieldsfor counters or cache fields that change frequently but aren't audit-worthy - Enable
ignoreWhitespacewhen form submissions may normalize whitespace differently - Enable
ignoreCasewhen 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.
// 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:
// In ArticlesTable::initialize()
$this->Tags->junction()->addBehavior('AuditStash.AuditLog');With this setup, when you add or remove tags from an article:
$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:
- Tags - If a new tag was created
- ArticlesTags - For each new tag association (create event)
- ArticlesTags - For each removed tag association (delete event, if using
replacestrategy)
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:
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
HasManyandHasOnerecords - After deletion, audit events are created for all cascade-deleted records
- All events share the same transaction ID for traceability
- The
parent_sourcefield indicates which table triggered the cascade delete
This is particularly useful when:
- You don't want to add
AuditLogBehaviorto 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 aliasComments.Comments→ audit rowsource = 'Comments.Comments'$this->fetchTable('Comments')→ registry aliasComments→ audit rowsource = '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:
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.CommentsANDComments. The coverage report surfaces the orphaned form asEmpiricalso you can spot it. Pick one form and stick with it — using the dotted form everywhere (fetchTable('Comments.Comments'), associationclassName, etc.) keeps the audit attribution stable.Source collisions across an app and plugin with the same name. If your app has its own
App\Model\Table\CommentsTableAND you load a plugin'sComments\Model\Table\CommentsTable, both writing audit rows under the bare aliasCommentsbecomes 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 thesourcecolumn 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 setclassNameexplicitly.
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:
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:
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:
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:
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
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 requestapi- 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:
// In your queue worker
EventManager::instance()->on(new EnvironmentMetadata('queue'));With Extra Metadata
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:
// In AppController::beforeFilter()
EventManager::instance()->on(new EnvironmentMetadata(
source: null, // auto-detect
extraData: [],
request: $this->getRequest(),
));API requests are detected by:
Acceptheader containingapplication/jsonorapplication/xmlContent-Typeheader containingapplication/jsonorapplication/xml- URL path starting with
/api/ Authorizationheader withBearerorBasicprefix
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:
EventManager::instance()->on(new EnvironmentMetadata(
request: $this->getRequest(),
capture: ['user_agent', 'referer', 'session_id'],
));Supported capture values:
user_agent—User-Agentheaderreferer—Refererheadersession_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:
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.
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:
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:
| Event | Fires | Use it for |
|---|---|---|
AuditStash.beforeLog | just before the persister writes the rows | mutating each log (add metadata, redact fields, attach extra context) |
AuditStash.afterLog | just after the persister returns | side 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.
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:
// 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():
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:
// 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:
// 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():
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:
// 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:
// 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:
// 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:
// 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
changedpayload, 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:
// 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:
// 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:
// 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:
// 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:
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:
'AuditStash' => [
'persister' => 'App\Namespace\For\Your\Persister',
]or if you are using as standalone via
\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:
'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
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:
use App\Model\Audit\AuditTrail;
use ArrayObject;
use Cake\Event\Event;Your transaction should then look similar to this example of a BookmarksController:
$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".