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.
1. Public UUIDs, internal integer keys
Section titled “1. Public UUIDs, internal integer keys”- 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 theuuidcolumn. 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
uuidattribute is always the canonical string (0192b3c4-...). The model registers the castAnvil\Data\Casts\UuidCastfor it automatically. - The UUID is immutable: updates of
uuidare 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 insertAnvil\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.
2. Tenant isolation
Section titled “2. Tenant isolation”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.
3. Auditing
Section titled “3. Auditing”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.updatedandrecord.deleted(withsoftin the context for soft deletes). The subject type is the table name, or$auditSubjectof 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 ofConfig\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 andConfig\Anvil::$auditIgnoredFields(created_at,updated_at). - Add model specific exclusions with
protected array $auditIgnore = [...].
Operations that are blocked
Section titled “Operations that are blocked”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.
Raw access
Section titled “Raw access”$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.