The Audit
The SDK records who changed what, when, and in which run. It does so at one seam — the unit of work — so that nothing a person decides changes unseen.
What is recorded
| Log | Table | One row per |
|---|---|---|
| Change log | sdk_change_log (head) + sdk_change_log_entries (positions) | committed unit of work that wrote; a position per changed audited field |
| Business log | sdk_business_log | message a unit of work logs on purpose, with committed |
| Security log | sdk_security_log | sign-in, impersonation, refusal |
| Validation log | sdk_validation_log | refused request |
| Report log | sdk_report_log | report or export served |
The head is written just before the commit, by the runner; a refused or failed unit of work writes none, and one that wrote nothing writes none either. Every row carries the run's correlation key, so a head, its business messages and a job the action dispatched read as one story. The logs are append-only: the models refuse an update and a row-by-row delete.
Opting in: #[Audited]
Positions are written for models that opt in, and only for the fields they list:
#[Audited(fields: ['hours', 'date', 'cost_center'], key: 'code')]
class TimesheetEntry extends Model { … }fields— what the business needs to prove. Hours and the cost centre, yes; the free-text comment, usually not. An unlisted field never enters the log.key— the stable key the position names the row by (default: the primary key). Use the code, not the id, where the row travels between systems.- An update writes one position with the old and new values of the changed listed fields; a create one with the new values; a delete one with the old. An update that touched only unlisted fields writes a head but no position.
- A listed field that is secret — in
$hidden, casthashedorencrypted, or a key onui5.audit.redact— is written as[redacted], and a test fails: remove it from the list.
List your audited models in ui5.audit.models — the SDK's own are built in. A statement the query builder writes does not say which model its table belongs to, so the write rule below needs the list to know which tables are audited.
Every table the implementation consultant maintains carries #[Audited] — the Configuration. Its change log is where the transport of a release's configuration is read from; a Configuration table without the attribute is silently left behind.
A Customizing catalog never carries it. Customizing is vocabulary the code declares and ui5:sync mirrors — it travels with the code, and the code is its history.
The write rule
Handlers write only through models. create, update, delete, save on a loaded model; relations through a pivot model (->using()). Reading stays free.
// yes
$assignment->update(['valid_until' => now()]);
$group->roles()->detach([$role->id]); // through the GroupRole pivot model
// no
DB::table('sdk_role_assignments')->where('id', $id)->update([...]);
$group->roles()->newPivotQuery()->delete();The old and the new value exist only on the model; DB::table bypasses casts, invariants and the log. It is checked: on an audited table, every write statement of a unit of work must come with its model event. One that does not fails a test and logs a warning in production.
Writing past the hulls is caught too. A write on an audited table in a request, an artisan command or a queued job that runs no unit of work fails a test; in production it logs a warning and leaves a head with source Unmanaged, naming the command, route or job. Bulk work is not an action's — it is a job or a command composing units of work.
A migration and a seeding run are steps of their own. What they write on an audited table is no violation; when they wrote any, they leave one head with source Migration — named by the migration's file or the seeder's class, with the tables, without positions, set by the automation actor. A migration that only changes the schema leaves nothing. This is S5's reshape below; a correction of a value is never a migration but an audited person command.
Schema rules for audited tables
| Rule | Why | |
|---|---|---|
| S1 | No foreign key of an #[Audited] table cascades. | A cascade deletes rows in the database, unseen by the log. |
| S2 | A catalogue unit of work declares no #[Audited] table. | The opt-out is for code-owned tables only. |
| S3 | The only referential action on an audited table is restrict — no SET NULL, no ON UPDATE CASCADE. | They write without a model event too. |
| S4 | No quiet writes on an audited model: no saveQuietly(), deleteQuietly(), withoutEvents(), truncate(). | They skip the events the log listens to. |
| S5 | A migration never changes what an audited value means. Schema-only steps write nothing; a reshape (the same value in another form) writes one head; correcting a value is not a migration but a decision — an audited person command. | A migration's old/new would mislead an examiner. |
| S6 | One table, one owner: it mirrors code or holds people's decisions, never both. | The sync must never touch a person's value. |
The consequence of S1 and S6 for upgrades: when a new release no longer declares a row that people's data still uses, ui5:sync does not delete it — it reports a blocker and waits until the implementation consultant has cleared the data in the apps. See units of work.
The security log
sdk_security_log records who got in, who was turned away, and who acted as someone else. It is always on and runs below the SDK: a failed sign-in has no actor yet, so a row names the user and the login name as typed until there is one.
| Recorded | From |
|---|---|
| Sign-in, failed sign-in, sign-out, lockout, password reset, e-mail verified | Laravel's auth events — whatever package authenticates fires them |
| An artifact, action, request, OData read or intent refused | the SDK's own gates, with the target and the missing ability (act/closePeriod) |
| Impersonation begun and ended | the SDK's impersonation |
A sign-in path of your own — a magic link — tells the log itself, after Auth::login():
event(new SecurityEvent(type: SecurityEventType::MagicLinkRedeemed, user: $user, guard: 'web'));Never put a password, a token or a link's signature into an event; context is redacted by ui5.audit.redact. A lockout's flow comes from the route that throttled it (login.submit, password.email).
Retention
The audit — head and positions — is never deleted. The other logs keep their rows for a period and are pruned daily by ui5:prune, which the SDK schedules; run Laravel's scheduler. The periods are settings of the SDK: the operator changes them on scope Installation in the Settings app.
| Log | Kept for (days) | Setting |
|---|---|---|
| Security | 365 | audit.retention.security |
| Validation | 30 | audit.retention.validation |
| Business | 365 | audit.retention.business |
| Report | 1095 | audit.retention.report |
ui5:prune is an automation command: its run is recorded in the change log under the automation actor.
The validation log is off by default. It records what a person typed into a form that was refused — field, rules and value, secrets redacted — which settles a support case and is personal data. The operator turns it on with the setting audit.validation_log.
The business log
Inject BusinessLogInterface where a unit of work has something to say on purpose — a warning about a dunning level skipped, a success worth a trace:
public function __construct(private readonly BusinessLogInterface $log) {}
$this->log->warning(MessageText::of('messages.dunning.skipped', [$invoice->number]), $invoice);The entry is written when the outermost transaction ends, with committed telling whether the change it speaks of stands.
Reading the logs
In 2.0 you read the logs through their models or in the database: the schema reference describes every column. An Audit app that reads them in the browser, by person, by record and by run, follows after 2.0.
See also
- Units of Work — the frame the audit hangs on
- Mutating Actions — where the write rule applies first
- Database Schema