Skip to content

Getting started

A complete component from scratch: a page with a list of new support tickets, an action per ticket and one for all of them, the view and the tests.

A page “Triage” lists new support tickets. Each ticket can be marked as read; a button marks all of them as read. The pieces:

  • App\Pages\TriagePage: the page, provides the component
  • App\Components\TriageComponent: actions and rendering
  • app/Views/triage/tickets.php: the HTML
  • a test

The ticket model is an Anvil model (Anvil\Data\Model) with the columns subject and status; tenant scope, UUIDs and the audit log come from the model.

namespace App\Pages;
use Anvil\Endpoint\Component;
use Anvil\Endpoint\ComponentClaims;
use Anvil\Page\HasComponents;
use Anvil\Page\Page;
use App\Components\TriageComponent;
final class TriagePage extends Page implements HasComponents
{
public function title(): string
{
return 'Triage';
}
public function navigationIcon(): string
{
return 'inbox';
}
// Called by the endpoint for the id "<panel>:triage:<kind>".
public function component(string $kind): ?Component
{
return $kind === TriageComponent::KIND ? new TriageComponent($this->panel) : null;
}
public function content(): string
{
return (new TriageComponent($this->panel))->render(new ComponentClaims(
component: $this->panel->id() . ':' . $this->slug() . ':' . TriageComponent::KIND,
panel: $this->panel->id(),
tenantId: service('tenantContext')->id(),
));
}
}

Register the page in Panel::pages() like any page. Its slug is triage, so the component id is admin:triage:tickets in the panel admin.

namespace App\Components;
use Anvil\Endpoint\Component;
use Anvil\Endpoint\ComponentAction;
use Anvil\Endpoint\ComponentClaims;
use Anvil\Endpoint\ComponentRequest;
use Anvil\Endpoint\ComponentResponse;
use Anvil\Endpoint\EndpointException;
use Anvil\Panel\Panel;
use App\Models\TicketModel;
final readonly class TriageComponent implements Component
{
public const string KIND = 'tickets';
public function __construct(
private Panel $panel,
) {
}
public function actions(ComponentClaims $claims): array
{
return [
new ComponentAction(
name: 'markRead',
permission: 'tickets.update',
handler: $this->markRead(...),
rules: ['ticket' => ['required', 'valid_uuid']],
),
new ComponentAction(
name: 'markAllRead',
permission: 'tickets.update',
handler: $this->markAllRead(...),
),
];
}
/**
* Renders the list with a fresh token: used by the page and by every action.
*/
public function render(ComponentClaims $claims): string
{
return view('triage/tickets', [
'tickets' => (new TicketModel())->where('status', 'new')->orderBy('created_at')->asArray()->findAll(50),
'token' => service('componentTokens')->issue($claims),
'endpoint' => $this->panel->endpointUrl(),
], ['saveData' => false]);
}
private function markRead(ComponentRequest $request): ComponentResponse
{
$tickets = new TicketModel();
$ticket = $tickets->asArray()->findByUuid($request->string('ticket'))
?? throw EndpointException::forNotFound('ticket');
$tickets->update($ticket['id'], ['status' => 'read']);
return ComponentResponse::html($this->render($request->claims));
}
private function markAllRead(ComponentRequest $request): ComponentResponse
{
$tickets = new TicketModel();
$ids = array_column($tickets->select('id')->where('status', 'new')->asArray()->findAll(), 'id');
if ($ids !== []) {
$tickets->update($ids, ['status' => 'read']);
}
return ComponentResponse::html($this->render($request->claims))
->withEvent('anvil:notify', ['message' => count($ids) . ' tickets marked as read', 'type' => 'success']);
}
}
  • The ticket comes from the input as a UUID (never an internal id) and is looked up through the model, so a ticket of another tenant is not found (404).
  • Both actions change data, so they run in a transaction and are written to the audit log as component.action (default mutates: true).
<?php
/**
* @var list<array<string, mixed>> $tickets
* @var string $token
* @var string $endpoint
*/
// Token and action on the button, never on a container (see "htmx conventions").
$vals = static fn (array $values): string => esc(json_encode(['_token' => $token, ...$values], JSON_THROW_ON_ERROR), 'attr');
?>
<div id="triage" class="card">
<div class="card-header d-flex align-items-center">
<span class="fw-semibold me-auto">New tickets</span>
<?php if ($tickets !== []): ?>
<button type="button" class="btn btn-sm btn-outline-secondary"
hx-post="<?= esc($endpoint, 'attr') ?>" hx-vals="<?= $vals(['_action' => 'markAllRead']) ?>"
hx-target="#triage" hx-swap="outerHTML" hx-confirm="Mark all tickets as read?">Mark all as read</button>
<?php endif ?>
</div>
<?php if ($tickets === []): ?>
<div class="anvil-empty py-4"><i class="bi bi-inbox" aria-hidden="true"></i>No new tickets</div>
<?php else: ?>
<ul class="list-group list-group-flush">
<?php foreach ($tickets as $ticket): ?>
<li class="list-group-item d-flex align-items-center gap-2">
<span class="me-auto"><?= esc($ticket['subject']) ?></span>
<button type="button" class="btn btn-sm btn-link"
hx-post="<?= esc($endpoint, 'attr') ?>" hx-vals="<?= $vals(['_action' => 'markRead', 'ticket' => $ticket['uuid']]) ?>"
hx-target="#triage" hx-swap="outerHTML">Mark as read</button>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
</div>
  • The answer is the whole component (<div id="triage">…), and the buttons replace it (hx-target="#triage" hx-swap="outerHTML"). The new HTML carries a new token.
  • hx-confirm opens Anvil’s confirmation dialog instead of the browser’s confirm(); the question is inserted as text.
  • The CSRF header is added to every htmx request by anvil.js.
use Anvil\Data\Uuid;
use App\Models\TicketModel;
final class TriagePageTest extends AppTestCase // your base class, see Testing
{
public function testMarkRead(): void
{
$this->grantPermissions(['tickets.update']);
$uuid = $this->createTicket('Printer is broken');
$response = $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => $uuid]);
$this->assertStringNotContainsString('Printer is broken', $response->html);
$this->seeInDatabase('tickets', ['subject' => 'Printer is broken', 'status' => 'read']);
}
public function testMarkAllReadNotifies(): void
{
$this->grantPermissions(['tickets.update']);
$this->createTicket('A');
$this->createTicket('B');
$response = $this->dispatchComponent('admin', 'triage:tickets', 'markAllRead');
$this->assertSame('2 tickets marked as read', $response->events['anvil:notify']['message']);
}
public function testNeedsPermission(): void
{
$this->grantPermissions(['tickets.view']);
$uuid = $this->createTicket('A');
$this->assertRejected(403, fn () => $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => $uuid]));
}
public function testUnknownTicket(): void
{
$this->grantPermissions(['tickets.update']);
$this->assertRejected(404, fn () => $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => Uuid::v7()->toString()]));
}
public function testTicketMustBeAUuid(): void
{
$this->grantPermissions(['tickets.update']);
$e = $this->assertRejected(422, fn () => $this->dispatchComponent('admin', 'triage:tickets', 'markRead', ['ticket' => '42']));
$this->assertArrayHasKey('ticket', $e->errors);
}
private function createTicket(string $subject): string
{
$tickets = new TicketModel();
$tickets->insert(['subject' => $subject, 'status' => 'new']);
return (string) $tickets->getInsertUuid();
}
}

See Testing components for the helpers.