Appearance
Writing a Module
Introduction
php artisan kitloom:make-module writes a module that already follows every rule on this page — Your First Module walks through it. This page explains what it wrote and why, so you can change it with confidence or write a module by hand.
A module is three Composer packages:
text
acme/reviews the domain: models, migrations, the site side, config — no admin code
acme/reviews-filament the Filament admin: resources, pages, the menu section
acme/reviews-nova the Nova admin: the same in NovaA headless site installs the domain package only; a Filament site adds -filament, a Nova site adds -nova. Neither admin package knows about the other.
The generator has two shapes. With --post-type=review the records are WordPress posts of that type, with addresses of their own. Without it they are rows of a table of the module's own (reviews). The examples below are the post type shape unless they say otherwise.
The domain package
composer.json
json
{
"name": "acme/reviews",
"description": "Reviews as posts of the `review` type, with addresses of their own.",
"license": "proprietary",
"type": "library",
"require": {
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"kitloom/site": "^1.0",
"kitloom/wp-schema": "^1.0",
"php": "^8.4"
},
"autoload": {
"psr-4": { "Acme\\Reviews\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Acme\\Reviews\\Tests\\": "tests/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Reviews\\ReviewsServiceProvider"]
},
"kitloom": {
"module": {
"name": "Reviews",
"description": "Reviews with pages of their own on the site.",
"group": "Content",
"default": false
}
}
}
}extra.laravel.providers— Laravel's package discovery registers the provider. The application never lists it.extra.kitloom.module— puts the package on the Modules screen, where it can be switched off. Only the domain package declares it.groupanddefaultplace it inkitloom:install's questions on a new site: the list it is offered in, and whether it is ticked there;core: truekeeps a module out of the questions.requireis the dependency graph the Modules screen reads too: while your module is on, the modules it requires cannot be switched off. Require every kitloom package whose classes you use, and no other.- A table module requires
kitloom/wp-schemaonly;kitloom/sitecomes with a post type, for its addresses. - The kitloom packages are required as
^1.0, the version every kitloom package is released under. The admin packages require your domain package as@dev, which works while they are linked from a path repository; before you publish, require it by version — see Publishing a Module.
No admin in the domain package
The domain package does not require filament/filament or laravel/nova and does not import their classes.
The service provider
For a post type, the provider tags two type providers — see Post types and taxonomies:
php
class ReviewsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/acme-reviews.php', 'acme-reviews');
// Tags, not calls: the types are collected when someone first needs them.
$this->app->tag([ReviewsContentTypes::class], ContentTypes::TAG);
$this->app->tag([ReviewsPostTypes::class], PostTypeRegistry::PROVIDER_TAG);
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([__DIR__.'/../config/acme-reviews.php' => config_path('acme-reviews.php')], 'acme-reviews-config');
}
}
}For a table of the module's own, it loads the migrations and puts the model into the registry, unless the application put its own there:
php
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->registerModels();
// publishes() as above
}
/**
* The package's models, unless the application put its own in the registry.
*/
private function registerModels(): void
{
$register = static function (ModelRegistry $registry): void {
if (! $registry->has(Review::KEY)) {
$registry->use(Review::KEY, Review::class);
}
};
$this->app->resolving(ModelRegistry::class, $register);
if ($this->app->resolved(ModelRegistry::class)) {
$register($this->app->make(ModelRegistry::class));
}
}Code that reads the records asks the registry for the class — WpSchema::model(Review::KEY), WpSchema::query(Review::KEY) — so an application that swapped the model gets its class everywhere, the admins included. See Data.
The model and the type
Reviews.php holds the names the module's parts agree on: POST_TYPE (review, as WordPress keeps it in post_type) and SLUG (reviews, the address prefix).
The model of a post type extends Kitloom\WpSchema\Models\Post — any model of wp_posts rows must, or the types fail as soon as they are collected. It keeps itself to its type, whoever builds the query:
php
class Review extends Post
{
protected static function booted(): void
{
parent::booted();
static::addGlobalScope(Reviews::POST_TYPE, fn (Builder $query) => $query->where($query->qualifyColumn('post_type'), Reviews::POST_TYPE));
static::creating(static function (self $post): void {
$post->post_type = Reviews::POST_TYPE;
});
}
}Fields of its own are post meta: $this->getMeta('key'), getMetaInt(), getMetaBool(), getMetaArray(), $this->setMeta('key', $value).
The type is declared twice, for two audiences:
php
// For the site: addresses, the archive, the model — register_post_type().
final readonly class ReviewsContentTypes implements ContentTypeProvider
{
public function postTypes(): iterable
{
yield new PostTypeObject(Reviews::POST_TYPE, rewriteSlug: Reviews::SLUG, withFront: false, hasArchive: true, label: 'Reviews', model: Review::class);
}
public function taxonomies(): iterable
{
return [];
}
}
// For kitloom/wp-schema's registry: what roles, relations and the admin ask about.
final readonly class ReviewsPostTypes implements PostTypeProviderInterface
{
public function postTypes(): array
{
return [Reviews::POST_TYPE];
}
}A site moved from WordPress with a plugin that registered review keeps its records and addresses: they are the same rows.
Config
config/acme-reviews.php holds the module's own options and the two admin switches:
php
return [
'filament' => ['register' => true],
'nova' => ['register' => true],
];A site that builds the section itself sets one to false (php artisan vendor:publish --tag=acme-reviews-config). Add a switch for each thing your module offers to others — shortcodes.register, ai.register — so a site can turn it off without touching your code.
Migrations
A table of the module's own is created only when it is missing, so a site moved from WordPress with the plugin's table keeps its rows:
php
if (! Schema::hasTable('reviews')) {
Schema::create('reviews', function (Blueprint $table): void {
$table->id();
$table->string('title');
$table->timestamps();
});
}The connection adds the site's table prefix (wp_): write reviews, not wp_reviews. In raw SQL, let the grammar wrap the name so it gets the prefix too, and hand SQL built at runtime to the query builder through Kitloom\WpSchema\Support\RawSql — Laravel 13 types the raw methods as literal strings:
php
use Kitloom\WpSchema\Support\RawSql;
$query->selectRaw(RawSql::of('max('.DB::getQueryGrammar()->wrap('reviews.updated_at').') as updated'));README and tests
The domain package's README.md is its How it works page in the admin: the Modules screen links to it, and the module's menu section ends with it. Write it for the person who configures the module — what it does, where its data lives, its settings — not only for the developer.
tests/ReviewsTestCase.php creates WordPress's tables in an in-memory SQLite database and puts WordPress's models in the registry; the generator maps the tests in the application's autoload-dev and adds a Reviews testsuite to its phpunit.xml. See Testing.
The admin packages
Plugging in
The Filament package adds itself to every panel; the application's AdminPanelProvider is not edited:
php
class ReviewsFilamentServiceProvider extends ServiceProvider
{
public function register(): void
{
// The permission of this module's section, for the roles screen.
$this->app->tag([ReviewsPermissions::class], Permission::PROVIDER_TAG);
// Where WordPress puts a plugin's own menu: after Comments.
$this->app->afterResolving(MenuOrder::class, fn (MenuOrder $order) => $order->after(ReviewsPlugin::GROUP, 'Comments'));
Panel::configureUsing(function (Panel $panel): void {
if (! (bool) $this->app['config']->get('acme-reviews.filament.register', true)) {
return;
}
$panel->plugin(ReviewsPlugin::make());
});
}
}The plugin may arrive twice — on its own and from a panel that adds it by hand to control the order — so it registers only what is not there yet:
php
public function register(Panel $panel): void
{
if (! in_array(ReviewResource::class, $panel->getResources(), true)) {
$panel->resources([ReviewResource::class]);
}
if (! in_array(ReviewsSettingsPage::class, $panel->getPages(), true)) {
$panel->pages([ReviewsSettingsPage::class]);
$panel->navigationItems([
ModuleHelpItem::make('acme/reviews', self::GROUP, fn (): bool => ReviewResource::canViewAny() || ReviewsSettingsPage::canAccess()),
]);
}
}The Nova package registers inside Nova::serving() and steps aside when Nova is not installed:
php
class ReviewsNovaServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->tag([ReviewsNovaPermissions::class], Permission::PROVIDER_TAG);
}
public function boot(): void
{
if (! class_exists(Nova::class) || ! (bool) $this->app['config']->get('acme-reviews.nova.register', true)) {
return;
}
Nova::serving(function (): void {
// The application's subclass of the resource, if it registered one.
$resource = ResourceOverride::find(ReviewResource::class, Nova::$resources);
Nova::resources([$resource]);
Nova::tools([new Reviews($resource)]);
});
}
}In the table shape the Nova provider also sets $resource::$model from the registry, so an application that swapped the model does not have to override the resource as well.
The menu
Each module has one section in each admin, named as the module. In it come the module's screens, then Settings (the module's settings page), then How it works last — the domain package's README, served by kitloom/modules-filament or kitloom/modules-nova at modules/{vendor}/{name}. A module puts none of its pages under the shared Settings section.
| Admin | Section | How it works |
|---|---|---|
| Filament | the plugin's GROUP; resources and pages return it from getNavigationGroup(); placed with MenuOrder::after() | Kitloom\WpSchema\Filament\Navigation\ModuleHelpItem::make('acme/reviews', ReviewsPlugin::GROUP, fn (): bool => …) — the closure says who sees it |
| Nova | the tool's menu() returns a MenuSection | Kitloom\WpSchema\Nova\Menu\ModuleHelpItem::make('acme/reviews') — null without the modules admin, so the generated menu filters it out |
The Nova tool is the sidebar section. The resource has $displayInNavigation = false; the tool lists it, Add New, Settings and How it works, each shown only to whoever may open it:
php
public function menu(Request $request)
{
return MenuSection::make(__('Reviews'), array_values(array_filter([
MenuItem::make(__('All Reviews'), '/resources/'.$this->resource::uriKey())
->canSee(fn (Request $request): bool => $this->resource::authorizedToViewAny($request)),
MenuItem::link(__('Add New Review'), '/resources/'.$this->resource::uriKey().'/new')
->canSee(fn (Request $request): bool => $this->resource::authorizedToCreate($request)),
MenuItem::link(__('Settings'), static::pageUrl(ReviewsSettingsFields::PATH))
->canSee(fn (): bool => Gate::allows(ReviewsSettingsFields::class)),
ModuleHelpItem::make('acme/reviews'),
])))
->icon('collection')
->collapsable();
}Resources
| Records | Filament resource extends | Nova resource extends |
|---|---|---|
| A table of the module's own | Filament\Resources\Resource, model from WpSchema::model(Review::KEY), canViewAny() asks Gate::allows(static::class) | Kitloom\WpSchema\Nova\Resources\WpResource with AsksTheGate |
| Posts of a type | Kitloom\WpSchema\Filament\Resources\PostTypeResource | Kitloom\WpSchema\Nova\Resources\PostResource with HasFormSections |
A post type resource brings the post's fields (featured image, title, slug, text, excerpt, status, date, author), the blocks other modules add to post forms (SEO, custom fields), the trash, and the post type's capabilities — edit_posts, publish_posts and the rest, as in WordPress. Its writes go through Kitloom\WpSchema\Content\PostWriter with the signed-in user. Fields of the module's own go into mainFields() with MetaField::bind() in Filament, and into fields() with the keys in $metaFields in Nova — see Your First Module.
Settings pages
php
use Kitloom\Settings\Filament\SettingsPage;
class ReviewsSettingsPage extends SettingsPage
{
protected static ?string $slug = 'reviews-settings';
protected static ?int $navigationSort = 9;
public static function getNavigationGroup(): ?string
{
return __(ReviewsPlugin::GROUP);
}
public static function getNavigationLabel(): string
{
return __('Settings');
}
protected function settingsFields(): array
{
return [
Section::make()->schema([
TextInput::make('reviews_per_page')->label(__('Per page'))->numeric()->minValue(1)->default(10),
]),
];
}
}php
use Kitloom\Settings\Nova\SettingsTool;
class Reviews extends SettingsTool
{
public function boot(): void
{
static::page(ReviewsSettingsFields::PATH, __('Reviews Settings'), fn () => (new ReviewsSettingsFields)->fields(), ReviewsSettingsFields::class);
}
// menu(): MenuItem::link(__('Settings'), static::pageUrl(ReviewsSettingsFields::PATH))
}A field's name is the option it is stored in, written by SettingWriter. Arrays are stored as JSON — see Data. The Filament page opens at /admin/reviews-settings, the Nova one at /nova/settings/reviews-settings.
Permissions
One permission per menu item, declared by the admin package and tagged Permission::PROVIDER_TAG in its register(). A resource or a page asks the Gate about itself — Gate::allows(static::class) — and the permission that covers it answers:
php
final class ReviewsPermissions implements PermissionProvider
{
public function permissions(): iterable
{
yield new Permission(
name: 'manage_reviews',
group: 'Reviews',
covers: [ReviewsSettingsPage::class],
label: 'Manage Reviews settings',
);
}
}A post type's records go by the type's capabilities, as in WordPress, so its permission covers the settings page only. A table module's permission covers its resource too. See Permissions.
Licenses
The generated packages say proprietary, as every kitloom package does. Set license to whatever you publish under.
Rules
These are the rules every kitloom package follows; the generated code already does.
The module plugs itself in. Installing it with Composer is enough: Laravel's package discovery, Panel::configureUsing() in the Filament provider, Nova::serving() in the Nova one, and a switch <config>.filament.register / <config>.nova.register to turn each admin off. If a site has to edit its own code or another package for your module to work, the module is not finished.
Packages talk through contracts and tags. Use another package's classes only if your composer.json requires it. To let others extend your module, expose an interface or a container tag and let the other side tag itself in — see Extension Points.
Every contract has a default. A module works on a bare skeleton: bind defaults with bindIf() / scopedIf() so a site or another module can replace them.
Models go through the registry. Admin resources and code read the model class from ModelRegistry, so an application can swap it. A model of wp_posts rows extends Kitloom\WpSchema\Models\Post, a model of term_taxonomy rows extends TermTaxonomy. To add a relation, attribute, scope or observer to a model you don't own, use WpSchema::extend() — not a subclass. See Data.
Queries see with the request's eyes. The global scopes ask Kitloom\WpSchema\Visibility: a visitor, the console, the queue and code that says nothing see what is published; the admin panels see everything; an API or an agent acting as a user says forUser($user). Writes go through Content\PostWriter and Content\TermWriter with the acting user — they make the slug unique, fill the dates, assign terms and refuse what the user may not do; without a user they write as the system.
No state between requests. kitloom runs under Octane. A cache that lives for one request is a scoped() binding; a singleton holds only what does not change after boot; no static properties that collect per-request data. A facade over a per-request service sets protected static $cached = false.
JSON in, WordPress formats read. New arrays in the database are JSON. Values WordPress wrote — serialize()d arrays, '1'/'0' — are read too, with Kitloom\WpSchema\Support\StoredArray, and become JSON on their next save. See Data.
WordPress names stay in storage. Tables, meta keys and option names follow WordPress and its plugins, so a migrated site keeps its data. The PHP API is named the Laravel way — $post->title, Settings::get() — not wp_* functions or get_post_meta() look-alikes. Meta goes through the model (getMeta(), setMeta()), custom fields through field() / setField() or the Fields facade. Facades live in <Vendor>\<Package>\Facades.
Table names get the prefix. In the schema builder and in raw SQL alike.
Few dependencies. Add a Composer or npm package only when there is no reasonable way without it, and never a GPL or commercial one in an MIT package.
declare(strict_types=1), comments that say why. In every PHP file. A docblock on the class says what it is for; a comment explains a WordPress quirk, an ordering constraint or a decision, not the next line. In English.
Checklist
- [ ] The domain package has no Filament or Nova imports.
- [ ]
extra.kitloom.moduleis declared in the domain package only. - [ ] Admin packages are named
<module>-filament/<module>-novaand require the domain package directly — that is how the Modules screen finds them. - [ ] The admins plug themselves in and have a
registerswitch. - [ ] Plugins tolerate being registered twice.
- [ ] One menu section per admin: the screens, Settings, then How it works.
- [ ] Every menu item has a declared permission.
- [ ] Models come from the registry; post and term models extend
Post/TermTaxonomy. - [ ] Writes go through the writers with the acting user.
- [ ] Every contract has a default binding.
- [ ] Arrays are written as JSON and WordPress formats are still read.
- [ ] The README explains the module to whoever configures it.
- [ ] Tests run on a bare skeleton.