Skip to content

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 JSON

1. 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 ​

EventWhenWhat a listener may do
Kitloom\Site\Events\RewriteRulesBuildingthe rules are builtprepend() or append() a rule
Kitloom\Site\Events\SearchQueryBuildinga search (/?s=) has its query of postsnarrow $event->query (where…) to keep records out
Kitloom\Site\Events\MainQueryResolvedthe main query has its resultput another $event->result in place
Kitloom\Site\Events\PageRespondingthe address is canonical, the page is about to be drawn (template_redirect)set $event->response — send the visitor elsewhere
Kitloom\Site\Events\TemplateCandidatesthe template names are ready$event->candidates->prepend('shop')
Kitloom\Site\Events\PageNotFoundnothing lives at the addresslog 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 it

Whose 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 addUse
Post types and taxonomies with addressesContracts\ContentTypeProvider tagged ContentTypes::TAG
A part of the pagePageParts::provide() with a Page\PartProvider
A filter on the content (the_content)Content\ContentFilters::add()
A dynamic blockContracts\BlockType tagged BlockTypes::TAG
A widget typeContracts\WidgetType tagged WidgetTypes::TAG
Template variables (%title% %sep% %sitename%)Contracts\VariableSource tagged Variables::TAG
Scripts in the page's head or footerContracts\PageScriptsProvider tagged PageScripts::PROVIDER_TAG
A tracker that waits for consentContracts\ScriptConsent::gate($category, $html)
Shop events for trackersPage\CommerceEvents::publish() / CommerceEvents::listen()
Routes a front end callsContracts\RestNamespace tagged RestNamespace::TAG
Another step at sign-indecorate 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:

RegistryWhat it collectsProvider contractTag
Kitloom\WpSchema\PostType\PostTypeRegistrypost types the site knowsKitloom\WpSchema\Contracts\PostTypeProviderInterfacePostTypeRegistry::PROVIDER_TAG
Kitloom\WpSchema\Taxonomy\TaxonomyRegistrytaxonomiesKitloom\WpSchema\Contracts\TaxonomyProviderInterfaceTaxonomyRegistry::PROVIDER_TAG
Kitloom\Site\Types\ContentTypestypes with addresses on the siteKitloom\Site\Contracts\ContentTypeProviderContentTypes::TAG
Kitloom\Settings\SettingRegistrydeclared settingsKitloom\Settings\Contracts\SettingsProviderSettingRegistry::PROVIDER_TAG
Kitloom\Access\SectionPermissionspermissions of admin sectionsKitloom\Access\Contracts\PermissionProviderPermission::PROVIDER_TAG
Kitloom\Site\Content\WidgetTypeswidget typesKitloom\Site\Contracts\WidgetTypeWidgetTypes::TAG
Kitloom\Site\Content\BlockTypesdynamic blocksKitloom\Site\Contracts\BlockTypeBlockTypes::TAG
kitloom/aitools and context for AI agentsKitloom\WpSchema\Contracts\AgentTool, AgentContextAgentTool::TAG, AgentContext::TAG
each admin's FormSectionsblocks on the post formper adminFormSections::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 site

From 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 field

Plain 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 ​

LayerPackagesMay depend on
Datakitloom/wp-schema, kitloom/settings, kitloom/accessLaravel
Sitekitloom/site (with the Blade engine), kitloom/site-inertiathe data layer
Modules (domain)kitloom/seo, kitloom/redirects, kitloom/forms, …the data layer, the site, other domain packages
Adminkitloom/*-filament, kitloom/*-nova, on kitloom/wp-schema-filament / kitloom/wp-schema-novatheir domain package, the panel
Themeskitloom/*-themethe 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.json requires 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.

kitloom — modules and themes for Laravel.
Pricing · Services · License · Refunds · Privacy · Community · [email protected]