Validation Rules
A validation rule is one PHPScript snippet per table that runs before every create, update, and delete. Return a non-empty string to block the write — the string is shown to the person making the change — or return nothing to let it through.
It's the place for rules that a Required checkbox can't express: cross-field checks, status locks, line-item totals, and "who is allowed to change this, and how".
Adding a rule
In a table's Configure screen, open the General tab and write the rule in the Validation editor. It's checked for syntax when you save — a broken script is rejected on save, not stored. Clear the editor to remove the rule.
When it runs
The rule runs at the moment of the write, on every path — the record form, web forms, the public API, automations, and imports all funnel through it. For a record with line items, the parent and all its lines are validated together as one atomic unit: if the rule blocks, nothing persists — no parent, no orphan lines.
Two things to know:
- It fails closed. If the rule itself errors or exceeds its 5-second cap, the write is blocked, not silently allowed — a broken integrity gate is worse than an annoying one. Keep it fast and simple.
- It only reads. Use the query helpers (records_query(), record_get(), etc.) to look things up. Don't write from a validation rule.
What the rule can use
| Variable | What it holds |
|---|---|
$record |
The proposed values (after the edit), keyed by field id. null on delete. |
$before |
The current stored values — on update and delete only (absent on create). Compare $record against $before to see what changed. |
$lines |
The proposed line items, keyed by sub-table id (for records with sub-tables). |
$user |
The acting user — id, email, name. Always present: a write nobody triggered gets a stand-in System user. |
$user["role"] |
Their role in this workspace: admin, readwrite, or readonly (see below). |
$user["system"] |
true when nobody triggered the write (a schedule, a webhook, a web form, email-to-record). |
$via |
How the write arrived (see below). |
$table |
This table's own schema: $table["id"], $table["name"], $table["db_table"], and $table["fields"] keyed by field id. Same variable script fields and automations get. |
On an update, $record is the stored record with the edit laid over it, so
every field is present — not just the ones being changed.
Which one do I check, $record or $before?
It depends on the kind of rule, and getting it wrong gives you a rule that looks right but can be walked around:
| Your rule | Check |
|---|---|
| "this value must never be saved" | $record — you're judging the result |
| "a record already in this state is frozen" | $before — you're judging what's stored |
A lock written against $record is escaped by the very write that breaks it:
if you write if ( $record["status"] == "posted" ), then a user who changes the
status away from "posted" in the same save has already made $record["status"]
something else, and the rule doesn't fire. Locks must read $before.
The reverse is true too — a "never save this value" rule checked against
$before blocks edits to records that were bad (including the edit that fixes
them) while letting the bad value in unchallenged.
$user["role"] — the person's role in this workspace
$user["role"] is the acting person's role in the workspace this table lives
in, so a rule can gate on it:
$user["role"] |
Who |
|---|---|
"admin" |
Admin, the account owner (in every workspace), and any write nobody triggered |
"readwrite" |
Read & Write |
"readonly" |
Read Only |
"" |
Signed in, but not a member of this workspace |
A role belongs to a person and a workspace, not to the person alone. The same teammate can be an Admin in one workspace and Read Only in another, so a rule on table A and a rule on table B can reach different answers about the same person.
if ( $user["role"] != "admin" ) {
return "Only workspace admins can change this record.";
}
return "";
That rule does what you'd expect, including on your automations: a write nobody triggered (a schedule, a webhook, a web form, email-to-record) runs as the System user, which carries admin authority. Your published web form keeps working, and your scheduled automations aren't stopped by a rule you wrote to constrain people.
When an automation was triggered by a person (a button they pressed, a record
they saved), $user is that person and their role applies, so the rule is
enforced through the automation too.
API keys have a role as well: an account key acts as the account owner, so it
reports "admin"; a personal key carries its own owner's role.
Singling out automated writes
Because System is an admin, the role can't tell you "nobody did this". Two things can, and both say what they mean:
// Any write with no person behind it.
if ( $user["system"] ) return "";
// Or name the exact origin — see the $via table below.
if ( $via == "webform" ) return "";
Use these when a rule genuinely must treat unattended writes differently, for example to stop a public form from creating records your team is meant to own:
if ( $user["system"] ) {
return "Records here are created by the team, not by the public form.";
}
return "";
$via — the write origin
$via is a string naming where the write came from, so a rule can allow or block a change based on how it was made:
$via |
Origin |
|---|---|
"ui" |
A signed-in user editing on the record form |
"api" |
The public API (any API key) |
"flow" |
An automation |
"webform" |
A public web form submission |
"email" |
Email-to-record create |
"system" |
An internal/background write with no other origin |
Returning a result
- Return an empty string,
null, or nothing → the write passes. - Return a non-empty string → the write is blocked and that string is shown to the user.
On the record form a blocked write shows the message in a dialog and leaves the record unchanged. Through the public API the write is rejected with an error carrying your message (see the API errors reference).
Examples
Require a field before a status
// A deal can't be marked "won" without a close date.
if ( $record["stage"] == "won" && empty($record["close_date"]) ) {
return "Set a close date before marking the deal won.";
}
Lock a record once it reaches a status
Compare $before (what's stored) to freeze a record after it's been posted:
// Once posted, an invoice can't be edited or deleted.
if ( $before["status"] == "posted" ) {
return "This invoice is posted and can no longer be changed.";
}
Because $record is null on delete but $before is still set, the same rule blocks both edits and deletes of a posted record — and because it reads $before, it also blocks the save that would un-post the invoice.
Make a table editable only via API or flows
Use $via to keep a table as a system of record — your integrations and automations maintain it, and people can't change it by hand:
// Only the API and automations may write here; block interactive edits.
if ( ! in_array($via, ["api", "flow"]) ) {
return "This table is maintained by our integrations and can't be edited directly.";
}
Swap the allow-list to fit the case: ["api"] for API-only, or add "webform" to also accept public form submissions.
Restrict a single field instead of the whole write
To make one field uneditable by regular users — rather than gating the whole record — you usually don't need a rule at all. Mark the field Admin Only in its field settings: non-admins see it locked, while admins (and an admin-scoped API key) can still write it. Reach for a validation rule when the lock depends on data (a status, another field, the write origin) rather than just on the user's role.
Notes and limits
- A rule set on a sub-table doesn't fire on its own — line items are validated by the parent table's rule as part of the whole-unit write. Put line-item checks in the parent's rule and read
$lines. - A parent-only update (no line items sent) leaves
$linesempty. Send the lines if a rule needs to check them. - The rule runs read-only and returns a single message. Field-by-field error mapping isn't available yet.