Appearance
What Is WordPress Here, What Is Laravel
Introduction
kitloom keeps its content in the WordPress database and handles a request the way WordPress does. Everything else — the code, the models, the admin, the API — is Laravel. This page draws the line, concept by concept, with the price of each WordPress-shaped part and the way around it when it is in your way.
The short version: the storage is WordPress's; the code never is. There is no PHP from WordPress, no hooks, no wp-admin. Where WordPress names show, they are the column or the option a site brought over, and the public API wraps them in Laravel names.
The map
| Concept | Where it lives | What is WordPress | What is Laravel | The price | The way around |
|---|---|---|---|---|---|
| A record of content | wp_posts | the table, its columns, the statuses (publish, draft, trash…) | an Eloquent model, Post, with aliases (title, slug, status, published_at), writers, events, global scopes | a type's model must extend Post; a record that is not a row of wp_posts gets no address from the main query — only from a route of your own | keep the row, put your fields in tables of your own |
| Fields of a record | wp_postmeta | one row per key, the value a string, ACF's layout for field groups | getMeta(), getMetaInt(), setMeta(), $post->field(), the Fields facade; all keys of a record read in one query, with('meta') to load them with the list | writes are two queries a key; filtering and sorting by a field joins an unindexed text column | a hasOne details table with real columns; meta for what came from WordPress |
| Categories, tags, taxonomies | wp_terms, wp_term_taxonomy, wp_term_relationships | the three-table split, term_id and term_taxonomy_id | Term and TermTaxonomy models, TermWriter, $post->category, $post->termsIn('genre'), $post->termTaxonomies | two ids for one thing; a join for every term lookup | TermTaxonomy reads name and slug through its term, and the post's taxonomy attributes hide the rest; nothing to do |
| Options | wp_options | the table, option_name, every array as serialize() writes it — WordPress's own and its plugins' | Settings::get(), Settings::array(), SettingWriter, SettingSaved; the autoloaded options in one query, cached, the rest by name; arrays written as JSON | two formats to read; an array kitloom has saved is JSON, which a WordPress still running on the same database no longer reads | read as arrays only, never as objects; the site moves one way |
| Users and sign-in | wp_users, wp_usermeta | the table, WordPress's password hashes | the application's User with the IsWordPressUser trait, the wordpress auth driver and hasher (phpass and WordPress 6.8's bcrypt), the Two-Factor module | the users table is not Laravel's users | a user signing in keeps their password; nothing to do |
| Who may do what | wp_usermeta ({prefix}capabilities) for the user's role, mirrored for WordPress; the permission tables for what the role may do | the capability names: edit_posts, manage_options, edit_theme_options; map_meta_cap() | Laravel's Gate, a roles engine on spatie/laravel-permission behind its own contracts, Permission providers per module, the Roles screen | WordPress names in every can() | constants on the modules' classes (Shop::CAPABILITY_ORDERS), admin sections asked by class; a module declares its own permissions with any name |
| Arrays in the database | every table | serialize() for every array, WordPress's own and its plugins'; 1/0 flags | JSON, StoredArray | two formats to read | written as JSON once touched |
| Table names | every table | the wp_ prefix | the connection prefix, also in raw SQL | the prefix in every raw query | the connection prefix; the models never see it |
| An address | — | permalink settings, rewrite rules, query vars, the canonical 301 | Route::fallback(), events (RewriteRulesBuilding, MainQueryResolved), Site::resolve() | a route of your own wins over the fallback, nothing else to learn | your routes first, the site answers the rest |
| What a page shows | — | the main query and its conditional tags, the template hierarchy (single-book, archive, index) | page parts, typed objects with toArray() and a JSON Schema (kitloom:schema writes TypeScript from them), Blade or Vue through Inertia, JSON or Markdown for every page | template names follow WordPress's list | a TemplateCandidates listener adds yours |
| Widgets and menus | sidebars_widgets, widget_*, nav_menu terms | the options and the term tree a WordPress site brought | the menus, menuLocations and widgets page parts, zones and locations added in the admin | the widget settings are option rows | the parts give them drawn; nothing to do |
| The REST API | — | wp/v2, application passwords, X-WP-Total | Laravel routes and controllers under /wp-json/, RestNamespace per module, WordPress's error shape | WordPress's JSON for the core routes | a namespace of your own with any shape |
| Plugin data | the plugin's tables and meta | ACF field groups, Rank Math meta and redirects, Contact Form 7 forms and Flamingo messages, WooCommerce products and orders, Slide Anything, Nextend Social Login | a module per format, with models, writers and admins of its own | the module's storage is the plugin's | that is the point: the site moves with its data |
| The code | — | nothing | service providers, contracts and container tags, Eloquent, events, queues, Horizon, Pulse, Octane-ready, Pint, PHPStan, PHPUnit | — | — |
Why a type's model extends Post
A post type of your own — a theme's recipe, a module's review — is read with a model you name, and that model extends Kitloom\WpSchema\Models\Post. The site refuses one that does not (PostTypeObject). The reason is not WordPress; it is where the site keeps everything a record needs:
- meta and custom fields —
getMeta(),field(), the Custom Fields module; - extensions — what modules add to every post with
WpSchema::extend('post', …): attributes, relations, scopes, observers — Languages' translated title and text, the events' own observers; - capabilities —
edit_post,read_post,delete_postof this record, mapped as WordPress maps them; - the main query — statuses, visibility, the preview of a draft, the canonical address, the template hierarchy;
- writers and events —
PostWriter,PostSaved,PostTrashed,PostDeleted, everything that listens to them — search, the activity log, SEO, import and export — and everything that writes through them: the REST API, the AI tools.
A model that is not a Post would have to carry all of that itself, and every module would have to know about it. Extending Post is the price of getting it for free.
What it does not mean: that your fields belong in wp_postmeta.
What it costs, and why it stays for now
The base class has costs, and they were weighed rather than missed:
- One base class. A model that already extends another class — a package's model, a base model of your application — cannot be a type's model as it is. It becomes a
Post, or it sits beside one: the post is the record, your model a relation of it. - A large parent.
Postbrings meta, fields, extensions, the featured image, column defaults and the model registry with it. A type inherits all of it, whether it uses it or not. wp_postsunderneath. A type's record is always a row of that table, with its columns. What the type adds goes into tables of its own (below), not instead of the row.
The alternative is a contract: an interface, with the behaviour in traits that any Eloquent model over wp_posts could take, and Post as the default implementation. It is not built now, on purpose:
- Tables of your own already give a type its own shape. A type's data lives in its own tables, on relations, with indexes, while the record keeps its address, permissions, comments, REST API and agent tools (below). That is the freedom most types need.
- The change reaches everything that reads a post. That is every package that shows, writes, searches, translates or exports content. It also includes the parts most likely to break: the visibility scopes, the capability mapping, per-request state under Octane. Each would need tests on a model that is not a
Postbefore anything moves. - Waiting closes no door. Adding a contract later breaks nothing written today:
Postwould implement it, so every model that extendsPostwould keep passing every check.
What would change the decision: a case a relation cannot cover — a model that must keep another base class and still be read by the main query itself.
The way around meta
Meta is the right place for what came from WordPress and what WordPress-format modules expect there: the featured image, SEO's title and description, ACF field groups a site brought over. It is the wrong place for the fields of a type you are building now and will filter and sort by. For those, keep the row in wp_posts and put the fields in tables of the theme or module, on Eloquent relations:
php
final class Recipe extends Post
{
/** One row per recipe, real columns, indexed: servings, minutes, difficulty. */
public function details(): HasOne
{
return $this->hasOne(RecipeDetails::class, 'post_id');
}
public function ingredients(): HasMany
{
return $this->hasMany(Ingredient::class, 'post_id')->orderBy('sort');
}
public function equipment(): BelongsToMany
{
return $this->belongsToMany(Equipment::class, 'recipe_equipment', 'post_id', 'equipment_id');
}
}The post keeps its title, text, slug, address, status, author, terms and permissions; the archive filters and sorts on recipe_details with indexes; the page parts load the relations with the post. Saffron does this for a whole type: a recipe's card is one row of typed, indexed columns, its ingredients and steps a row each, and the archive filters and sorts on them; Game Reviews keeps its player ratings the same way. A theme of your own gets the same shape from the generator with --storage=tables. The migrations, the model registry and the rules are on Data & Models → Tables of your own.
What reads meta does not see these tables: Custom Fields, the WXR export, the REST API, Search & Replace (unless the theme declares its tables to it), the generic AI tools. A type on tables of its own gives the REST API its own fields and agents its own tool, the way a module does; the export of those rows is a command of the theme's own.
Names
The rule the packages follow, and the one for code you write on them: WordPress names stay in the storage layer. The public API speaks Laravel: $post->title, not post_title; Settings::get(), not get_option(); Menus::get('main'), not wp_nav_menu(); $post->status, with the constants of PostContract, not the string 'publish' typed out. Queries, raw rows and import/export keep the columns, because that is what they are.
Next
- Architecture — how a request becomes a page.
- Data & Models — the registry, meta, options, tables of your own.
- Permissions — the capability names and the roles engine.
- Headless & APIs — every page as JSON, the REST root.