Appearance
Extension Points
Introduction
A module never edits another package or the application. It plugs in through four mechanisms:
| Mechanism | How | Used for |
|---|---|---|
| Tags | $this->app->tag([MyThing::class], Registry::TAG) | offering something to a registry: a post type, a form block, a permission, a field type |
| Contracts | $this->app->bind(Contract::class, MyImplementation::class) | replacing how something is done: the HTML editor, the primary category |
| Events | Event::listen(Event::class, MyListener::class) | taking part in a request: rewrite rules, the main query, templates, not-found pages |
| Page parts | PageParts::provide(Part::class, Provider::class) | giving themes data to show |
Tag in your provider's register() — several collectors are singletons, and a tag added after they are resolved is not seen. Everything tagged is resolved through the container, so constructors can ask for dependencies.
Post types and taxonomies
A post type or a taxonomy is declared twice, for two audiences.
For the site — addresses, archive, the model records are read with — tag a Kitloom\Site\Contracts\ContentTypeProvider with ContentTypes::TAG:
php
use Kitloom\Site\Contracts\ContentTypeProvider;
use Kitloom\Site\Types\PostTypeObject;
use Kitloom\Site\Types\TaxonomyObject;
final readonly class BookTypes implements ContentTypeProvider
{
public function postTypes(): iterable
{
yield new PostTypeObject('book', rewriteSlug: 'books', withFront: false, hasArchive: true, label: 'Books', model: Book::class);
}
public function taxonomies(): iterable
{
yield new TaxonomyObject('genre', rewriteSlug: 'genres', hierarchical: true, hierarchicalPaths: true);
}
}
$this->app->tag([BookTypes::class], \Kitloom\Site\Types\ContentTypes::TAG);The fields follow register_post_type() and register_taxonomy(): public, hierarchical, rewriteSlug (rewrite.slug), withFront (rewrite.with_front), hasArchive, excludeFromSearch, label (labels.name). A later provider replaces an earlier one's type of the same name; the application overrides any of them in config('kitloom-site.types').
For the registries the admin, roles and relations ask — a list of names:
| Tag | Interface | Method |
|---|---|---|
Kitloom\WpSchema\PostType\PostTypeRegistry::PROVIDER_TAG | Kitloom\WpSchema\Contracts\PostTypeProviderInterface | postTypes(): array — slugs |
Kitloom\WpSchema\Taxonomy\TaxonomyRegistry::PROVIDER_TAG | Kitloom\WpSchema\Contracts\TaxonomyProviderInterface | taxonomies(): array — slugs |
Kitloom\WpSchema\Capabilities\Capabilities::PROVIDER_TAG | Kitloom\WpSchema\Contracts\CapabilityTypeProvider | capabilityType(string $postType): ?array — ['book', 'books'] gives edit_books, publish_books…; taxonomyCapabilities(string $taxonomy): ?array |
The two interfaces both have a postTypes() method with different return types, so they are two classes — kitloom:make-module --post-type generates both.
A theme or a module that reads someone else's type with a model of its own tags a Kitloom\Site\Contracts\TypeModels (models(): array — type => model class) with ContentTypes::MODELS_TAG.
The admin
Blocks on the post form
Modules put blocks on the forms of posts, pages, terms and users — SEO, custom fields and the featured image all come this way. In Filament a block is a collapsible section below the main fields; in Nova it is a tab.
php
use Kitloom\WpSchema\Filament\Forms\FormSection;
use Kitloom\WpSchema\Filament\Forms\FormSections;
use Kitloom\WpSchema\Filament\Forms\MetaField;
use Kitloom\WpSchema\Filament\Resources\PostTypeResource;
final class ReadingTimeSection implements FormSection
{
public function appliesTo(string $resource): bool
{
return is_a($resource, PostTypeResource::class, true);
}
public function title(): string
{
return __('Reading time');
}
public function components(string $resource): array
{
return [
MetaField::bind(TextInput::make('reading_time')->numeric()->suffix('min'), '_reading_time'),
];
}
}
$this->app->tag([ReadingTimeSection::class], FormSections::TAG);php
use Kitloom\WpSchema\Nova\Forms\FormSection;
use Kitloom\WpSchema\Nova\Forms\FormSections;
final class ReadingTimeSection implements FormSection
{
public function appliesTo(string $resource): bool { /* … */ }
public function title(): string { return __('Reading time'); }
public function fields(NovaRequest $request, Model $model): array
{
return [Number::make(__('Minutes'), 'reading_time')];
}
/** Request key => meta key, saved after the record. */
public function metaKeys(Model $model): array
{
return ['reading_time' => '_reading_time'];
}
/** null: not mine, the resource stores it as is. */
public function prepareMetaValue(string $metaKey, mixed $value): ?string { return null; }
/** true: stored it myself. */
public function saveMetaValue(NovaRequest $request, Model $model, string $metaKey, string $requestKey): bool { return false; }
}
$this->app->tag([ReadingTimeSection::class], FormSections::TAG);An empty components() / fields() means no block for that record.
Columns in the lists
| Admin | Tag | Interface |
|---|---|---|
| Filament | Kitloom\WpSchema\Filament\Tables\IndexColumns::TAG | IndexColumn::column(string $type): ?Column |
| Nova | Kitloom\WpSchema\Nova\Columns\IndexColumns::TAG | IndexColumn::field(string $type, Resource $resource): ?Field |
$type is the post type or taxonomy of the list; null leaves it alone. The link counts in the posts list are a column like this.
The HTML editor
The post text and WYSIWYG custom fields are edited by whatever is bound to the HtmlEditor contract — a textarea or code editor by default, TinyMCE with kitloom/visual-editor-*:
| Admin | Contract | Method | Default |
|---|---|---|---|
| Filament | Kitloom\WpSchema\Filament\Contracts\HtmlEditor | component(string $name): Field | TextareaHtmlEditor |
| Nova | Kitloom\WpSchema\Nova\Contracts\HtmlEditor | field(string $name, string $attribute): Field | CodeHtmlEditor |
A resource of your own gets the site's editor the same way: app(HtmlEditor::class)->component('post_content').
Settings pages
| Admin | How |
|---|---|
| Filament | a page extending Kitloom\Settings\Filament\SettingsPage, settingsFields() returns the fields |
| Nova, a section of its own | a tool extending Kitloom\Settings\Nova\SettingsTool: static::page($path, $label, fn () => $fields, $gateSection) in boot(), a link to static::pageUrl($path) in menu() |
| Nova, under Settings | a Kitloom\WpSchema\Nova\Contracts\SiteSettingsPage (path(), label(), fields()) tagged Kitloom\WpSchema\Nova\Tools\SiteSettings::PAGE_TAG |
| Nova, Settings → General | a Kitloom\WpSchema\Nova\Contracts\GeneralSettingsProvider (generalSettingsFields()) tagged Kitloom\WpSchema\Nova\Settings\GeneralSettings::PROVIDER_TAG — its fields go first |
A field is the option named by its attribute. See Data.
Permissions
One Kitloom\Access\Permission per menu item, from a Kitloom\Access\Contracts\PermissionProvider tagged Permission::PROVIDER_TAG; the page or resource asks Gate::allows(static::class). See Permissions.
The menu
Filament — sections follow WordPress: Posts, Media, Pages, Comments, Appearance, Users, Tools, Settings, then Contact, SEO, Custom Fields. Put yours after one of them:
php
use Kitloom\WpSchema\Filament\MenuOrder;
$this->app->afterResolving(MenuOrder::class, fn (MenuOrder $order) => $order->after('Reviews', 'Comments'));Nova — the sidebar order is the application's data, kept by the admin menu screen. A module adds its section; where it sits is changed on that screen or by a migration of the application. See Nova: the sidebar.
The site
Events
Event (Kitloom\Site\Events\…) | When | What a listener may do |
|---|---|---|
RewriteRulesBuilding | the rewrite rules are built | $event->prepend($rule), $event->append($rule) |
MainQueryResolved | the main query has found its result | replace $event->result |
TemplateCandidates | the template names are listed | $event->candidates->prepend('shop'), before(), append(), remove() |
PageNotFound | nothing lives at the address | log it, redirect from it |
A rule is a pattern for the path without slashes at either end, and the query vars it sets; $1, $2 are the pattern's groups:
php
use Kitloom\Site\Events\RewriteRulesBuilding;
use Kitloom\Site\Rewrite\RewriteRule;
Event::listen(RewriteRulesBuilding::class, fn (RewriteRulesBuilding $event) => $event->prepend(
new RewriteRule('books/(.+)', ['name' => '$1']),
));The template hierarchy:
php
use Kitloom\Site\Events\TemplateCandidates;
Event::listen(TemplateCandidates::class, function (TemplateCandidates $event): void {
if ($event->result->isSingle() && $event->result->postType === 'book') {
$event->candidates->prepend('book'); // before single-book, single, singular, index
}
});The main query — the shop answers 404 for its pages while the shop is off:
php
use Kitloom\Site\Events\MainQueryResolved;
use Kitloom\Site\Query\QueryResult;
Event::listen(MainQueryResolved::class, function (MainQueryResolved $event): void {
if ($this->shouldHide($event->result)) {
$event->result = QueryResult::notFound($event->result->vars);
}
});QueryResult answers what WordPress's conditional tags answer — isSingle(), isPage(), isArchive(), isTax(), isSearch(), isNotFound() and the rest — and carries object (the post, term or author), posts, postType, taxonomy.
Page parts
What a template shows comes in parts, each worked out only when the template asks for it. A module provides a part from its provider:
php
use Kitloom\Site\Page\Page;
use Kitloom\Site\Page\PageParts;
$this->callAfterResolving(PageParts::class, fn (PageParts $parts) => $parts->provide(
ReviewDetails::class, // implements Kitloom\Site\Page\Part: key(), toArray()
ReviewDetailsProvider::class, // implements PartProvider: for(Page $page): Part — or a closure
json: fn (Page $page): bool => $page->result->isSingle(),
));json— whether a JSON page carries the part when the request does not name parts (?include=review,contentnames them).true,false, or a closure deciding per page.- A provider that needs another part asks the page for it:
$page->part(Content::class). - A theme takes it by class (Blade) or lists it in its
$partsfor a template (Vue themes get it as a prop named bykey()). A part no module provides is left out. key()and thetoArray()keys are a contract with theme authors: keep them stable.
Your First Module builds one from start to end.
Content filters
Kitloom\Site\Content\ContentFilters is the_content: named filters applied to a post's text before a theme gets it.
php
use Kitloom\Site\Content\ContentFilters;
$this->callAfterResolving(ContentFilters::class, fn (ContentFilters $filters) => $filters->add('emoji', EmojiFilter::class));A filter implements Kitloom\Site\Content\ContentFilter::filter(string $content, Page $page, ?Model $post = null): string, or is a closure. The order is config('kitloom-site.content_filters'), then the rest.
Template variables
Rank Math's variables — %title% %sep% %sitename%, %customterm(genre)% — are replaced in titles and descriptions. A Kitloom\Site\Contracts\VariableSource tagged Kitloom\Site\Content\Variables::TAG is asked first:
php
public function value(string $name, ?string $argument, ?Model $object, ?Page $page = null): ?string
{
return $name === 'rating' && $object instanceof Review ? (string) $object->rating : null;
}Widget types
Widgets live where WordPress keeps them — sidebars_widgets and widget_{type} options. A widget type of your own needs:
- on the site, a
Kitloom\Site\Contracts\WidgetType(base(): string,markup(Widget $widget): ?string) taggedKitloom\Site\Content\WidgetTypes::TAG. A widget whose type nobody declares is not shown; - in the admin, a
WidgetFormtaggedWidgetForms::TAG—Kitloom\WpSchema\Filament\Widgets\…orKitloom\WpSchema\Nova\Widgets\…. In Nova a widget without a form is shown read-only.
The page cache
Pages are cached for guests. A module whose pages differ per visitor keeps them out:
php
use Kitloom\Site\Cache\PageCache;
$this->callAfterResolving(PageCache::class, fn (PageCache $cache) => $cache->bypassWhen(
fn (Request $request): bool => $request->session()->has('basket'),
));Contracts with a default
| Contract | Method | Default | Replaced by |
|---|---|---|---|
Kitloom\Site\Contracts\PrimaryTerms | termId(int $postId, string $taxonomy): ?int — the category in permalinks and breadcrumbs | the lowest term id, as WordPress | kitloom/seo: Rank Math's primary category |
Kitloom\Site\Contracts\Images | find(array $attachmentIds): array | WordPress attachments | — |
Kitloom\WpSchema\Contracts\SiteRoles | names(): array — role key => name | none: the {prefix}user_roles option is read | kitloom/permissions |
HtmlEditor (both admins) | see above | textarea / code editor | kitloom/visual-editor-* |
To replace one, bind() your class in a provider. To offer a contract of your own, bind its default with bindIf() so others can replace it.
Custom field types
kitloom/custom-fields has the 37 ACF field types. A type of your own implements Kitloom\CustomFields\Types\FieldType — name(), label(), category(), defaults(), settings(), storage(Field $field), offered() — and is tagged Kitloom\CustomFields\Types\FieldTypes::TAG; a type with the name of a built-in replaces it. Kitloom\CustomFields\Types\BuiltInType shows how the built-in ones are made.
Any model that implements Kitloom\WpSchema\Contracts\HasCustomFields — posts, terms, users — gets the field blocks in both admins and $model->acf_{name} attributes.