Appearance
Your First Module
Introduction
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, a template in the theme, 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, and kitloom/modules, which carries the generator.
1. Generate the module
bash
php artisan kitloom:make-module Reviews --post-type=review --vendor=acmeIn an application whose composer.json has no path repository yet, it prints:
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 ReviewsRun the three commands it prints; Composer is not run for you. Both admin packages are written, but only the one your application runs is required — here Filament; in a Nova application it is acme/reviews-nova. The packages live 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 and a model in the registry. |
--vendor=acme | Composer names acme/reviews…, namespace Acme\Reviews, config acme-reviews. Default: kitloom. |
--filament, --nova | Only that admin. Without either, both are generated. |
--path=modules | Where the packages go. Default: the directory of a path repository with a wildcard in composer.json (../packages/*), otherwise packages/. |
The name is the plural (Reviews). The model is its singular, or the post type in studly case (Review). A name that is its own singular (Backup) is refused unless --post-type gives the model another name. A package that already exists is left as it is and only connected — run the same command in a second application to connect a module you generated in the first.
Open the admin: there is a Reviews section with All Reviews, Settings and, with the modules admin installed, How it works — the domain package's README. Nova adds Add New Review. Add a review, publish it, and open /reviews/its-slug/: the active theme shows it with its single-review template, or single, singular, index — the first one it has. /reviews/ lists the reviews with archive-review, archive or index.
What was generated:
text
packages/reviews/ the domain: no admin code
├── composer.json extra.kitloom.module puts it on the Modules screen
├── README.md the "How it works" page in the admin
├── 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
├── README.md
└── src/
├── ReviewsFilamentServiceProvider.php Panel::configureUsing — no edits in your panel
├── ReviewsPlugin.php the section: resource, settings, How it works
├── ReviewsPermissions.php the settings page's permission for the roles screen
├── Resources/ReviewResource.php + Pages/
└── Settings/ReviewsSettingsPage.php
packages/reviews-nova/ the same in Nova
├── README.md
└── src/
├── ReviewsNovaServiceProvider.php Nova::serving — no edits in your NovaServiceProvider
├── ReviewsNovaPermissions.php
├── Resources/ReviewResource.php
├── Settings/ReviewsSettingsFields.php
└── Tools/Reviews.php the sidebar sectionWriting a Module goes through each of these files.
2. A field of its own: the rating
A review's rating is 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
/** Post meta: the rating, a whole number. */
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
/**
* @return Attribute<int, never>
*/
protected function rating(): Attribute
{
return Attribute::get(fn (): int => $this->getMetaInt(Reviews::RATING) ?? 0);
}
}getMetaInt() gives null when the meta is missing or not a number. Its neighbours are getMeta(), getMetaBool() and getMetaArray(); setMeta() and deleteMeta() write — see Data.
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'))->integer()->minValue(1)->maxValue(10),
Reviews::RATING,
// An emptied field deletes the meta.
toStored: fn (mixed $value): ?string => blank($value) ? null : (string) $value,
),
];
}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 $metaFields = ['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)
->hideFromIndex(),
]),
];
}MetaField::bind() reads a meta key into any Filament field when the form opens and writes it after the record is saved; toStored turns the field's value into the stored text, and null deletes the meta. In Nova, $metaFields names the request keys the base resource saves as meta after the post is saved; fillUsing() keeps Nova from writing rating as a column of posts.
Custom fields instead
With kitloom/custom-fields installed, a field group made in the admin gives reviews a rating with no code in the resources: the model reads it with $review->field('rating'), code writes it with $review->setField('rating', 4, $user). The form blocks appear in both admins. See Data.
3. A setting: the rating scale
The generated settings page has one example field, reviews_per_page. Replace it with the scale the rating is out of.
php
protected function settingsFields(): array
{
return [
Section::make()->schema([
TextInput::make('reviews_out_of')
->label(__('Rating scale'))
->integer()
->minValue(1)
->default(5),
]),
];
}php
public function fields(): array
{
return [
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.
The page lives at Reviews → Settings: /admin/reviews-settings in Filament, /nova/settings/reviews-settings in Nova. Who may open it is the manage_reviews permission on the roles screen — administrators have it.
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;
use Kitloom\Site\Page\Schema\Shape;
/**
* The rating of the review a page shows.
*/
final readonly class ReviewDetails implements Part
{
public function __construct(public int $rating = 0, public int $outOf = 5) {}
public static function key(): string
{
return 'review';
}
public static function schema(): array
{
return Shape::object([
'rating' => Shape::integer('Stars given, 0 when the page shows no review.'),
'outOf' => Shape::integer(),
], description: 'The rating of the review a page shows.');
}
/**
* @return array{rating: int, outOf: int}
*/
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\PartProvider;
final readonly class ReviewDetailsProvider implements PartProvider
{
public function for(Page $page): ReviewDetails
{
$review = $page->result->object;
return new ReviewDetails(
$review instanceof Review ? $review->rating : 0,
(int) (Settings::get('reviews_out_of') ?: 5),
);
}
}$page->result is what the main query found: object is the review on its own page, and isSingle(), isArchive(), postType answer what WordPress's conditional tags answer. Records of the review type are read with the Review model, because ReviewsContentTypes names it.
Provide the part 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, static fn (PageParts $parts) => $parts->provide(
ReviewDetails::class,
ReviewDetailsProvider::class,
// Which JSON pages carry it without being asked: a review's own page.
json: static fn (Page $page): bool => $page->result->isSingle() && $page->result->postType === Reviews::POST_TYPE,
));The domain package now uses kitloom/settings too — add "kitloom/settings": "^1.0" to the require of 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",
"url": "https://example.test/reviews/dune/",
"content": { "title": "Dune", "...": "..." },
"review": { "rating": 4, "outOf": 5 },
"...": "the other parts"
}template is the template the active theme chose, null without a theme. ?include=review,content asks for those parts only.
5. A template in the theme
Templates are named by the WordPress template hierarchy, so a theme gives reviews a page of their own with a single-review template. It takes the part by class (Blade) or as a prop named by key() (Vue).
blade
@extends('layouts.app')
@section('content')
@php($content = $page->part(\Kitloom\Site\Page\Parts\Content::class))
<article>
<h1>{{ $content->title }}</h1>
@if ($page->has(\Acme\Reviews\Site\ReviewDetails::class))
@php($review = $page->part(\Acme\Reviews\Site\ReviewDetails::class))
<p class="rating">{{ $review->rating }} / {{ $review->outOf }}</p>
@endif
@shortcodes($content->content)
</article>
@endsectionvue
<script setup>
defineProps({
content: { type: Object, default: () => ({}) },
review: { type: Object, default: () => ({}) },
});
</script>
<template>
<article>
<h1>{{ content.title }}</h1>
<p class="rating">{{ review.rating }} / {{ review.outOf }}</p>
<div v-html="content.content"></div>
</article>
</template>$page->has() keeps the Blade template working on a site without the module: asking for a part nobody provides throws. A Vue theme lists the parts each template gets in its service provider; a part of a module the site does not have is left out:
php
protected array $parts = [
'*' => [Content::class /* , … */],
'single-review' => [ReviewDetails::class], // the `review` prop of single-review.vue
];A Vue theme that lists no parts gets the ones a JSON page has, review included. See Templates.
6. 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_kept_in_post_meta(): void
{
$id = $this->insertPost('dune', Reviews::POST_TYPE);
Review::query()->findOrFail($id)->setMeta(Reviews::RATING, 4);
$this->assertSame('4', $this->db()->table('postmeta')->where('post_id', $id)->value('meta_value'));
$this->assertSame(4, Review::query()->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, in a section of their own with the settings page and How it works, and a permission on the roles screen.
- 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.