Skip to content

Plugins

Anvil has no plugin API yet. A plugin today is an ordinary Composer package that ships resources, pages, widgets, fields, columns, filters, layouts, policies, migrations, language files and views. The application installs it and registers its classes in its panels and in Config\Anvil, the same way it registers its own classes.

Anvil does this itself: the user and role administration is a set of resources and pages that every panel adds through Anvil\Access\Access (see Bundle class).

Every class in a package must follow the rules for every extension (escaping, no internal ids, Content Security Policy, htmx, permissions).

acme/anvil-billing/
├── composer.json
├── src/
│ ├── Billing.php bundle class
│ ├── Resources/InvoiceResource.php
│ ├── Models/InvoiceModel.php
│ ├── Entities/Invoice.php
│ ├── Pages/BillingReportPage.php
│ ├── Database/Migrations/2026-11-01-000001_CreateBillingInvoices.php
│ ├── Language/en/Billing.php
│ ├── Language/de/Billing.php
│ └── Views/report.php
├── public/billing.js optional assets
└── tests/
{
"name": "acme/anvil-billing",
"type": "library",
"require": {
"php": "^8.5",
"rakoitde/anvil": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\Billing\\": "src/" }
}
}

CodeIgniter discovers the namespaces of Composer packages (Config\Modules::$discoverInComposer, on by default). That is what makes migrations, language files, views and a Config/Registrar.php of the package visible to the application, exactly as for Anvil (Anvil\ → src/).

Anvil’s Access class returns lists of class names that a panel spreads into its own lists:

// src/Access/Access.php (Anvil)
final class Access
{
public static function resources(): array
{
return [UserResource::class, RoleResource::class, /* ApiClientResource when the API is used */];
}
public static function pages(): array
{
return [InboxPage::class, ProfilePage::class, WorkflowsPage::class];
}
}

A package uses the same pattern. It is a convention, not an interface: add whatever the package provides.

namespace Acme\Billing;
use Acme\Billing\Pages\BillingReportPage;
use Acme\Billing\Resources\InvoiceResource;
use Acme\Billing\Widgets\OpenInvoices;
final class Billing
{
/** @return list<class-string<\Anvil\Resource\Resource>> */
public static function resources(): array
{
return [InvoiceResource::class];
}
/** @return list<class-string<\Anvil\Page\Page>> */
public static function pages(): array
{
return [BillingReportPage::class];
}
/** @return list<\Anvil\Widget\Widget> */
public static function widgets(): array
{
return [new OpenInvoices()];
}
}

Panel::resources() and pages() take class names, Panel::widgets() takes instances.

The application adds the bundle to the panels that should show it (Panels):

use Acme\Billing\Billing;
use Anvil\Access\Access;
use Anvil\Page\Dashboard;
use Anvil\Panel\Panel;
final class AdminPanel extends Panel
{
public function id(): string { return 'admin'; }
public function resources(): array
{
return [ProjectResource::class, ...Billing::resources(), ...Access::resources()];
}
public function pages(): array
{
return [Dashboard::class, ...Billing::pages(), ...Access::pages()];
}
public function widgets(): array
{
return [...Billing::widgets()];
}
public function scripts(): array
{
return [base_url('assets/billing/billing.js')];
}
}

Routes, navigation entries and component ids follow from the panel; the panel itself is listed in Config\Anvil::$panels as usual. Navigation groups of the package’s resources ($navigationGroup) can be ordered with Panel::navigationGroups(), see Navigation.

Some features are registered in Config\Anvil rather than in a panel. The package documents which entries the application has to set:

Option What a package provides
$policies record policies (Model/entity class => Policy class), see Authorization
$permissions extra permissions for the role editor (custom actions)
$workflowBlocks Anvil\Workflow\BlockProvider classes with guards, effects, forms and resolvers, see Workflows
$links external systems that columns and actions link to
app/Config/Anvil.php
public array $policies = [\Acme\Billing\Entities\Invoice::class => \Acme\Billing\Policies\InvoicePolicy::class];
public array $workflowBlocks = [\Acme\Billing\Workflow\BillingBlocks::class];

CodeIgniter’s registrars also work: a static method Anvil() in the package’s src/Config/Registrar.php returns properties that are merged into the application’s Config\Anvil (arrays with array_merge). This applies to every application that installs the package, without a choice; prefer explicit configuration for anything security relevant (policies, permissions).

The permissions of a package’s resources and pages are derived like those of the application: <slug>.view, .create, .update, .delete, .history, .export and <panel id>.page.<page slug>, see Resources. They appear in the role editor and in php spark anvil:permissions as soon as the classes are registered in a panel. Permissions of custom actions outside a resource go into Config\Anvil::$permissions.

Migrations live in src/Database/Migrations with the namespace Acme\Billing\Database\Migrations. Tables follow the same conventions as Anvil’s own: a uuid BINARY(16) column with a unique key and, for tenant data, tenant_id with a foreign key to anvil_tenants (see make:anvil-model for what it generates; the command writes into app/, so move the files into the package and adjust the namespaces).

Terminal window
php spark migrate --all # all namespaces, as anvil:install does
php spark migrate -n "Acme\Billing" # only the package
  • Language: src/Language/en/Billing.php is read with lang('Billing.invoice'). Use a file name of your own, not Anvil.php: files with the same name are merged across namespaces, which is how applications override Anvil’s texts (and can override yours).
  • Views: reference them by namespace, e.g. view('Acme\Billing\Views\report', [...]). Applications can replace them with app/Views/overrides/Acme/Billing/Views/report.php, as they do for Anvil’s views. Fields, filters and layouts return their view from view(); give the full namespaced name, a name without / or \ refers to Anvil’s own views.

The make:anvil-* generators accept --namespace Acme\Billing for the class, but write the view below app/Views; move it into the package.

Panel::stylesheets() and Panel::scripts() take URLs of the own origin (Styling). A package therefore copies its files into the public directory of the application. Anvil’s own anvil:publish only publishes Anvil’s assets; a package can ship a CodeIgniter publisher, which php spark publish discovers in the Publishers directory of every namespace:

namespace Acme\Billing\Publishers;
use CodeIgniter\Publisher\Publisher;
final class BillingPublisher extends Publisher
{
protected $source = __DIR__ . '/../../public';
protected $destination = FCPATH . 'assets/billing';
public function publish(): bool
{
return $this->addPath('/')->merge(true);
}
}

The Content Security Policy applies to package code as to any other: no inline scripts or styles, no event attributes; Alpine components are registered on alpine:init in the package script, see Scripts and Alpine components.

Test the package against Anvil with PHPUnit and CodeIgniter’s test tools, as described in Testing. Require codeigniter4/framework as a dev dependency and use its test bootstrap (vendor/codeigniter4/framework/system/Test/bootstrap.php), as Anvil does.

  • Put a test panel into tests/_support, register the bundle in it and inject the registry: Services::injectMock('panels', new PanelRegistry([TestPanel::class])) (Anvil\Panel\PanelRegistry).
  • Migrate Anvil’s dependencies, Anvil and the package: protected $namespace = ['CodeIgniter\Settings', 'CodeIgniter\Shield', 'CodeIgniter\Queue', 'Anvil', 'Acme\Billing'];
  • Use Anvil\Test\InteractsWithAnvil for permissions (grantPermissions()), component actions (dispatchComponent()), uploads (fakeUploads()) and rejections (assertRejected()).
final class InvoiceResourceTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use InteractsWithAnvil;
public function testCreate(): void
{
$this->grantPermissions(['billing-invoices.create']);
$this->dispatchComponent('admin', 'billing-invoices:form', 'save', ['number' => 'RE-1']);
$this->seeInDatabase('billing_invoices', ['number' => 'RE-1']);
}
}