Appearance
Architecture
Introduction
kitloom is small at the centre and grows through packages. kitloom/wp-schema knows the WordPress data model; kitloom/site handles a request the way WordPress handles one; everything else — menus, head meta, forms, a shop — comes from modules that plug in through contracts and container tags and do not know about each other. The admin panels and the themes sit on top.
The public site follows the WordPress developer documentation: the Rewrite API, WP_Query, redirect_canonical(), the template hierarchy, template tags. If you know how WordPress answers an address, you know how kitloom does.
A request, the WordPress way
Your application's own routes always win. Every other address reaches Kitloom\Site\Http\SiteController through the fallback route:
php
Route::fallback(SiteController::class)->middleware(CachePage::class);and goes through the steps WordPress takes:
text
GET /category/news/page/2/
│
├─ 1. rewrite rules address → query vars parse_request()
├─ 2. main query query vars → QueryResult WP_Query
├─ 3. canonical a wrong address → 301 redirect_canonical()
├─ 4. template template hierarchy → theme template locate_template()
└─ 5. page the result and its parts → the active theme's engine, or JSON1. Rewrite rules
Rewrite\RewriteRules builds the site's rules from WordPress's options — permalink_structure, category_base, tag_base — and from the types that have addresses, in the order WordPress tries them: search, author, term archives, post types, dates, then posts and pages. Rewrite\RequestParser turns the address into query vars. As in WordPress, when the post structure could also match a page path, the page is tried first and kept only if it exists.
Links go the other way, as template tags give them: URL::permalink($post), URL::termLink($term), URL::authorLink($user). Site addresses keep their trailing slash.
2. The main query
Query\MainQuery finds what the vars ask for: one record, a page of a list with the object it is about, or nothing. The answer is a Query\QueryResult that speaks WordPress's conditional tags — isSingle(), isPage(), isSingular(), isHome(), isFrontPage(), isCategory(), isTag(), isTax(), isAuthor(), isDate(), isPostTypeArchive(), isSearch(), isNotFound(), isPaged() — and carries the same as one value, kind (single, page, category, search, not-found…).
Records are read through the application's models, so their scopes apply and a list shows what is published. A single draft, pending or private record opens, as in WordPress, for a signed-in user who may edit or read that record — a preview — and for nobody else. Search is WordPress's own — words, "phrases", -excluded, stopwords, title matches first — unless a module binds Contracts\SearchProvider (kitloom/search does) and ranks the results itself.
3. The canonical address
Query\CanonicalUrl decides where the visitor should have come: a record has one address, and any other way to it — without the trailing slash, through ?p=, with a wrong category in the post address, the front page by its slug — gets a 301 there. A JSON client is not redirected for a missing trailing slash.
4. The template hierarchy
Templates\TemplateHierarchy lists template names from the most specific to index — single-book-dune, single-book, single, singular, index for a book — and the active theme takes the first one it has. The theme is named by the kitloom_theme option, otherwise by APP_THEME. See Templates.
5. The page and its parts
Page\Page gives the template what was found and its parts: the content, the list of posts, breadcrumbs, widgets, the site's name — and whatever modules add (menus, head meta, the cart). A part is worked out only when a template asks for it, and once per request:
blade
@php($content = $page->part(\Kitloom\Site\Page\Parts\Content::class))
<h1>{{ $content->title }}</h1>A module provides a part from its service provider:
php
use Kitloom\Site\Page\PageParts;
$this->callAfterResolving(PageParts::class, fn (PageParts $parts) => $parts->provide(
Reviews::class,
ReviewsProvider::class, // implements Page\PartProvider
json: fn (Page $page): bool => $page->result->isSingle(), // in the JSON of these pages
));The page is then rendered by the engine of the active theme — Blade, built into kitloom/site, or Inertia + Vue from kitloom/site-inertia — or returned as JSON: when the client asks for it with Accept: application/json, and always when the site has no theme. The JSON is kind, template, url and the parts, the same data a theme gets. See Headless & APIs.
CachePage keeps whole pages for guests for SITE_CACHE_TTL seconds, until a content table is saved; signed-in users are drawn every time, and a module may keep other requests out (PageCache::bypassWhen()).
Events
| Event | When | What a listener may do |
|---|---|---|
Kitloom\Site\Events\RewriteRulesBuilding | the rules are built | prepend() or append() a rule |
Kitloom\Site\Events\SearchQueryBuilding | a search (/?s=) has its query of posts | narrow $event->query (where…) to keep records out |
Kitloom\Site\Events\MainQueryResolved | the main query has its result | put another $event->result in place |
Kitloom\Site\Events\PageResponding | the address is canonical, the page is about to be drawn (template_redirect) | set $event->response — send the visitor elsewhere |
Kitloom\Site\Events\TemplateCandidates | the template names are ready | $event->candidates->prepend('shop') |
Kitloom\Site\Events\PageNotFound | nothing lives at the address | log it — the 404 log of kitloom/redirects does |
Side effects belong in listeners: the site's steps stay the same for every request.
Pages without a request
Kitloom\Site\Site answers the same questions without an HTTP request — for the console, a test, a queued job or an AI agent:
php
use Kitloom\Site\Site;
$site = app(Site::class);
$result = $site->resolve('/books/dune/'); // a QueryResult
$result->isSingle();
$page = $site->render('/books/dune/'); // HTML with the theme's template
$page->status; // 200, or 404
$page->template; // 'single-book'
$data = $site->render($book, Site::JSON)->toArray(); // a record at its own address, as JSON
$draft = $site->render($book, Site::HTML, $editor); // as that user sees itWhose eyes a call uses is said per call: $as is the user, a visitor when it is null — never whoever happens to be signed in. A draft without an address of its own is rendered as its preview.
The REST root
/wp-json/ belongs to kitloom/site, as it belongs to WordPress: the index that lists the namespaces and their routes, and WordPress's rest_no_route (404) for anything else under the prefix. Modules mount namespaces there through Kitloom\Site\Contracts\RestNamespace, tagged RestNamespace::TAG — kitloom/api is wp/v2, kitloom/forms is contact-form-7/v1, kitloom/shop is wc/store/v1. Errors under the prefix reach the client as WordPress's JSON. See Headless & APIs.
Extending the site
A module never edits the site; it offers something the site asks for:
| To add | Use |
|---|---|
| Post types and taxonomies with addresses | Contracts\ContentTypeProvider tagged ContentTypes::TAG |
| A part of the page | PageParts::provide() with a Page\PartProvider |
A filter on the content (the_content) | Content\ContentFilters::add() |
| A dynamic block | Contracts\BlockType tagged BlockTypes::TAG |
| A widget type | Contracts\WidgetType tagged WidgetTypes::TAG |
Template variables (%title% %sep% %sitename%) | Contracts\VariableSource tagged Variables::TAG |
| Scripts in the page's head or footer | Contracts\PageScriptsProvider tagged PageScripts::PROVIDER_TAG |
| A tracker that waits for consent | Contracts\ScriptConsent::gate($category, $html) |
| Shop events for trackers | Page\CommerceEvents::publish() / CommerceEvents::listen() |
| Routes a front end calls | Contracts\RestNamespace tagged RestNamespace::TAG |
| Another step at sign-in | decorate Kitloom\WpSchema\Contracts\SignIn |
Classes without a namespace here are under Kitloom\Site\. See Extending.
Registries
Modules often need to know what exists on the site — which post types, which settings, which permissions. Instead of a contract every module must implement, a registry collects answers from tagged providers. A module tags its provider in its service provider's register(); the registry asks it on first use:
| Registry | What it collects | Provider contract | Tag |
|---|---|---|---|
Kitloom\WpSchema\PostType\PostTypeRegistry | post types the site knows | Kitloom\WpSchema\Contracts\PostTypeProviderInterface | PostTypeRegistry::PROVIDER_TAG |
Kitloom\WpSchema\Taxonomy\TaxonomyRegistry | taxonomies | Kitloom\WpSchema\Contracts\TaxonomyProviderInterface | TaxonomyRegistry::PROVIDER_TAG |
Kitloom\Site\Types\ContentTypes | types with addresses on the site | Kitloom\Site\Contracts\ContentTypeProvider | ContentTypes::TAG |
Kitloom\Settings\SettingRegistry | declared settings | Kitloom\Settings\Contracts\SettingsProvider | SettingRegistry::PROVIDER_TAG |
Kitloom\Access\SectionPermissions | permissions of admin sections | Kitloom\Access\Contracts\PermissionProvider | Permission::PROVIDER_TAG |
Kitloom\Site\Content\WidgetTypes | widget types | Kitloom\Site\Contracts\WidgetType | WidgetTypes::TAG |
Kitloom\Site\Content\BlockTypes | dynamic blocks | Kitloom\Site\Contracts\BlockType | BlockTypes::TAG |
kitloom/ai | tools and context for AI agents | Kitloom\WpSchema\Contracts\AgentTool, AgentContext | AgentTool::TAG, AgentContext::TAG |
each admin's FormSections | blocks on the post form | per admin | FormSections::TAG |
For example, the Post Types module tells everyone about the types created in the admin:
php
use Kitloom\WpSchema\PostType\PostTypeRegistry;
use Kitloom\Site\Types\ContentTypes;
use Kitloom\WpSchema\Taxonomy\TaxonomyRegistry;
$this->app->tag([AcfPostTypeProvider::class], PostTypeRegistry::PROVIDER_TAG);
$this->app->tag([AcfTaxonomyProvider::class], TaxonomyRegistry::PROVIDER_TAG);
$this->app->tag([AcfContentTypes::class], ContentTypes::TAG); // and their addresses on the siteFrom then on the SEO module offers title templates for those types, the sitemap lists them and the admins show their sections — without any of those modules knowing the Post Types module exists.
The data layer
Models through the registry
Every WordPress table has a model in kitloom/wp-schema, and Kitloom\WpSchema\ModelRegistry says which class represents it. The application swaps a class in config/kitloom-wp-schema.php; relations inside the packages and the admins' resources take the class from the registry, so they return the application's model. Queries are typed by key:
php
use Kitloom\WpSchema\WpSchema;
$post = WpSchema::query('post')->where('post_name', 'dune')->first();
$post->title; // post_title
$post->published_at; // post_date
$post->getMeta('isbn');
$post->field('rating'); // a custom fieldPlain names (title, slug, status, parent_id, published_at) are aliases of WordPress's columns; queries and raw rows keep the columns. A module that needs a relation, an attribute, a scope or an observer on a model it does not own adds it with WpSchema::extend('post', fn (ModelExtension $post) => …) — it reaches every subclass, whatever the boot order. See Data & Models.
Who sees what
Queries see with the request's eyes, never with whoever is signed in. The global scopes of posts and terms ask Kitloom\WpSchema\Visibility: a visitor, the console, the queue and any code that says nothing see what is published. The admin panels say everything() for their own requests (a Filament panel middleware, Nova::serving()); the REST API and AI agents acting for a user say forUser($user) — an editor sees everything, an author their own drafts too. The one place the site looks at the signed-in user is the preview of a single record described above.
Writes and events
Records are written through Kitloom\WpSchema\Content\PostWriter and TermWriter, as wp_insert_post() and wp_insert_term() write them: the slug made unique, the dates filled, the terms assigned. Given the acting user, they refuse what that user may not do; without one they are the system — an import, a demo command, the console. The admins, the REST API and the AI tools all write through them.
Every post and term model, whatever its class, announces what happened: Kitloom\WpSchema\Events\PostSaved, PostPublished, PostTrashed, PostRestored, PostDeleted, TermSaved, TermTrashed, TermRestored, TermDeleted. Options announce Kitloom\Settings\Events\SettingSaved and SettingDeleted; roles Kitloom\Permissions\Events\RoleSaved, RoleDeleted and UserRoleChanged. Each carries the actor. Listen to these rather than to eloquent.*: trash and restore are not Eloquent events at all.
Arrays are stored as JSON. WordPress's serialized values are still read — safely, without objects — and become JSON on their next save. See Data & Models.
Package boundaries
| Layer | Packages | May depend on |
|---|---|---|
| Data | kitloom/wp-schema, kitloom/settings, kitloom/access | Laravel |
| Site | kitloom/site (with the Blade engine), kitloom/site-inertia | the data layer |
| Modules (domain) | kitloom/seo, kitloom/redirects, kitloom/forms, … | the data layer, the site, other domain packages |
| Admin | kitloom/*-filament, kitloom/*-nova, on kitloom/wp-schema-filament / kitloom/wp-schema-nova | their domain package, the panel |
| Themes | kitloom/*-theme | the site, the modules they show |
Two rules matter most:
- Domain packages never import Filament or Nova. Anything an admin needs from the domain — a list of options, a slug rule, a tree of terms — is a domain class both admins use.
- Packages talk through contracts and tags. A package uses another's classes only if its
composer.jsonrequires it; to offer something to others it exposes a contract or a tag and lets the other side tag itself in.
A module plugs itself in: installing it with Composer is enough — Laravel's package discovery, Panel::configureUsing() in the Filament package, Nova::serving() in the Nova one. Nothing is edited in the application or in another package.
Octane and long-lived workers
Nothing lives from one request to the next. What is read for a request — the options, the types and their rewrite rules, the main query, the visibility — is a scoped() binding, forgotten when the request ends; singletons hold only what never changes after boot. Facades over per-request services are not cached between requests. The same holds for queue workers and tests that run several requests in one process.