Appearance
Extension Points
Introduction
A module never edits another package or the application. It plugs in through a handful of mechanisms:
| Mechanism | How | Used for |
|---|---|---|
| Tags | $this->app->tag([MyThing::class], Registry::TAG) | offering something to a collector: a post type, a form block, a permission, a widget type, an agent tool |
| Contracts | $this->app->bind(Contract::class, MyImplementation::class) | replacing how something is done: the HTML editor, the primary category, the search |
| Events | Event::listen(Event::class, MyListener::class) | taking part in a request or reacting to a change: rewrite rules, the main query, a saved post, a saved option |
| Collectors | $this->callAfterResolving(PageParts::class, fn (PageParts $parts) => $parts->provide(…)) | adding to a service once it exists: page parts, content filters, shortcodes, the page cache |
| Model extensions | WpSchema::extend('post', fn (ModelExtension $post) => …) | relations, attributes, scopes and observers on models you don't own |
Tag in your provider's register(): several collectors are singletons, and a tag added after they are resolved is not seen. Everything tagged is made by the container, so constructors can ask for dependencies. Give the application a switch for what you offer — kitloom-<name>.shortcodes.register, kitloom-<name>.ai.register — so it can be turned off without touching your code.
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\ContentTypes;
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', taxonomies: ['genre'], model: Book::class);
}
public function taxonomies(): iterable
{
yield new TaxonomyObject('genre', rewriteSlug: 'genres', hierarchical: true, hierarchicalPaths: true);
}
}
$this->app->tag([BookTypes::class], ContentTypes::TAG);The fields follow register_post_type() and register_taxonomy(): public, hierarchical, rewriteSlug (rewrite.slug), withFront (rewrite.with_front), hasArchive, excludeFromSearch, label and singularLabel (labels), supports, taxonomies / objectTypes, showInRest, restBase. model is the Eloquent model the records are read with: it must extend Kitloom\WpSchema\Models\Post (a taxonomy's, TermTaxonomy), or the types fail as soon as they are collected. 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 |
ContentTypeProvider::postTypes() and PostTypeProviderInterface::postTypes() have 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. It is applied after every type is known, whatever order the providers come in.
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 section below the main fields; in Nova it is a tab.
php
use Filament\Forms\Components\TextInput;
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')->integer()->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);Each admin has its own FormSections (Kitloom\WpSchema\Filament\Forms\…, Kitloom\WpSchema\Nova\Forms\…). An empty components() / fields() means no block for that record. MetaField::bind() reads and writes a meta key for any Filament field; null from its toStored closure deletes the meta.
Columns in the lists
| Admin | Tag | Interface |
|---|---|---|
| Filament | Kitloom\WpSchema\Filament\Tables\IndexColumns::TAG | Kitloom\WpSchema\Filament\Tables\IndexColumn::column(string $type): ?Column |
| Nova | Kitloom\WpSchema\Nova\Columns\IndexColumns::TAG | Kitloom\WpSchema\Nova\Columns\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 plain editor by default, TinyMCE with kitloom/visual-editor-filament / kitloom/visual-editor-nova:
| 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') in Filament, app(HtmlEditor::class)->field(__('Content'), 'post_content') in Nova.
The attachment picker
Every field that picks files from the media library — a featured image, a category's thumbnail, image and gallery custom fields, the image widget — is made by the AttachmentPicker contract. Its state is attachment ids, as WordPress keeps them in meta.
| Admin | Contract | Method | Default |
|---|---|---|---|
| Filament | Kitloom\WpSchema\Filament\Contracts\AttachmentPicker | component(string $name, bool $multiple = false, bool $imagesOnly = true): Field | SelectAttachmentPicker, a searchable list |
| Nova | Kitloom\WpSchema\Nova\Contracts\AttachmentPicker | field(string $name, string $attribute, bool $multiple = false, bool $imagesOnly = true): Field | SelectAttachmentPicker |
kitloom/media-filament and kitloom/media-nova bind the library window with previews. A field of your own asks the contract, so it gets whichever is installed.
Settings pages
| Admin | How |
|---|---|
| Filament | a page extending Kitloom\Settings\Filament\SettingsPage; settingsFields() returns the fields; it goes in the module's own navigation group |
| Nova, the module's section | a tool extending Kitloom\Settings\Nova\SettingsTool: static::page($path, $label, fn () => $fields, $section) in boot(), a link to static::pageUrl($path) in menu() |
| Nova, Settings → General | a Kitloom\WpSchema\Nova\Contracts\GeneralSettingsProvider (generalSettingsFields()) tagged Kitloom\WpSchema\Nova\Settings\GeneralSettings::PROVIDER_TAG — its fields go first |
| Nova, a page of the core Settings section | a Kitloom\WpSchema\Nova\Contracts\SiteSettingsPage (path(), label(), fields()) tagged Kitloom\WpSchema\Nova\Tools\SiteSettings::PAGE_TAG |
A field is the option named by its attribute, written by SettingWriter. See Data. A module's own settings go in its own section, not under the shared Settings — see the menu.
Permissions
One Kitloom\Access\Permission per menu item, from a Kitloom\Access\Contracts\PermissionProvider tagged Permission::PROVIDER_TAG in the admin package's register(); 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 — a module's tool returns its MenuSection. Where it sits, and how the sidebar is arranged, is changed on the admin menu screen (kitloom/admin-menu-nova) or by a migration of the application. See Nova: the sidebar.
In both admins the module's section ends with How it works, its README — ModuleHelpItem from kitloom/wp-schema-filament or kitloom/wp-schema-nova. See Writing a Module.
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 ($event->request, $event->result) |
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 — a page the module hides answers 404:
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's boot():
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(), schema(), 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). The order of the work follows from these calls. - A Blade theme takes a part by class,
$page->part(ReviewDetails::class), after$page->has()if the module may be missing. A Vue theme lists it in its$partsfor a template and gets it as a prop named bykey(). 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, a term's description and every widget before a theme gets them.
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 with the same arguments. The filters config('kitloom-site.content_filters') names run first, in that order; the rest follow in the order they were added — paragraphs (WordPress's wpautop, on unless kitloom-site.paragraphs is false), then blocks, then the modules' own. remove($name) takes one out.
Blocks
The blocks filter draws WordPress's blocks: a static block is the HTML it was saved with, a synced pattern (<!-- wp:block {"ref":12} /-->) is the text of that wp_block post. A dynamic block is saved as a comment only — <!-- wp:latest-posts {"postsToShow":3} /--> — and is nothing on the page without a type that draws it.
A module draws one with a Kitloom\Site\Contracts\BlockType, tagged Kitloom\Site\Content\BlockTypes::TAG:
php
use Kitloom\Site\Content\BlockTypes;
use Kitloom\Site\Content\BlockWrapper;
use Kitloom\Site\Contracts\BlockType;
final readonly class LatestReviewsBlock implements BlockType
{
public function __construct(private ReviewCatalog $reviews) {}
public function blockName(): string
{
return 'reviews/latest';
}
public function renderBlock(array $attributes, string $content): ?string
{
$items = $this->reviews->latest((int) ($attributes['count'] ?? 3));
if ($items === []) {
return null;
}
return '<ul'.BlockWrapper::attributes($this->blockName(), $attributes).'>'
.implode('', array_map(fn ($review): string => '<li>'.e($review->title).'</li>', $items))
.'</ul>';
}
}
$this->app->tag([LatestReviewsBlock::class], BlockTypes::TAG);$attributes is the JSON of the block comment, $content the block's inner HTML with inner blocks already drawn; null draws nothing. BlockWrapper::attributes() prints what WordPress puts on the outer element: the block's own classes, wp-block-reviews-latest, the alignment, the editor's extra class and the anchor as the id — the markup themes already style. A widget type that WordPress also has as a block implements both WidgetType and BlockType, as kitloom/widgets does for core/latest-posts, core/archives and six others; a block type tagged later with the same name replaces an earlier one.
Widget types
Widgets live where WordPress keeps them — the sidebars_widgets and widget_{type} options. A widget type of your own needs:
- on the site, a
Kitloom\Site\Contracts\WidgetType—base(): string(recent-posts),markup(Widget $widget): ?stringanddefaultTitle(Widget $widget): ?string— the title when the owner left it empty, WordPress's for the type through__(), null for none — taggedKitloom\Site\Content\WidgetTypes::TAG. A widget whose type nobody declares is not shown; of two types with the same base, the one declared later wins; - in the admin, a form tagged with that admin's
WidgetForms::TAG: in Filament aKitloom\WpSchema\Filament\Widgets\WidgetForm(base(),title(),components(),settings(array $data)), in Nova aKitloom\WpSchema\Nova\Contracts\WidgetForm(base(),title(),fields(),settings(array $attributes)) taggedKitloom\WpSchema\Nova\Widgets\WidgetForms::TAG. A widget without a form is shown read-only.
Template variables
Rank Math's variables — %title% %sep% %sitename%, %customterm(genre)%, %date(Y-m-d)% — are replaced in titles and descriptions. A Kitloom\Site\Contracts\VariableSource tagged Kitloom\Site\Content\Variables::TAG is asked first; the first value wins:
php
public function value(string $name, ?string $argument, ?Model $object, ?Page $page = null): ?string
{
return $name === 'rating' && $object instanceof Review ? (string) $object->rating : null;
}The page cache
Pages are cached for guests (SITE_CACHE_TTL) until a record of the content tables is saved. 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'),
));Page scripts
WordPress's wp_head(), wp_body_open() and wp_footer() are @siteHead, @siteBodyOpen and @siteFooter in the theme's root layout. A module that puts markup there — analytics, a consent banner, a pixel — tags a Kitloom\Site\Contracts\PageScriptsProvider with PageScripts::PROVIDER_TAG:
php
use Kitloom\Site\Contracts\PageScriptsProvider;
use Kitloom\Site\Page\Page;
use Kitloom\Site\Page\PageScripts;
final readonly class StatsScripts implements PageScriptsProvider
{
public function scripts(string $slot, ?Page $page): iterable
{
if ($slot === PageScripts::HEAD) {
yield view('stats::tag');
}
}
}
$this->app->tag([StatsScripts::class], PageScripts::PROVIDER_TAG);$slot is PageScripts::HEAD, BODY_OPEN or FOOTER; $page is the page being rendered, null in a view the site did not render. The markup goes out unescaped and into the page cache, so it is the same for every visitor; what depends on the visitor is decided by the script in the browser. A provider that throws loses its own snippet, not the page.
Trackers and consent
A tracker — analytics, a tag manager, a pixel, an embed that sets cookies — hands its markup to Kitloom\Site\Contracts\ScriptConsent::gate() before yielding it:
php
use Kitloom\Site\Contracts\ScriptConsent;
public function __construct(private ScriptConsent $consent) {}
public function scripts(string $slot, ?Page $page): iterable
{
if ($slot === PageScripts::HEAD) {
yield $this->consent->gate(ScriptConsent::STATISTICS, $this->tag());
}
}The category is ScriptConsent::STATISTICS, MARKETING or PREFERENCES (FUNCTIONAL is never held back) — those of the WP Consent API. A Google tag that reads Google Consent Mode itself passes googleConsentMode: true: it may load before the visitor decides, once the consent defaults are set ahead of it. Without a consent module the markup goes out as it is; kitloom/cookie-consent holds it back in the browser until the visitor agrees.
Shop events
A module that sells publishes what happened — a product seen, put in the cart, a checkout begun, an order placed — into the page, and every tracker that is there hears it. Neither side requires the other.
php
use Kitloom\Site\Page\CommerceEvents;
// The seller, in its page scripts:
yield CommerceEvents::publish(['event' => CommerceEvents::VIEW_ITEM, 'currency' => 'EUR', 'value' => 12.5, 'items' => [$item]], $nonce);
// A tracker, inside its script:
$js = CommerceEvents::listen('function(e){ /* map e.event, e.items … */ }');An event has the shape of a GA4 e-commerce event: event (view_item, add_to_cart, begin_checkout, purchase), currency, value, items (item_id, item_name, price, quantity, item_category), and for a purchase transaction_id, tax, shipping. publish() pushes it into the browser queue window.kitloomCommerce and dispatches kitloom:commerce; with a key in $once the browser publishes it only the first time, for a cached thank-you page. listen() hands the tracker every queued event, then each new one — so a tracker held back for consent still gets what happened before it started.
REST namespaces
A route the front end calls — a form is sent, a vote is cast — is a namespace under the site's REST root, /wp-json/. A Kitloom\Site\Contracts\RestNamespace is tagged RestNamespace::TAG in the module's register():
php
use Illuminate\Routing\Router;
use Kitloom\Site\Contracts\RestNamespace;
final class ReviewsNamespace implements RestNamespace
{
public function name(): string
{
return 'reviews/v1';
}
public function register(Router $router): void
{
$router->post('{review}/votes', VoteController::class)->whereNumber('review')->middleware('throttle:10,1');
}
/** For the index at /wp-json/, with parameters as WordPress writes them. */
public function routes(): array
{
return ['/(?P<review>[\d]+)/votes' => ['POST']];
}
}
$this->app->tag([ReviewsNamespace::class], RestNamespace::TAG);- The routes are controllers, not closures, so they can be cached. They are mounted once every provider has booted.
- They have no
webgroup — no session, no CSRF — unless the namespace puts it on them. Whatever a route needs (authentication, a rate limit) the namespace adds itself. - Errors under the root reach the client as WordPress's JSON: throw
Kitloom\Site\Rest\RestException(RestException::notFound($code, $message),forbidden(),invalidParams(), ornew RestException($code, $message, $status)) for{"code": "…", "message": "…", "data": {"status": 404}}. $rest->url('reviews/v1', '5/votes')on an injectedKitloom\Site\Rest\RestApiis the absolute address. A shortcode never reads the request: it draws, and its data gives the theme this address. A list is read from the page itself —GET <archive>/page/2/withAccept: application/json.
kitloom/api mounts wp/v2, kitloom/forms contact-form-7/v1, kitloom/shop wc/store/v1. See Headless & APIs.
Shortcodes
A shortcode is a handler class registered by tag. A shortcode the theme draws leaves an anchor in the text, and its data goes to the page's shortcodes part:
php
$this->callAfterResolving(Shortcodes::class, fn (Shortcodes $shortcodes) => $shortcodes->addShortcode('books', BooksShortcode::class));How to write one, how it reaches the page and how themes draw it: Shortcodes.
Search
The site searches the posts table as WordPress does. A module with an index of its own binds Kitloom\Site\Contracts\SearchProvider:
php
use Kitloom\Site\Contracts\SearchProvider;
use Kitloom\Site\Query\SearchHits;
final readonly class IndexSearch implements SearchProvider
{
public function search(string $terms, array $postTypes, int $perPage, int $page, ?string $language = null): ?SearchHits
{
// One page of post IDs, best first, and the total; null — let the site search.
return new SearchHits($ids, $total);
}
}
$this->app->bind(SearchProvider::class, IndexSearch::class);The provider only ranks and counts. The page stays the main query's — its address, page size, links — and a hit is shown only when its post is published and of a public type. $language is the slug of the language the page is shown in; null when the site has one language. kitloom/search binds one.
Signing in
Every sign-in on the site ends in Kitloom\WpSchema\Contracts\SignIn::signIn(Authenticatable $user, bool $remember = false): SignInResult — by password (POST /login of kitloom/site), by a social account, or whatever comes next. By default the user is signed in at once. A module that needs one more step decorates the binding and answers with a challenge, calling the decorated one when the step is done:
php
use Kitloom\WpSchema\Auth\SignInResult;
use Kitloom\WpSchema\Contracts\SignIn;
$this->app->extend(SignIn::class, fn (SignIn $signIn, Application $app): SignIn => new ConfirmEmailSignIn($signIn));
// In ConfirmEmailSignIn::signIn():
return SignInResult::challenge($user, 'confirm-email', ['email' => $masked], '/login/?confirm=1');The theme draws the step from the challenge's name and data. kitloom/two-factor is built this way; kitloom/social-accounts signs users in through the same contract. The admins' own sign-in pages do not come through here.
The data
Content events
React to content with Kitloom\WpSchema\Events, not with eloquent.* events: these come from every post, taxonomy and comment model — the package's, the application's, a theme's — and trash and restore are not Eloquent events at all.
| Event | When | Carries |
|---|---|---|
PostSaved | a post was created or changed — save_post | $event->post |
PostPublished | it became published — after PostSaved | $event->post |
PostTrashed, PostRestored | it went to the trash, came back — after PostSaved | $event->post |
PostDeleted | the row is gone for good | $event->post, still holding what it had |
TermSaved, TermTrashed, TermRestored, TermDeleted | the same for a term | $event->term, a term_taxonomy row |
CommentPosted | a comment was added, whatever status it got — comment_post | $event->comment, $event->actor |
CommentStatusChanged | a comment moved from one status to another — transition_comment_status | $event->comment, $event->from, $event->to, $event->actor |
Listeners check the type themselves, as WordPress hooks do:
php
use Kitloom\WpSchema\Events\PostPublished;
Event::listen(PostPublished::class, function (PostPublished $event): void {
if ($event->post->type === 'review') {
// …
}
});Settings and roles events
| Event | When | Carries |
|---|---|---|
Kitloom\Settings\Events\SettingSaved | an option was written, through SettingWriter or the model | name, old, new, actor; wasAdded() |
Kitloom\Settings\Events\SettingDeleted | an option was removed | name, old, actor |
Kitloom\Permissions\Events\RoleSaved | a role was created or changed (RoleEditor) | role, previous, actor; wasCreated(), addedPermissions() |
Kitloom\Permissions\Events\RoleDeleted | a role was removed | role, actor |
Kitloom\Permissions\Events\UserRoleChanged | a user got another role (UserRoles::setRole()) | user, old, new, actor |
When you write as a user, pass yours as the actor — the activity log reads it.
Extending models you don't own
A relation, an attribute, a scope or an observer on a model of another package goes through WpSchema::extend() in your provider's boot(). It reaches every subclass — the application's post, a theme's review — whatever the boot order. Modules and themes don't subclass post, term_taxonomy and the other WordPress keys to add behaviour.
php
use Illuminate\Database\Eloquent\Builder;
use Kitloom\WpSchema\Models\ModelExtension;
use Kitloom\WpSchema\Models\Post;
use Kitloom\WpSchema\WpSchema;
WpSchema::extend('post', fn (ModelExtension $post) => $post
->relation('votes', fn (Post $post) => $post->hasMany(Vote::class, 'post_id'))
->attribute('score', fn (Post $post): int => $post->getMetaInt('_score') ?? 0)
->scope('popular', fn (Builder $query) => $query->whereHas('votes'))
->observe(VoteObserver::class));The target is a registry key (post means the package's Post and all its subclasses), a model class or a contract the models implement. globalScope($name, $scope) and attributes($owns, $read) (a family of attributes by name) are there too. See Data.
Personal data
A module that keeps something about people — orders, form messages, bookings, reviews — says so to kitloom/privacy, which answers WordPress's Export and Erase Personal Data requests. It tags an exporter and an eraser in its provider's register(); without the privacy module nothing reads them.
php
use Kitloom\WpSchema\Contracts\PersonalDataEraser;
use Kitloom\WpSchema\Contracts\PersonalDataExporter;
use Kitloom\WpSchema\PersonalData\ErasurePage;
use Kitloom\WpSchema\PersonalData\ExportItem;
use Kitloom\WpSchema\PersonalData\ExportPage;
final class ReviewsExporter implements PersonalDataExporter
{
public function key(): string { return 'acme-reviews'; }
public function label(): string { return __('Reviews'); }
public function export(string $email, int $page): ExportPage
{
$reviews = Review::query()->where('email', $email)->forPage($page, 500)->get();
return ExportPage::of($reviews->map(fn (Review $review) => new ExportItem(
'reviews', __('Reviews'), 'review-'.$review->id,
[__('Rating') => (string) $review->rating, __('Text') => $review->body],
))->all(), 500);
}
}
final class ReviewsEraser implements PersonalDataEraser
{
public function key(): string { return 'acme-reviews'; }
public function label(): string { return __('Reviews'); }
public function erase(string $email, int $page): ErasurePage
{
// What a round erased no longer has the address: read the first ones again.
$count = Review::query()->where('email', $email)->limit(500)->update(['name' => __('Anonymous'), 'email' => '']);
return new ErasurePage($count, done: $count < 500);
}
}
$this->app->tag([ReviewsExporter::class], PersonalDataExporter::TAG);
$this->app->tag([ReviewsEraser::class], PersonalDataEraser::TAG);Items of one group from several exporters go together in the archive, and one item (group, item id) told by two is merged. Erase what the law lets go, or make it anonymous; what it requires you to keep — an order for the accounts — stays, counted as retained with a message saying why: the owner reads it in the report.
AI agents
kitloom/ai is an MCP server at /mcp. A module offers tools to it without requiring it: a Kitloom\WpSchema\Contracts\AgentTool in the domain package's src/Agent/, tagged AgentTool::TAG behind a kitloom-<name>.ai.register switch. Without kitloom/ai nothing reads the tag.
php
use Kitloom\WpSchema\Contracts\AgentTool;
final readonly class ReviewsTrash implements AgentTool
{
public function __construct(private ReviewService $reviews) {}
public function name(): string { return 'reviews_trash'; } // prefixed with the module
public function module(): string { return 'acme/reviews'; }
public function description(): string
{
return 'Moves a review to the trash. Nothing is deleted for good.';
}
public function schema(JsonSchema $schema): array
{
return ['id' => $schema->integer()->min(1)->required()];
}
public function permission(): ?string { return 'manage_reviews'; } // null — any user an agent acts for
public function writes(): bool { return true; } // a read-only key does not get it
public function handle(array $arguments, Authenticatable $actor): array
{
$valid = Validator::make($arguments, ['id' => ['required', 'integer', 'min:1']])->validate();
return $this->reviews->trash((int) $valid['id'], $actor);
}
}php
if ((bool) $this->app->make('config')->get('acme-reviews.ai.register', true)) {
$this->app->tag([ReviewsList::class, ReviewsTrash::class], AgentTool::TAG);
}handle() calls the module's service with the actor, as the admins do; a ValidationException, AuthorizationException or InvalidArgumentException is said back to the agent. Nothing is deleted for good: a trash or a status instead. What an agent should know before it calls anything — fields per type, menu places — is a Kitloom\WpSchema\Contracts\AgentContext (key(), module(), describe(Authenticatable $actor): array) tagged AgentContext::TAG; it lands in the describe_site answer under the module. Test a tool directly in the module — kitloom/redirects has an example in tests/Agent.
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/media: the address files are served from |
Kitloom\Site\Contracts\ScriptConsent | gate(string $category, string $html, bool $googleConsentMode = false): string | everything goes out | kitloom/cookie-consent |
Kitloom\Site\Contracts\SearchProvider | see Search | none: the site searches the posts table | kitloom/search |
Kitloom\WpSchema\Contracts\SignIn | see Signing in | signed in at once | decorated by kitloom/two-factor |
Kitloom\WpSchema\Contracts\SiteRoles | names(): array — role key => name | none: the {prefix}user_roles option is read | kitloom/permissions |
Kitloom\Media\Contracts\ImageOptimizer | optimize(string $bytes, string $mime): string | none: sizes stored as GD encoded them | kitloom/media-optimizer |
HtmlEditor (both admins) | see above | textarea / code editor | kitloom/visual-editor-* |
AttachmentPicker (both admins) | see above | a searchable list | kitloom/media-filament, kitloom/media-nova |
To replace one, bind() your class in a provider; to add a step around one, extend() it. 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 reads its fields with $model->field('name'); $model->setField('name', $value, $actor) and the Kitloom\CustomFields\Facades\Fields facade write them, validated as the form validates.