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.
How a request is processed
Section titled “How a request is processed”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-RedirectThe 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.
Component tokens
Section titled “Component tokens”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.
Writing a component
Section titled “Writing a component”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']); }}Making it reachable
Section titled “Making it reachable”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>Rules for components
Section titled “Rules for components”- 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::AUTHENTICATEDis 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 ascomponent.action; read-only actions passmutates: false. - Throw
EndpointException::forValidation([...]),forForbidden()orforNotFound()for errors. - Responses:
ComponentResponse::html(),empty(),redirect($path)anddownload($path)(relative, same-origin paths only), pluswithEvent($name, $payload). - Escape all output. Prefer rendering views.
Configuration
Section titled “Configuration”| 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.