Skip to content

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 Nova

A 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 domain package ​

composer.json ​

json
{
    "name": "acme/reviews",
    "description": "Reviews as posts of the `review` type, with addresses of their own.",
    "license": "MIT",
    "type": "library",
    "require": {
        "illuminate/database": "^11.0|^12.0",
        "illuminate/support": "^11.0|^12.0",
        "kitloom/site": "^1.0",
        "kitloom/wp-schema": "^1.0",
        "php": "^8.2"
    },
    "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."
            }
        }
    }
}
  • 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. name is required, description falls back to the package's.
  • require is the dependency graph the Modules screen reads too: while your module is on, the modules it requires cannot be switched off.

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 ​

Generated for a module whose records live in a table of its own:

php
class ReviewsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/acme-reviews.php', 'acme-reviews');
    }

    public function boot(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');

        $this->registerModels();

        if ($this->app->runningInConsole()) {
            $this->publishes([__DIR__.'/../config/acme-reviews.php' => config_path('acme-reviews.php')], 'acme-reviews-config');
        }
    }

    /**
     * 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));
        }
    }
}

With --post-type, the provider tags two type providers instead — see Post types and taxonomies:

php
$this->app->tag([ReviewsContentTypes::class], ContentTypes::TAG);
$this->app->tag([ReviewsPostTypes::class], PostTypeRegistry::PROVIDER_TAG);

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).

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:

php
$query->selectRaw('max('.DB::getQueryGrammar()->wrap('reviews.updated_at').') as updated');

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
    {
        $this->app->tag([ReviewsPermissions::class], Permission::PROVIDER_TAG);

        $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]);
    }
}

The Nova package registers inside Nova::serving() and steps aside when Nova is not installed:

php
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)]);
    });
}

The tool is the sidebar section: the list, Add New, and the settings page, each shown only to whoever may open it.

Resources ​

RecordsFilament resource extendsNova resource extends
A table of the module's ownFilament\Resources\Resource, model from WpSchema::model(Review::KEY)Kitloom\WpSchema\Nova\Resources\WpResource with AsksTheGate
Posts of a typeKitloom\WpSchema\Filament\Resources\PostTypeResourceKitloom\WpSchema\Nova\Resources\PostResource with HasFormSections

A post type resource brings the post's fields (title, slug, text, excerpt, status, date, author), the blocks other modules add to post forms (SEO, custom fields), and the post type's capabilities — edit_posts, publish_posts and the rest, as in WordPress.

Settings pages ​

php
use Kitloom\Settings\Filament\SettingsPage;

class ReviewsSettingsPage extends SettingsPage
{
    protected static ?string $slug = 'reviews-settings';

    protected function settingsFields(): array
    {
        return [
            TextInput::make('reviews_out_of')->numeric()->default(5),
        ];
    }
}
php
use Kitloom\Settings\Nova\SettingsTool;

class Reviews extends SettingsTool
{
    public function boot(): void
    {
        static::page('reviews-settings', __('Reviews Settings'), fn () => (new ReviewsSettingsFields)->fields(), ReviewsSettingsFields::class);
    }

    // menu(): MenuItem::link(__('Settings'), static::pageUrl('reviews-settings'))
}

A field's name is the option it is stored in. Arrays are stored as JSON — see Data.

Permissions ​

One permission per menu item, declared by the admin package and tagged Permission::PROVIDER_TAG. A resource or a page asks the Gate about itself — Gate::allows(static::class) — and the permission that covers it answers. See Permissions.

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. If a site has to edit its own code for your module to work, the module is not finished. Give a site switches (*.filament.register, *.nova.register) and contracts to rebind, not instructions.

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 collect what is tagged — see Extension Points.

Every contract has a default. A module works on a bare skeleton: bind defaults with bindIf() / singletonIf() so a site or another module can replace them.

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.

JSON in, WordPress formats read. New arrays in the database are JSON. Values WordPress wrote — serialize()d arrays, '1'/'0' — are read too, 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: no wp_* functions, no get_post_meta() look-alikes in public classes.

declare(strict_types=1), comments that say why. A docblock on the class says what it is for; a comment explains a WordPress quirk or a decision, not the next line.

Checklist ​

  • [ ] The domain package has no Filament or Nova imports.
  • [ ] extra.kitloom.module is declared in the domain package only.
  • [ ] Admin packages are named <module>-filament / <module>-nova and require the domain package directly — that is how the Modules screen finds them.
  • [ ] The admins plug themselves in and have a register switch.
  • [ ] Plugins tolerate being registered twice.
  • [ ] Every menu item has a declared permission.
  • [ ] Models are registered only when the key is free; the admins read the class from the registry.
  • [ ] Every contract has a default binding.
  • [ ] Arrays are written as JSON and WordPress formats are still read.
  • [ ] Tests run on a bare skeleton.