Appearance
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=acmetext
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 ReviewsRun the three commands it prints. The packages are in packages/, and Composer links them into vendor/ — edit them in packages/.
| Option | What it does |
|---|---|
--post-type=review | the 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=acme | Composer names acme/reviews…, namespace Acme\Reviews. Default: kitloom |
--filament, --nova | only that admin; by default both are generated and the one your application runs is required |
--path=modules | where 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 NovaWriting 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 ReviewsTesting 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
- Writing a Module — what each generated file does, and the rules a module follows.
- Extension Points — everything a module can hook into.
- Data — meta, options, how arrays are stored, swapping models.
- Publishing a Module — Composer, the license, the Modules screen.