Extending Anvil
Anvil covers the common building blocks. Applications add their own
fields, columns, filters, layouts, pages, widgets, scripts and views
without changing the library. Generators create the skeletons:
make:anvil-field, make:anvil-column, make:anvil-filter,
make:anvil-layout, make:anvil-page, make:anvil-widget and
make:anvil-policy, see Commands.
Read the rules first: they are what keeps an application secure and working with the Content Security Policy.
Rules for every extension
Section titled “Rules for every extension”Output
Section titled “Output”- Escape every value:
esc($value)in HTML,esc($value, 'attr')in attributes. Methods that return HTML (Column::html(), views) must return safe HTML. - Never output internal ids (
id,tenant_id, foreign keys). Use UUIDs and titles;Field::storedText()anddisplayText()must not show ids.
Content Security Policy
Section titled “Content Security Policy”- No inline scripts (
<script>…</script>), no event attributes (onclick=), nostyle=attributes. They are blocked by the CSP and silently do nothing. Use CSS classes and your own scripts, see Styling. A test of Anvil (tests/Unit/Support/ViewConventionsTest.php) enforces this for its own views; a similar test is worth having in an application. - Alpine runs in its CSP build: templates reference properties and methods
of registered components by name (
x-show="isOpen",@click="toggle"), no inline expressions. x-showhides withdisplay: none. Bootstrap’sd-flex,d-inline-flexetc. are!importantand win: putx-showon a wrapper element.- In Alpine methods use
this.$rootfor the component element;this.$elis the element of the current event handler.
- Never put tokens into
hx-valsof a container (form, div):hx-valsis inherited by links inside, and boosted links would put the token into the URL. Use hidden inputs (_token,_action) orhx-valson the button. - Forms that update only part of the page: set
hx-disinherit="*"(or the attributes that children must not inherit), otherwise links and buttons inside inherithx-target/hx-swap. - Links to downloads need
hx-boost="false"(the body is boosted).
Data and permissions
Section titled “Data and permissions”- Query through the resource (
$resource->query(),findRecord()), not the bare model: the tenant scope,scopeQuery()and policies apply. - Check permissions on the server for every request, also when a button is hidden in the UI.
- Use the query builder for conditions; never concatenate input into SQL.
For
IS NULLusewhere($column)/where("$column !=", null)so the table prefix applies.
Fields
Section titled “Fields”php spark make:anvil-field Ratingnamespace App\Fields;
use Anvil\Schema\Field;
final class Rating extends Field{ protected function typeRules(): array { return ['integer', 'greater_than_equal_to[1]', 'less_than_equal_to[5]']; }
public function dehydrate(mixed $value): ?int { return is_numeric($value) ? (int) $value : null; }
public function displayText(mixed $value): string { return is_numeric($value) ? str_repeat('★', (int) $value) : ''; }
protected function view(): string { return 'fields/rating'; // app/Views/fields/rating.php }}How a field works:
| Step | Method | Default |
|---|---|---|
| Value for the form | value(FormContext) |
the record value, default() when creating |
| Rendering | view() → view with $field, $id, $name, $label, $value, $required, $disabled, $readOnly, $helperText, $placeholder |
|
| Validation | typeRules(); lists: nestedRules() for name.* |
required/permit_empty is added |
| Saving | dehydrate($value) |
trimmed string, empty → null |
| Display | displayText() (read only lists), storedText() (history) |
scalar as string |
| API answer | apiValue($stored), apiSchema() (OpenAPI), isApiReadable() |
scalar as it is, schema from the rules |
| API input | apiInput($json) converts a JSON value into the form input, isApiWritable() |
booleans '1'/'0', numbers as string |
The view decides on $readOnly (detail page) and should render the input
through Anvil\Views\schema\fields\_wrapper (label, help text, error slot,
data-anvil-field for server errors). A view() without / or \ refers
to Anvil’s own field views.
Columns
Section titled “Columns”php spark make:anvil-column Progressnamespace App\Columns;
use Anvil\Collection\Columns\Column;
final class Progress extends Column{ public function text(array|object $record): string { return $this->percent($record) . ' %'; }
public function html(array|object $record): string { $percent = $this->percent($record);
// Escape every value, also numbers: the code stays safe when it changes. return '<progress class="w-100" max="100" value="' . esc((string) $percent, 'attr') . '">' . esc($this->text($record)) . '</progress>'; }
public function exportValue(array|object $record): int { return $this->percent($record); }
private function percent(array|object $record): int { $value = $this->value($record);
return max(0, min(100, is_numeric($value) ? (int) $value : 0)); }}Labels, sortable(), searchable(), alignEnd(), toggleable(),
queryColumn(), state(), color(), icon(), description() and
link() come from Column. html() (default: escaped text) is shown in
table and cards; render() (final) adds link, icon and
tone around it.
text() is plain text (search hits, titles), exportValue() (default:
text) is used for exports.
Filters
Section titled “Filters”php spark make:anvil-filter MinimumAmountnamespace App\Filters;
use Anvil\Collection\Filters\Filter;use Anvil\Data\Model;
final class MinimumAmount extends Filter{ public function rules(): array { return [$this->inputName() => ['permit_empty', 'is_natural']]; }
public function value(array $input): ?string { $value = self::input($input, $this->inputName());
return $value === '' ? null : $value; }
public function apply(Model $model, array|string $value): void { $model->where($this->qualifiedColumn($model) . ' >=', $value); }
protected function view(): string { return 'filters/minimum_amount'; }}- Only inputs listed in
rules()reach the filter; invalid input is rejected with 422. toInput()(inverse ofvalue()) is needed for filters with several inputs or formatted values, so saved views can be applied.- The view receives
$filter,$value,$endpointand$swap; inputs must carryhx-post="<?= esc($endpoint, 'attr') ?>" hx-sync="closest form:replace" <?= $swap ?> hx-trigger="change".
Layouts
Section titled “Layouts”A layout is a Anvil\Schema\Component with fields() (all fields inside,
so they are validated and saved) and render(FormContext). Layouts take the
full width (protected int $columnSpan = self::FULL;). Generate one with
make:anvil-layout and render the children with
view('Anvil\Views\schema\grid', ['columns' => …, 'components' => …, 'context' => $context]).
php spark make:anvil-page ReportsA page renders its own content in the panel layout:
final class ReportsPage extends Page{ public function title(): string { return 'Reports'; }
public function content(): string { return view('pages/reports', ['totals' => model(InvoiceModel::class)->totals()]); }}Its permission is <panel>.page.<slug> (shown in the role editor);
permission(): null makes it available to every user of the panel.
Interactive pages implement Anvil\Page\HasComponents and return their
components; the endpoint finds them as <panel>:<page slug>:<kind>:
final class ReportsPage extends Page implements HasComponents{ public function component(string $kind): ?Component { return $kind === 'filter' ? new ReportFilter() : null; }
public function content(): string { $token = service('componentTokens')->issue(new ComponentClaims( $this->panel->id() . ':' . $this->slug() . ':filter', $this->panel->id(), service('tenantContext')->id(), ));
return view('pages/reports', ['token' => $token, 'endpoint' => $this->panel->endpointUrl()]); }}How to write the component (actions, permissions, validation, responses): Component endpoint.
Widgets
Section titled “Widgets”A widget implements Anvil\Widget\Widget (canView(Gate $gate),
columnSpan(), render()) and is returned from Panel::widgets()
(generator: make:anvil-widget). Load data in render(): it only runs when
the widget is shown. For key figures, charts and tables use StatWidget,
ChartWidget and TableWidget, see Widgets.
Scripts and Alpine components
Section titled “Scripts and Alpine components”final class AdminPanel extends Panel{ public function scripts(): array { return [base_url('js/app.js')]; }
public function stylesheets(): array { return [base_url('css/app.css')]; }}Both are URLs of the own origin. The scripts are loaded (deferred) before Anvil starts Alpine, so they can register Alpine components:
document.addEventListener('alpine:init', () => { Alpine.data('stars', () => ({ value: 0, get label() { return `${this.value} / 5`; }, set(event) { this.value = Number(event.currentTarget.dataset.value); }, }));});<div x-data="stars"> <button type="button" data-value="3" @click="set">3</button> <span x-text="label"></span></div>window.Anvil.notify({ message: 'Saved', type: 'success' }) shows a toast.
Server responses can trigger the same with
ComponentResponse::html(…)->withEvent('anvil:notify', ['message' => 'Saved']).
Replacing views
Section titled “Replacing views”Every Anvil view can be replaced with CodeIgniter’s view overrides: a file
app/Views/overrides/Anvil/Views/<path>.php replaces
Anvil\Views\<path>, e.g. app/Views/overrides/Anvil/Views/pages/dashboard.php.
Copy the original from vendor/rakoitde/anvil/src/Views/ as a starting
point. The replacement receives the same variables; keep the escaping and
the htmx attributes. Replacements are not updated with Anvil: check them
after updates.
Language lines are merged: app/Language/de/Anvil.php overrides single
keys of Anvil and adds new ones, e.g. labels of your own audit events:
return [ 'history' => ['events' => ['invoice_sent' => 'Rechnung versendet']],];Storage and virus scanner
Section titled “Storage and virus scanner”- Files are stored by
Anvil\Files\Storage(LocalStorageby default). Another storage, e.g. S3, implementsput(),path()anddelete()and is used by overriding thefilesservice inapp/Config/Services.php. - A virus scanner implements
Anvil\Files\Scanner::isClean()and is set inConfig\Anvil::$fileScanner; throw an exception if the scan is not possible (the upload is rejected).
Policies
Section titled “Policies”Record level rules (“agents only edit orders of their region”) implement
Anvil\Security\Authorization\Policy and are registered in Config\Anvil::$policies, see
Authorization. Generate one with
make:anvil-policy.
Audit events
Section titled “Audit events”Business events are logged with service('auditLogger')->record('invoice.sent', 'invoices', $uuid, [], ['to' => $email]);
see Audit log.