Skip to content

Extension Points ​

Introduction ​

A module never edits another package or the application. It plugs in through four mechanisms:

MechanismHowUsed 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
EventsEvent::listen(Event::class, MyListener::class)taking part in a request: rewrite rules, the main query, templates, not-found pages
Page partsPageParts::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:

TagInterfaceMethod
Kitloom\WpSchema\PostType\PostTypeRegistry::PROVIDER_TAGKitloom\WpSchema\Contracts\PostTypeProviderInterfacepostTypes(): array — slugs
Kitloom\WpSchema\Taxonomy\TaxonomyRegistry::PROVIDER_TAGKitloom\WpSchema\Contracts\TaxonomyProviderInterfacetaxonomies(): array — slugs
Kitloom\WpSchema\Capabilities\Capabilities::PROVIDER_TAGKitloom\WpSchema\Contracts\CapabilityTypeProvidercapabilityType(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 ​

AdminTagInterface
FilamentKitloom\WpSchema\Filament\Tables\IndexColumns::TAGIndexColumn::column(string $type): ?Column
NovaKitloom\WpSchema\Nova\Columns\IndexColumns::TAGIndexColumn::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-*:

AdminContractMethodDefault
FilamentKitloom\WpSchema\Filament\Contracts\HtmlEditorcomponent(string $name): FieldTextareaHtmlEditor
NovaKitloom\WpSchema\Nova\Contracts\HtmlEditorfield(string $name, string $attribute): FieldCodeHtmlEditor

A resource of your own gets the site's editor the same way: app(HtmlEditor::class)->component('post_content').

Settings pages ​

AdminHow
Filamenta page extending Kitloom\Settings\Filament\SettingsPage, settingsFields() returns the fields
Nova, a section of its owna 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 Settingsa Kitloom\WpSchema\Nova\Contracts\SiteSettingsPage (path(), label(), fields()) tagged Kitloom\WpSchema\Nova\Tools\SiteSettings::PAGE_TAG
Nova, Settings → Generala 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\…)WhenWhat a listener may do
RewriteRulesBuildingthe rewrite rules are built$event->prepend($rule), $event->append($rule)
MainQueryResolvedthe main query has found its resultreplace $event->result
TemplateCandidatesthe template names are listed$event->candidates->prepend('shop'), before(), append(), remove()
PageNotFoundnothing lives at the addresslog 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,content names 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 $parts for a template (Vue themes get it as a prop named by key()). A part no module provides is left out.
  • key() and the toArray() 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) tagged Kitloom\Site\Content\WidgetTypes::TAG. A widget whose type nobody declares is not shown;
  • in the admin, a WidgetForm tagged WidgetForms::TAG — Kitloom\WpSchema\Filament\Widgets\… or Kitloom\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 ​

ContractMethodDefaultReplaced by
Kitloom\Site\Contracts\PrimaryTermstermId(int $postId, string $taxonomy): ?int — the category in permalinks and breadcrumbsthe lowest term id, as WordPresskitloom/seo: Rank Math's primary category
Kitloom\Site\Contracts\Imagesfind(array $attachmentIds): arrayWordPress attachments—
Kitloom\WpSchema\Contracts\SiteRolesnames(): array — role key => namenone: the {prefix}user_roles option is readkitloom/permissions
HtmlEditor (both admins)see abovetextarea / code editorkitloom/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.