mirko-pagliai / cakephp-entities-logger
Logs who created, updated or deleted your CakePHP entities, and when.
Package info
github.com/mirko-pagliai/cakephp-entities-logger
Type:cakephp-plugin
pkg:composer/mirko-pagliai/cakephp-entities-logger
Requires
- php: >=8.2
- cakephp/cakephp: ^5.1.10
Requires (Dev)
- cakedc/cakephp-phpstan: ^4.0
- cakephp/cakephp-codesniffer: ^5.3.2
- cakephp/migrations: ^4.5 || ^5.0
- mockery/mockery: ^1.6.10
- phpunit/phpunit: ^10.5.5 || ^11.1.3 || ^12.0.9
Suggests
- cakephp/migrations: To create the table of the logs with the migrations of the plugin
Provides
None
Conflicts
None
Replaces
None
README
Logs who created, updated or deleted your CakePHP entities, and when.
Add a behavior to the tables you want, and the plugin writes a log every time an entity of those tables is created, updated or deleted. Each log stores:
- the entity (its class and its id);
- the type of the change:
created,updatedordeleted; - the user who made the change, the date and time, the IP address and the user agent.
For example, after a few changes the entities_logs table contains:
| id | entity_class | entity_id | user_id | type | datetime | ip | user_agent |
|---|---|---|---|---|---|---|---|
| 1 | App\Model\Entity\Article |
1 | 1 | created | 2026-10-20 09:30:00 | 192.168.0.10 | Mozilla/5.0 (…) Firefox/131.0 |
| 2 | App\Model\Entity\Article |
2 | 1 | created | 2026-10-20 09:42:00 | 192.168.0.10 | Mozilla/5.0 (…) Firefox/131.0 |
| 3 | App\Model\Entity\Article |
1 | 2 | updated | 2026-10-20 15:10:00 | 2001:db8:85a3::8a2e:370:7334 | Mozilla/5.0 (…) Chrome/130.0 |
| 4 | App\Model\Entity\Article |
2 | 1 | deleted | 2026-10-21 08:05:00 | 192.168.0.10 | Mozilla/5.0 (…) Firefox/131.0 |
The ip column holds both IPv4 and IPv6 addresses. The user agents are shortened here.
The plugin tells you who changed an entity and when, but it does not store what has changed: the values of the fields are not saved.
- Requirements
- Installation
- Create the table
- Add the behavior
- Reading the logs
- What is logged
- Options
- Merging close updates
- Grouping entities
- Requests and identity
- Supported primary keys
- Entity class
- IP address
- Logs on a different connection
- Customizing
- Versioning
Requirements
- PHP 8.2 or later;
- CakePHP 5.1.10 or later;
- the
cakephp/migrationsplugin (4.5 or later), to create the table with the migrations of this plugin; - a
Userstable in your application: each log belongs to the user who made the change, and by default the user must exist when the log is saved (see thecheckRulesoption); - an authenticated user: the current request must have an
identityattribute (for example, the one set by the CakePHP Authentication plugin) with an integerid. See "Requests and identity".
Installation
You can install the plugin via composer:
composer require --prefer-dist mirko-pagliai/cakephp-entities-logger
Then you have to load the plugin. For more information on how to load the plugin, please refer to the CakePHP documentation.
Simply, you can execute the shell command to enable the plugin:
bin/cake plugin load Cake/EntitiesLogger
This would update your application's bootstrap method.
Create the table
Now you need to create the table that the plugin will use to keep changes to the entities you want.
The best way is using migrations (if your application doesn't have it yet, composer require cakephp/migrations):
bin/cake migrations migrate -p Cake/EntitiesLogger
Instead, if you want to verify that the plugin migrations have been applied correctly:
bin/cake migrations status -p Cake/EntitiesLogger
Add the behavior
Add the Cake/EntitiesLogger.EntitiesLog behavior to the tables you want.
Inside the initialize() method of your tables:
namespace App\Model\Table; use Cake\ORM\Table; class ArticlesTable extends Table { public function initialize(array $config): void { // ... $this->addBehavior('Cake/EntitiesLogger.EntitiesLog'); // ... } }
Reading the logs
The behavior adds an EntitiesLogs association to the table, so the logs of an entity can be read like any other
association, from the oldest to the newest:
$Article = $this->Articles->find() ->contain(['EntitiesLogs' => ['Users']]) ->where(['Articles.id' => $id]) ->firstOrFail(); foreach ($Article->entities_logs as $EntitiesLog) { // `type` is a `Cake\EntitiesLogger\Model\Enum\EntitiesLogType` (`Created`, `Updated` or `Deleted`) echo $EntitiesLog->type->name . ' by user ' . $EntitiesLog->user_id . ' on ' . $EntitiesLog->datetime; }
Containing Users is optional: it also loads the user of each log ($EntitiesLog->user).
A deleted entity no longer exists, so it cannot be loaded this way. To read its logs, query EntitiesLogsTable
directly, by entity_class and entity_id.
What is logged
Each log is a row of the entities_logs table:
| Column | Description |
|---|---|
entity_class |
The entity class (with its namespace) of the table that loaded the behavior |
entity_id |
The primary key of the entity (an integer) |
user_id |
The user who made the change |
type |
created, updated or deleted |
datetime |
When the change was made |
ip |
The IP address of the request (IPv4 or IPv6) |
user_agent |
The user agent of the request |
The entity_class and entity_id columns have an index, used to find the logs of an entity.
The logs are written by the afterSave() and afterDelete() callbacks, so only when entities are saved or deleted
through their table (save(), saveMany(), delete(), and so on). For this reason:
- an entity that is saved without any change is not logged, because CakePHP does not even dispatch the events;
- the operations that don't go through entities, such as
updateAll()anddeleteAll(), are not logged; - when there is no request (for example, in a shell command or in a cronjob), nothing is logged.
Some things to keep in mind:
- every change adds a row to the table (and, for updates, the plugin reads the last log of the entity to merge close updates, see below);
- the plugin never deletes old logs: how long to keep them is up to your application;
- the IP address and the user agent may be personal data, depending on where you operate (for example, under the GDPR).
Options
The behavior accepts the following options:
$this->addBehavior('Cake/EntitiesLogger.EntitiesLog', [ 'checkRules' => true, 'mergeInterval' => 300, 'groupField' => null, ]);
checkRules(defaulttrue): whether the rules ofEntitiesLogsTableare checked when the log is saved (for example, that the user exists). Withfalse, the rules are not checked;mergeInterval(default300): the interval, in seconds, within which close updates are merged into a single log. With0, the updates are not merged and every change is logged. See below;groupField(defaultnull): the name of the field that groups the entities, to log the changes of the group instead of those of each single entity. See below.
Merging close updates
A user who changes the same entity several times in a short time (for example, it is saved, a mistake is noticed and it is saved again) would leave a log for each change. For this reason, when an entity is updated, a new log is not added if the last log of that entity:
- is an
Updatedlog (theCreatedandDeletedlogs are never merged); - was made by the same user, from the same IP address and with the same user agent;
- is not older than the
mergeIntervaloption (300 seconds, 5 minutes, by default).
In this case the datetime of that log is updated, so the log always shows the time of the last change. Nothing else is
written or deleted.
Some things to keep in mind:
- only the last log is considered: if another user has changed the entity in the meantime, a new log is added, so that the history is kept (the changes of A, B and then A again are three logs);
- the interval is counted from the last log, not from the first change. If the same user keeps changing the entity and never leaves more than the interval between two changes, only one log is kept;
- the logs that already exist are not modified.
Grouping entities
Some entities make sense only as part of a group. For example, the lines of an order are always handled together, and what you want to log is that the order has been changed, no matter which line.
With the groupField option you set the name of the field that identifies the group (here, order_id):
$this->addBehavior('Cake/EntitiesLogger.EntitiesLog', ['groupField' => 'order_id']);
With this option:
- the
entity_idcolumn of the log is the value of that field, not the primary key. Theentity_classcolumn is still the entity class of the table. So the logs of a group are those with the entity class of the table and theentity_idequal to the value of the field (for example, the id of the order); - every change is logged as
Updated. Creating, editing or deleting an entity is always a change of the group, so with this option theCreatedandDeletedlogs are never written. The log tells that the group has been changed, by whom and when, but not which entity of the group has been changed, nor how; - if an entity is moved from one group to another (the value of the field is changed), both groups have been changed, so
an
Updatedlog is written for each of them. This works for entities that have been read from the database and then modified; - the field must have a value, and it must be an integer, as for the primary key. Otherwise a
MissingPropertyExceptionor anUnexpectedValueExceptionis thrown when the entity is saved or deleted. For this reason an entity cannot be removed from its group (the field cannot be set tonull); - the primary key of the table is not used, so it can also be composite;
- close updates are merged as described above, and the group is what counts: the changes made by the same user to any entity of the same group, within the interval, are a single log;
- the
EntitiesLogsassociation added by the behavior finds the logs by the field instead of the primary key. So all the entities of a group have the same logs, those of the group.
If the table already has logs, don't add this option: they identify the entities by their primary key, so they would be mixed up with those of the groups.
Requests and identity
The log stores the user who made the change, so it is created only when there is a request:
- when there is no current request (for example, in a shell command or in a cronjob), nothing is logged, and the entity is saved or deleted as usual;
- when there is a request, but it has no
identityattribute (for example, the user is not authenticated), or the identity has noid, an exception is thrown when the entity is saved or deleted and the changes are rolled back, so that a change without an author is never saved silently.
Supported primary keys
The entity_id column of the entities_logs table is an integer. For this reason, the tables that load the behavior
must have a single-column primary key (it doesn't have to be named id) whose value is an integer.
If the primary key is composite or its value is not an integer (for example, a UUID), the log cannot be saved and an
UnexpectedValueException is thrown when the entity is saved or deleted.
The same applies to the identity stored in the request (the user), whose id must be an integer.
Entity class
The logs of an entity are identified by the entity_class and entity_id columns, and the EntitiesLogs association
added by the behavior finds the logs whose entity_class is exactly the entity class of the table. For this reason,
the logs of entities of other classes (even subclasses) are not found.
The entity class must identify the table: each table that loads the behavior must have its own entity class. Tables
that share the same entity class would also share the logs (the same entity_id of two tables would be mixed up), so
the generic Cake\ORM\Entity class, which CakePHP uses when a table has no entity class of its own, is not accepted:
an UnexpectedValueException is thrown when the entity is saved or deleted.
IP address
The IP address is taken from ServerRequest::clientIp(). If the application is behind a proxy or a load balancer, the
request must trust it (ServerRequest::$trustProxy), otherwise the address of the proxy is logged instead of the one of
the client.
Both IPv4 and IPv6 addresses are supported. The ip column holds up to 39 characters, the length of an IPv6 address in
its full form, and the address is stored as ServerRequest::clientIp() returns it, without normalizing it.
The address must be a valid IP address, without a port or a zone id (such as 203.0.113.5:4444 or fe80::1%eth0).
Otherwise the log does not pass the validation and a PersistenceFailedException is thrown, so the entity is not saved
either. This can happen if a trusted proxy adds the port to the X-Forwarded-For header.
Logs on a different connection
The log is saved in the afterSave() and afterDelete() callbacks of the table that loaded the behavior, so inside the
transaction of the entity being saved or deleted.
If you store the logs on a different connection (for example, with your own EntitiesLogsTable that overrides
defaultConnectionName()), the two cannot be rolled back together: if the transaction is rolled back after the log has
been written (for example, when the save is part of a larger transaction), the log is kept.
In this case, the plugin migrations must be run on that connection too (here logs), otherwise they would try to modify
the entities_logs table of the default connection, which doesn't exist:
bin/cake migrations migrate -p Cake/EntitiesLogger -c logs
The afterSaveCommit() and afterDeleteCommit() callbacks are not used, because CakePHP does not dispatch those
events when the save is part of a larger transaction, and the log would be lost.
Customizing
To use your own table for the logs (for example, to add a finder, or to store the logs on a different connection),
extend Cake\EntitiesLogger\Model\Table\EntitiesLogsTable, and extend the behavior so that it uses your table:
namespace App\Model\Behavior; use App\Model\Table\EntitiesLogsTable; use Cake\EntitiesLogger\Model\Behavior\EntitiesLogBehavior as BaseEntitiesLogBehavior; use Cake\ORM\Table; class EntitiesLogBehavior extends BaseEntitiesLogBehavior { /** * @inheritDoc */ public function __construct(Table $table, array $config = []) { $this->EntitiesLogsTable = $this->fetchTable(EntitiesLogsTable::class); parent::__construct($table, $config); } }
Then add your own behavior to the tables, instead of the one of the plugin:
$this->addBehavior('EntitiesLog');
The table must be set before calling the parent constructor, because the constructor uses it as the target of the
EntitiesLogs association. The protected methods buildEntity() and saveEntitiesLog() can be overridden too.
Your table must be named EntitiesLogsTable, so that its alias is EntitiesLogs, the one used by the association to
sort the logs. It also needs its own entity class, that extends the one of the plugin. By default CakePHP looks for it
next to your table, so for App\Model\Table\EntitiesLogsTable it is App\Model\Entity\EntitiesLog:
namespace App\Model\Entity; use Cake\EntitiesLogger\Model\Entity\EntitiesLog as BaseEntitiesLog; class EntitiesLog extends BaseEntitiesLog { }
If that class does not exist, CakePHP would use the generic Cake\ORM\Entity and saving the logs would fail. If your
entity has another name or namespace, set it in the initialize() method of your table with setEntityClass().
Versioning
For transparency and insight into our release cycle and to maintain backward compatibility, this package will be maintained under the Semantic Versioning guidelines.