Skip to content

Loading and saving

A resource loads and saves records through its model. Override these methods to change how. Saving methods run inside the transaction of the endpoint request: a failure rolls back the record, its audit entries and everything afterSave() did.

Method Use it to
scopeQuery(Model $model): void restrict all queries, e.g. join and filter related data
formValues(array|object $record): array provide values of virtual fields for the form
createRecord(array $data, array $virtual): Uuid create records differently, e.g. through a service
updateRecord(array|object $record, array $data, array $virtual): void update records differently
deleteRecord(array|object $record): void e.g. archive instead of delete
afterSave(Uuid $uuid, array $virtual, string $operation): void save virtual fields, e.g. relations (protected)
public function scopeQuery(Model $model): void
{
$model->select('invoices.*, c.name AS customer_name')
->join('customers c', 'c.id = invoices.customer_id')
->where('invoices.archived_at', null);
}

Records are always loaded through query() and findRecord($uuid), which apply the tenant scope of the model and scopeQuery(). A record outside the scope results in 404, as if it did not exist: users cannot even learn that it exists. The scope applies to the list, the detail, edit and delete, actions, exports, the global search, selects and relationship columns that use the resource, related lists and the API.

  • Joins are not tenant scoped by Anvil: join through keys of records the main query already restricts, or add the tenant condition yourself.
  • Select table.* when you add columns, so the record keeps its own columns (id, uuid).
  • Columns from joins can be shown, sorted and searched with queryColumn(), see Computed values.
  1. The form validates the input and converts it; only fields writable in the operation are included, see What is saved.
  2. Values of normal fields are $data (column => value), values of virtual fields $virtual.
  3. createRecord() inserts $data (UUID, tenant and audit entry are added by the model) and calls afterSave(…, 'create'); updateRecord() updates the changed record and calls afterSave(…, 'edit').
  4. Validation errors of the model ($validationRules) are shown at the fields (422).

Virtual fields: formValues() and afterSave()

Section titled “Virtual fields: formValues() and afterSave()”

Example: the items of a business role, stored in a link table.

public function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')->required()->maxLength(150),
CheckboxList::make('items')->groupedOptions($this->itemOptions())->columns(3)->virtual(),
]);
}
public function formValues(array|object $record): array
{
return ['items' => service('roleItems')->uuidsOf((int) $this->recordKey($record))];
}
protected function afterSave(Uuid $uuid, array $virtual, string $operation): void
{
service('roleItems')->sync($uuid, $virtual['items'] ?? []);
}

formValues() is also used for the detail page and the API representation. $operation is FormContext::CREATE or FormContext::EDIT.

public function createRecord(array $data, array $virtual): Uuid
{
return service('orders')->place($data, $virtual['items'] ?? []);
}
public function deleteRecord(array|object $record): void
{
// Archive instead of delete.
$this->model()->update($this->recordKey($record), ['archived_at' => date('Y-m-d H:i:s')]);
}
  • createRecord() returns the UUID of the new record; the user is sent to its detail page (or back to the parent record when it was created from a relation).
  • The default deleteRecord() deletes the record and its files (unless the model uses soft deletes, so a restore keeps them).
  • Permissions and policies are checked before these methods are called; don’t check them again, but do check business rules.
use Anvil\Endpoint\EndpointException;
throw EndpointException::forValidation(['amount' => 'The amount exceeds the budget.']);

The message is shown at the field (422) and the transaction is rolled back. A key that is not a field of the form is shown as an error message (toast) instead.

recordKey($record) returns the internal primary key for joins and services, recordUuid($record) the UUID. Never send the key to the browser: URLs, forms, tokens and API answers use the UUID.