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).
Package layout
Section titled “Package layout”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/).
Bundle class
Section titled “Bundle class”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.
Registering in a panel
Section titled “Registering in a panel”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.
Configuration
Section titled “Configuration”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 |
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).
Permissions
Section titled “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
Section titled “Migrations”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).
php spark migrate --all # all namespaces, as anvil:install doesphp spark migrate -n "Acme\Billing" # only the packageLanguage files and views
Section titled “Language files and views”- Language:
src/Language/en/Billing.phpis read withlang('Billing.invoice'). Use a file name of your own, notAnvil.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 withapp/Views/overrides/Acme/Billing/Views/report.php, as they do for Anvil’s views. Fields, filters and layouts return their view fromview(); 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.
Assets
Section titled “Assets”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.
Testing the package
Section titled “Testing the package”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\InteractsWithAnvilfor 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']); }}