Skip to content

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 ​

LogTableOne row per
Change logsdk_change_log (head) + sdk_change_log_entries (positions)committed unit of work that wrote; a position per changed audited field
Business logsdk_business_logmessage a unit of work logs on purpose, with committed
Security logsdk_security_logsign-in, impersonation, refusal
Validation logsdk_validation_logrefused request
Report logsdk_report_logreport 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:

php
#[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, cast hashed or encrypted, or a key on ui5.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.

php
// 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 ​

RuleWhy
S1No foreign key of an #[Audited] table cascades.A cascade deletes rows in the database, unseen by the log.
S2A catalogue unit of work declares no #[Audited] table.The opt-out is for code-owned tables only.
S3The only referential action on an audited table is restrict — no SET NULL, no ON UPDATE CASCADE.They write without a model event too.
S4No quiet writes on an audited model: no saveQuietly(), deleteQuietly(), withoutEvents(), truncate().They skip the events the log listens to.
S5A 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.
S6One 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.

RecordedFrom
Sign-in, failed sign-in, sign-out, lockout, password reset, e-mail verifiedLaravel's auth events — whatever package authenticates fires them
An artifact, action, request, OData read or intent refusedthe SDK's own gates, with the target and the missing ability (act/closePeriod)
Impersonation begun and endedthe SDK's impersonation

A sign-in path of your own — a magic link — tells the log itself, after Auth::login():

php
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.

LogKept for (days)Setting
Security365audit.retention.security
Validation30audit.retention.validation
Business365audit.retention.business
Report1095audit.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:

php
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 ​