Skip to content

Data rules: UUIDs, tenancy and auditing

Every model that extends Anvil\Data\Model follows three rules. They are enforced by the base class, so application code cannot forget them. A step by step example: Your first Anvil model.

  • Each record keeps an auto-increment integer primary key for joins and foreign keys. It never leaves the server. Sequential ids would let users guess and enumerate records.
  • On insert, Anvil generates a version 7 UUID (RFC 9562) and stores it as BINARY(16) in the uuid column. Version 7 UUIDs are time ordered, so the index stays compact. A UUID passed on insert (e.g. by an import) is kept.
  • In PHP, the uuid attribute is always the canonical string (0192b3c4-...). The model registers the cast Anvil\Data\Casts\UuidCast for it automatically.
  • The UUID is immutable: updates of uuid are ignored.
  • URLs, forms, API responses and component tokens only contain UUIDs.
$project = $model->findByUuid($uuidFromRequest); // null for unknown or invalid input
$model->whereUuid($uuid)->first(); // throws InvalidUuidException on invalid input
$uuid = $model->getInsertUuid(); // Anvil\Data\Uuid of the last insert

Anvil\Data\Uuid has v7(), fromString(), fromBytes(), isValid(), toString() and equals(). whereUuid($uuid, '<') and '>' compare the time ordered UUIDs, e.g. for keyset pagination.

Anvil\Data\Entity removes internal fields (Config\Anvil::$internalFields, by default id and tenant_id, plus the entity’s own $internal list) from toPublicArray() and from JSON serialization.

With Config\Anvil::$multiTenancy = true (default), every model is tenant scoped unless it sets protected ?bool $tenantScoped = false;.

Operation Behaviour
find(), findAll(), first(), paginate(), countAllResults(), chunk() restricted to the current tenant
insert(), insertBatch() tenant_id is set to the current tenant, given values are overwritten
update(), delete() only affect records of the current tenant
update() of tenant_id ignored, ownership is immutable
no tenant in the context TenancyException, the operation fails closed

Failing closed means a missing tenant is an error, never “all tenants”: a forgotten context cannot leak data of other customers.

The current tenant lives in service('tenantContext'). In panels the tenant filter sets it to the tenant the user selected, and only while the user is a member of it. Jobs and tasks can switch it temporarily:

service('tenantContext')->runAs($tenantId, fn () => $model->findAll());

Cross-tenant access must be explicit and should only follow an authorization check:

$all = $model->withoutTenantScope(fn (ProjectModel $m) => $m->findAll());

Shared tables without tenant_id (e.g. a global catalogue) set $tenantScoped = false. Selects on such models need a resource in the panel or an explicit scope, see BelongsTo. More about tenants: Tenancy.

With Config\Anvil::$audit = true (default), inserts, updates and deletes are written to the audit log in the same transaction as the change. If the change rolls back, so does its audit entry. A model opts out with protected ?bool $auditable = false;.

  • Events are record.created, record.updated and record.deleted (with soft in the context for soft deletes). The subject type is the table name, or $auditSubject of the model.
  • Updates only log fields whose value actually changed. The rows are read and locked before the update, so the logged old values are accurate.
  • Sensitive fields are masked as [masked]: every field whose name contains one of Config\Anvil::$auditMaskedFields (password, passphrase, secret, token, api_key, private_key, pin, otp, recovery, iban), case-insensitive, e.g. password_hash.
  • Not logged: the primary key, uuid, tenant_id, the internal fields and Config\Anvil::$auditIgnoredFields (created_at, updated_at).
  • Add model specific exclusions with protected array $auditIgnore = [...].

Some CodeIgniter operations would bypass these rules. Anvil models reject them with a ModelException:

Operation Alternative
get(), getWhere(), countAll(), truncate(), emptyTable(), upsert(), upsertBatch(), deleteBatch() findAll(), first(), countAllResults()
replace() insert() or update()
updateBatch() on scoped or audited models update() per record
update() / delete() of audited models without primary keys pass the ids explicitly
purgeDeleted() on audited models delete records explicitly

insertBatch() is supported, but inserts row by row in one transaction so that every row gets its UUID, tenant and audit entry.

$model->builder(), $model->db and $model->query() give raw access to the database and bypass all of the rules above. Use them only when necessary, apply the tenant condition yourself and write audit entries with service('auditLogger') where changes must be traceable.