Skip to content

Component endpoint

All interactive UI elements (tables, forms, actions, filters, the profile) talk to the server through one endpoint per panel: POST /<panel>/_anvil (e.g. /admin/_anvil). Panels, resources and pages use it automatically. This page explains how it protects the application, and how to write a component by hand.

browser server
─────── ──────
POST /admin/_anvil
_token = <opaque token> ─────────► 0. panel filters (session, login method,
_action = "rename" password change, tenant, panel permission),
name = "Beta" rate limit per IP
1. decrypt and verify the token
2. token panel and tenant == current ones
3. guards (pending password change, TOTP setup)
4. resolve the component from the registry
5. action declared by the component?
6. actor holds the action's permission?
7. validate input, drop fields without rule
8. run the handler (transaction + audit)
◄───────────────────────── HTML fragment, HX-Trigger, HX-Location, HX-Redirect

The request is form encoded (htmx) or JSON, with the fields _token and _action; all other fields are the input of the action.

If any step fails, the request is rejected with a generic message. The reason is recorded as security event access.denied (audit log and application log), see Audit log. An expired token is not recorded: an idle page sends it with every poll, and polling elements (data-anvil-poll) stop quietly after an error instead of showing a message every minute.

Status Meaning
400 malformed request
403 invalid, foreign or expired token, wrong panel or tenant, missing permission
404 unknown component or action
422 validation failed, errors contains field messages
429 rate limit exceeded

Answers carry Cache-Control: no-store.

A token is rendered into the page by the server. It is

  • encrypted and authenticated (XChaCha20-Poly1305, key derived with HKDF from encryption.key): the browser can neither read nor change it,
  • bound to the session: a random secret in the session is part of the key, so the token is useless in another session and becomes invalid on logout,
  • short-lived: Config\Anvil::$componentTokenTtl, default two hours.

It contains a registry id (never a class name), the panel, the tenant, an optional record UUID and server defined, immutable context.

use Anvil\Endpoint\Component;
use Anvil\Endpoint\ComponentAction;
use Anvil\Endpoint\ComponentClaims;
use Anvil\Endpoint\ComponentRequest;
use Anvil\Endpoint\ComponentResponse;
use Anvil\Endpoint\EndpointException;
final class ProjectTitle implements Component
{
public function actions(ComponentClaims $claims): array
{
return [
new ComponentAction(
name: 'rename',
permission: 'projects.update',
handler: $this->rename(...),
rules: ['name' => 'required|string|max_length[100]'],
),
];
}
private function rename(ComponentRequest $request): ComponentResponse
{
$projects = model(ProjectModel::class);
$project = $projects->findByUuid($request->claims->record)
?? throw EndpointException::forNotFound('project');
$projects->update($project->id, ['name' => $request->string('name')]);
return ComponentResponse::html(view('projects/title', ['project' => $project]))
->withEvent('anvil:notify', ['message' => 'Saved']);
}
}

On a custom page, implement Anvil\Page\HasComponents; the endpoint finds the component as <panel>:<page slug>:<kind>:

final class ProjectsPage extends Page implements HasComponents
{
public function component(string $kind): ?Component
{
return $kind === 'title' ? new ProjectTitle() : null;
}
public function content(): string
{
$uuid = (string) service('request')->getGet('project');
$project = model(ProjectModel::class)->findByUuid($uuid) ?? throw PageNotFoundException::forPageNotFound();
$token = service('componentTokens')->issue(new ComponentClaims(
component: $this->panel->id() . ':' . $this->slug() . ':title',
panel: $this->panel->id(),
tenantId: service('tenantContext')->id(),
record: Uuid::fromString($uuid), // Anvil\Data\Uuid
));
return view('projects/page', ['token' => $token, 'endpoint' => $this->panel->endpointUrl()]);
}
}

Components outside of pages are registered by id (lowercase, a-z0-9_.:-) in service('componentRegistry')->register('projects.title', static fn () => new ProjectTitle()). The registry lives for one request, so register them on every request (e.g. in an event listener in app/Config/Events.php), not only while rendering the page.

In the view, send the token and the action with hidden inputs (never in hx-vals of a container, see Extending):

<form hx-post="<?= esc($endpoint, 'attr') ?>" hx-target="this" hx-swap="outerHTML">
<input type="hidden" name="_token" value="<?= esc($token, 'attr') ?>">
<input type="hidden" name="_action" value="rename">
<input name="name" value="<?= esc($project->name, 'attr') ?>">
</form>
  • Only actions returned by actions() can be called. Other public methods are not reachable. Action names match [a-z][a-zA-Z0-9_-]{0,63}.
  • Every action needs a permission. Gate::AUTHENTICATED is only for data of the user themselves.
  • The handler only receives input that has a validation rule; read it with $request->string() or $request->input.
  • Actions with mutates: true (default) run in a transaction and are audited as component.action; read-only actions pass mutates: false.
  • Throw EndpointException::forValidation([...]), forForbidden() or forNotFound() for errors.
  • Responses: ComponentResponse::html(), empty(), redirect($path) and download($path) (relative, same-origin paths only), plus withEvent($name, $payload).
  • Escape all output. Prefer rendering views.
Option Default Description
anvil.endpointPath _anvil URI segment of the endpoint inside every panel
anvil.endpointRateLimit 120 requests per minute and IP, 0 = off
anvil.componentTokenTtl 7200 token lifetime in seconds

The endpoint is protected by the filters of its panel (Panel::filters()). Keep CodeIgniter’s CSRF filter enabled for all POST routes.