Skip to content

Your First Module ​

Introduction ​

In about fifteen minutes this page builds Reviews: book reviews with a rating, edited in the admin, shown at /reviews/{slug}/ with an archive at /reviews/, a setting for the rating scale, and a test. Every step is code you keep — nothing here is thrown away later.

You need an application with kitloom installed — one of the skeletons is enough — with either the Filament or the Nova admin.

1. Generate the module ​

bash
php artisan kitloom:make-module Reviews --post-type=review --vendor=acme
text
  acme/reviews .................................................. created
  acme/reviews-filament ......................................... created
  acme/reviews-nova ............................................. created
  composer.json: path repository packages/* ........................ done
  composer.json: require acme/reviews ............................... done
  composer.json: require acme/reviews-filament ...................... done
  composer.json: autoload-dev Acme\Reviews\Tests\ ................... done
  phpunit.xml: testsuite Reviews .................................... done

  Next:
  composer update acme/reviews acme/reviews-filament
  php artisan migrate
  php vendor/bin/phpunit --testsuite Reviews

Run the three commands it prints. The packages are in packages/, and Composer links them into vendor/ — edit them in packages/.

OptionWhat it does
--post-type=reviewthe records are WordPress posts of the review type, with pages of their own. Without it they are rows of a table of the module's own (reviews), with a migration
--vendor=acmeComposer names acme/reviews…, namespace Acme\Reviews. Default: kitloom
--filament, --novaonly that admin; by default both are generated and the one your application runs is required
--path=moduleswhere the packages go. Default: the directory of a path repository in composer.json (../packages/*), otherwise packages/

Open the admin: there is a Reviews section with All Reviews and Settings. Add a review, publish it, and open /reviews/its-slug/ — the active theme shows it with its single template, and /reviews/ lists the reviews.

What was generated:

text
packages/reviews/                         the domain: no admin code
├── composer.json                         extra.kitloom.module puts it on the Modules screen
├── config/acme-reviews.php               filament.register, nova.register
├── src/
│   ├── Reviews.php                       POST_TYPE, SLUG
│   ├── ReviewsServiceProvider.php        tags the types, merges the config
│   ├── Models/Review.php                 a Post of the review type
│   └── Types/
│       ├── ReviewsContentTypes.php       the type for the site: addresses, archive, model
│       └── ReviewsPostTypes.php          the type for the registries the admin and roles ask
└── tests/
    ├── ReviewsTestCase.php               WordPress tables in SQLite
    └── ReviewTest.php
packages/reviews-filament/                Reviews in Filament
├── src/
│   ├── ReviewsFilamentServiceProvider.php    Panel::configureUsing — no edits in your panel
│   ├── ReviewsPlugin.php
│   ├── ReviewsPermissions.php            the section's permission for the roles screen
│   ├── Resources/ReviewResource.php      + Pages/
│   └── Settings/ReviewsSettingsPage.php
packages/reviews-nova/                    the same in Nova

Writing a Module goes through each of these files.

2. A field of its own: the rating ​

A review's rating is a custom field — post meta under the key rating, exactly where a WordPress plugin would keep it.

Name the key once, in packages/reviews/src/Reviews.php:

php
public const RATING = 'rating';

Read it on the model, packages/reviews/src/Models/Review.php:

php
use Illuminate\Database\Eloquent\Casts\Attribute;

/**
 * @property-read int $rating
 */
class Review extends Post
{
    // booted() as generated

    protected function rating(): Attribute
    {
        return Attribute::get(fn (): int => (int) $this->getMetaValue(Reviews::RATING));
    }
}

Now edit it in the admin.

php
use Acme\Reviews\Reviews;
use Filament\Forms\Components\TextInput;
use Kitloom\WpSchema\Filament\Forms\MetaField;

/**
 * The post's own fields, then the rating.
 */
protected static function mainFields(): array
{
    return [
        ...parent::mainFields(),
        MetaField::bind(
            TextInput::make('rating')->label(__('Rating'))->numeric()->minValue(1)->maxValue(10),
            Reviews::RATING,
        ),
    ];
}
php
use Acme\Reviews\Models\Review;
use Acme\Reviews\Reviews;
use Laravel\Nova\Fields\Number;

/**
 * Request key => meta key: the base resource writes them after the post is saved.
 *
 * @var array<string, string>
 */
protected static array $postMeta = ['rating' => Reviews::RATING];

public function fields(NovaRequest $request)
{
    return [
        $this->withFormSections($request, [
            ...parent::fields($request),
            app(HtmlEditor::class)->field(__('Content'), 'post_content')->fullWidth()->hideFromIndex(),
            Number::make(__('Rating'), 'rating')->min(1)->max(10)
                ->resolveUsing(fn ($value, Review $review): int => $review->rating)
                ->fillUsing(static fn () => null),
        ]),
    ];
}

MetaField::bind() reads and writes a meta key for any Filament field, and deletes the meta when the field is emptied. In Nova, $postMeta says which request keys are saved as meta; fillUsing() keeps Nova from writing rating as a column of posts.

3. A setting: the rating scale ​

The generated settings page has one example field, reviews_per_page. Make it the scale the rating is out of.

php
TextInput::make('reviews_out_of')
    ->label(__('Rating scale'))
    ->numeric()
    ->minValue(1)
    ->default(5),
php
Number::make(__('Rating scale'), 'reviews_out_of')
    ->min(1)
    ->default(5)
    ->rules('nullable', 'integer', 'min:1'),

Each field is a row of the options table named as the field — wp_options on a site moved from WordPress. The site reads it with Settings::get('reviews_out_of'). See Data.

Settings lives at Reviews → Settings: /admin/reviews-settings in Filament, /nova/settings/reviews-settings in Nova.

4. Show it on the site: a page part ​

Themes get what a page shows as parts — the content, the menus, the head meta. A part is worked out only when a template asks for it. Give themes the rating as a part of its own.

packages/reviews/src/Site/ReviewDetails.php — the shape themes rely on:

php
<?php

declare(strict_types=1);

namespace Acme\Reviews\Site;

use Kitloom\Site\Page\Part;

/**
 * The rating of the review a page shows.
 */
final readonly class ReviewDetails implements Part
{
    public function __construct(public int $rating, public int $outOf) {}

    public static function key(): string
    {
        return 'review';
    }

    public function toArray(): array
    {
        return ['rating' => $this->rating, 'outOf' => $this->outOf];
    }
}

packages/reviews/src/Site/ReviewDetailsProvider.php — how it is worked out:

php
<?php

declare(strict_types=1);

namespace Acme\Reviews\Site;

use Acme\Reviews\Models\Review;
use Kitloom\Settings\Facades\Settings;
use Kitloom\Site\Page\Page;
use Kitloom\Site\Page\Part;
use Kitloom\Site\Page\PartProvider;

final readonly class ReviewDetailsProvider implements PartProvider
{
    public function for(Page $page): Part
    {
        $review = $page->result->object;

        return new ReviewDetails(
            $review instanceof Review ? $review->rating : 0,
            (int) (Settings::get('reviews_out_of') ?: 5),
        );
    }
}

Provide it in ReviewsServiceProvider::boot():

php
use Acme\Reviews\Site\ReviewDetails;
use Acme\Reviews\Site\ReviewDetailsProvider;
use Kitloom\Site\Page\Page;
use Kitloom\Site\Page\PageParts;

$this->callAfterResolving(PageParts::class, fn (PageParts $parts) => $parts->provide(
    ReviewDetails::class,
    ReviewDetailsProvider::class,
    // Which JSON pages carry it: a review's own page.
    json: fn (Page $page): bool => $page->result->isSingle() && $page->result->postType === Reviews::POST_TYPE,
));

The domain package now requires kitloom/settings too — add "kitloom/settings": "@dev" (or the version you build against) to its composer.json.

Ask a review page for JSON to see it:

bash
curl -H 'Accept: application/json' https://example.test/reviews/dune/
json
{
    "kind": "single",
    "template": "single",
    "review": { "rating": 4, "outOf": 5 },
    "...": "the other parts"
}

A theme uses it by name:

blade
@php($review = $page->part(\Acme\Reviews\Site\ReviewDetails::class))
<p class="rating">{{ $review->rating }} / {{ $review->outOf }}</p>
php
protected array $parts = [
    // …
    'single-review' => [ReviewDetails::class],   // the `review` prop of single-review.vue
];

5. A test ​

The generated ReviewsTestCase has WordPress's tables in an in-memory SQLite database. Add to packages/reviews/tests/ReviewTest.php:

php
#[Test]
public function the_rating_is_read_from_post_meta(): void
{
    $id = $this->insertPost('dune', Reviews::POST_TYPE);
    $this->db()->table('postmeta')->insert(['post_id' => $id, 'meta_key' => Reviews::RATING, 'meta_value' => '4']);

    $this->assertSame(4, Review::query()->withoutGlobalScope(PostPublishedScope::class)->findOrFail($id)->rating);
}
bash
php vendor/bin/phpunit --testsuite Reviews

Testing shows tests of the admin screens and of the site pages.

What you have ​

  • Reviews in the admin, with a permission on the roles screen and a place in the menu.
  • Pages at /reviews/{slug}/ and /reviews/, rendered by whatever theme is active, and the same as JSON.
  • A setting stored where WordPress keeps options.
  • The module on the Modules screen — it can be switched off, and its admin goes with it.
  • Tests.

Nothing in your application was edited by hand: the module plugs itself in.

Next ​