Skip to content

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.

  • 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() and displayText() must not show ids.
  • No inline scripts (<script>…</script>), no event attributes (onclick=), no style= 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-show hides with display: none. Bootstrap’s d-flex, d-inline-flex etc. are !important and win: put x-show on a wrapper element.
  • In Alpine methods use this.$root for the component element; this.$el is the element of the current event handler.
  • Never put tokens into hx-vals of a container (form, div): hx-vals is inherited by links inside, and boosted links would put the token into the URL. Use hidden inputs (_token, _action) or hx-vals on 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 inherit hx-target/hx-swap.
  • Links to downloads need hx-boost="false" (the body is boosted).
  • 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 NULL use where($column) / where("$column !=", null) so the table prefix applies.
Terminal window
php spark make:anvil-field Rating
namespace 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.

Terminal window
php spark make:anvil-column Progress
namespace 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.

Terminal window
php spark make:anvil-filter MinimumAmount
namespace 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 of value()) is needed for filters with several inputs or formatted values, so saved views can be applied.
  • The view receives $filter, $value, $endpoint and $swap; inputs must carry hx-post="<?= esc($endpoint, 'attr') ?>" hx-sync="closest form:replace" <?= $swap ?> hx-trigger="change".

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]).

Terminal window
php spark make:anvil-page Reports

A 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.

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.

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:

public/js/app.js
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']).

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']],
];
  • Files are stored by Anvil\Files\Storage (LocalStorage by default). Another storage, e.g. S3, implements put(), path() and delete() and is used by overriding the files service in app/Config/Services.php.
  • A virus scanner implements Anvil\Files\Scanner::isClean() and is set in Config\Anvil::$fileScanner; throw an exception if the scan is not possible (the upload is rejected).

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.

Business events are logged with service('auditLogger')->record('invoice.sent', 'invoices', $uuid, [], ['to' => $email]); see Audit log.