Appearance
Data
Introduction
kitloom keeps its data where WordPress keeps it: posts, meta, terms, options, users — the same tables, the same keys. A module follows the same rule for its own data: a custom field is post meta, a setting is an option, and a module that replaces a WordPress plugin reads and writes that plugin's tables and keys.
The data layer is kitloom/wp-schema. This page shows how a module reads and writes through it.
Models
The WordPress tables are Eloquent models, looked up by key rather than by class:
php
use Kitloom\WpSchema\WpSchema;
WpSchema::query('post')->where('post_type', 'book')->get(); // Builder<Post>
WpSchema::model('term_taxonomy'); // the class
WpSchema::make('post_meta'); // a new instance| Key | Table | Package model (Kitloom\WpSchema\Models\…) |
|---|---|---|
post, attachment | posts | Post, Attachment |
post_meta | postmeta | PostMeta |
term, term_taxonomy, term_meta | terms, term_taxonomy, termmeta | Term, TermTaxonomy, TermMeta |
comment, comment_meta | comments, commentmeta | Comment, CommentMeta |
user, user_meta | users, usermeta | User (the application's user replaces it), UserMeta |
option | options | Option |
Why by key: an application almost always extends the package models and adds its own behaviour. Relations inside the packages ask the registry for the class, so $post->meta returns the application's meta model and nothing it added is lost.
The calls are typed by key: WpSchema::query('post') is a Builder<Post>, so static analysis knows $post->title without a cast. A module reading a key of its own says what it relies on, and gets that type — or an exception when the registry holds something else:
php
WpSchema::query(Review::KEY, Review::class); // Builder<Review>The ModelRegistry
Kitloom\WpSchema\ModelRegistry holds the class for each key. Several parties register classes — the packages, the application's config('kitloom-wp-schema.models'), a module or a theme — with $registry->use($key, $class), and they must form one line of inheritance:
- the most specific class wins, whatever order they register in;
- two classes where neither extends the other are an error, not a silent loss of one of them;
- a class for a WordPress key must extend the package model (
App\Models\Post extends Kitloom\WpSchema\Models\Post) — the list isModelRegistry::TYPES.useris not on it: the application's user extendsAuthenticatableand takes WordPress's behaviour from theIsWordPressUsertrait.
php
// config/kitloom-wp-schema.php in the application
'models' => [
'post' => App\Models\Post::class,
'user' => App\Models\User::class,
],Any model of wp_posts rows extends Post, and any model of term_taxonomy rows extends TermTaxonomy — a post type's own model too. Meta, custom fields, extensions, content events and capabilities all live there.
To add a relation, an attribute, a scope or an observer to a model you don't own, don't subclass it — extend it from your provider's boot(). The extension reaches every subclass, the application's and the themes', whatever the boot order:
php
use Kitloom\WpSchema\Models\ModelExtension;
use Kitloom\WpSchema\Models\Post;
use Kitloom\WpSchema\WpSchema;
WpSchema::extend('post', fn (ModelExtension $post) => $post
->relation('reviews', fn (Post $post) => $post->hasMany(Review::class, 'book_id'))
->observe(ReviewObserver::class));ModelExtension also has attribute(), scope() and globalScope(). See Extending kitloom.
A module's own models
A module puts its models in the registry under keys of its own, unless the application already put a class there, and reads the class back from the registry everywhere — in its admin resources too. This is what kitloom:make-module generates:
php
private function registerModels(): void
{
$register = static function (ModelRegistry $registry): void {
if (! $registry->has(Review::KEY)) {
$registry->use(Review::KEY, Review::class);
}
};
$this->app->resolving(ModelRegistry::class, $register);
if ($this->app->resolved(ModelRegistry::class)) {
$register($this->app->make(ModelRegistry::class));
}
}php
// Filament resource
public static function getModel(): string
{
return WpSchema::model(Review::KEY);
}php
// Nova provider, inside Nova::serving(): the resource's model follows the registry
$resource::$model = $this->app->make(ModelRegistry::class)->class(Review::KEY);A module whose records are posts of its own type — kitloom:make-module --post-type=review — gets a model extending Post with a global scope on post_type, declared to the site with new PostTypeObject('review', …, model: Review::class) (Kitloom\Site\Types\PostTypeObject).
Reading
Code reads the columns by plain names. They are aliases of the WordPress columns, not copies: queries, raw rows and import/export keep the column names.
| Model | Plain names | Columns |
|---|---|---|
Post | title, content, excerpt, slug, status, type, parent_id, author_id, published_at, modified_at | post_title, post_content, post_excerpt, post_name, post_status, post_type, post_parent, post_author, post_date, post_modified |
TermTaxonomy | name, slug (of its term, written to it), status, type (read only) | terms.name, terms.slug, the status in term meta, taxonomy |
Comment | post_id, content, author_name, author_email, author_url, approved, type, parent_id, created_at | comment_post_ID, comment_content, comment_author, … comment_date |
php
$book = WpSchema::query('post')->where('post_type', 'book')->where('post_name', 'dune')->first();
$book->title; // 'Dune' — HTML entities WordPress stored are decoded
$book->published_at; // Carbon, in the site's timezone, as post_date keeps it
$book->genre; // the post's terms of the `genre` taxonomy, by the taxonomy's nameWho sees what
Queries see with the eyes of the request, never of whoever is signed in. The global scopes of posts and terms ask Kitloom\WpSchema\Visibility, one instance per request (and per queued job):
| Who | Sees | Says so with |
|---|---|---|
| A visitor, the console, the queue, code that said nothing | published posts, attachments (inherit), published terms | — the default |
| An admin panel | everything; each action is then decided by capability | everything() — a Filament panel middleware and Nova::serving do it for you |
| An API acting as a user | everything with edit_others_posts, otherwise what is published and the user's own records | forUser($user) |
php
use Kitloom\WpSchema\Visibility;
app(Visibility::class)->forUser($request->user());Site::render($url, as: $user) renders a page with a given user's eyes and puts the request's visibility back afterwards. For a raw query, Visibility::current()->postStatuses() gives the statuses in view (null when all are).
Writing posts and terms
Writes go through Kitloom\WpSchema\Content\PostWriter and TermWriter. They do what wp_insert_post() and wp_insert_term() do: the slug is made unique, the dates are filled in, a post dated ahead is scheduled, terms and the featured image go in the same transaction. Pass the actor — the user the write is done as — and the writer refuses, with an AuthorizationException, whatever that user's capabilities do not allow. Without an actor the writer is the system (an import, a seeder, the console) and checks nothing.
php
use Kitloom\WpSchema\Content\PostWriter;
$writer = app(PostWriter::class);
$book = $writer->create('book', [
'title' => 'Dune',
'content' => '<p>Spice.</p>',
'status' => 'publish',
], $user, terms: ['genre' => [12]], featuredImage: 345);
$writer->update($book, ['title' => 'Dune Messiah'], $user);
$writer->trash($book, $user);
$writer->restore($book, $user);PostWriter | |
|---|---|
create(string|Post $type, array $attributes, ?Authenticatable $actor = null, array $terms = [], int|string|null $featuredImage = null): Post | a new post: a draft by the actor, dated now, unless the attributes say otherwise |
update(Post $post, array $attributes, ?Authenticatable $actor = null, ?array $terms = null, int|string|false|null $featuredImage = false): Post | null terms leave them as they are; false leaves the image, null removes it |
stage(Post $post, ?Authenticatable $actor = null): void | everything but the save, for an admin that saves the model itself |
syncTerms(Model $post, string $taxonomy, array $termIds, ?Authenticatable $actor = null): void | the post's terms of one taxonomy become exactly these |
setFeaturedImage(Post $post, int|string|null $attachmentId, ?Authenticatable $actor = null): void | |
trash(), restore(), delete() | delete() is for good |
TermWriter has create(string|TermTaxonomy $taxonomy, array $attributes, ?Authenticatable $actor = null) with name (required), slug, description, parent and status; update(), stage(), trash(), restore() and delete() take the term and the actor.
Attributes are the plain names or the columns. A module's agent tool, an API controller and an admin action all pass the user they act for.
Meta
Meta goes through the model. Values are read the way get_post_meta($id, $key, true) reads them — the first row of the key — from the model's meta relation, loaded once for all keys:
php
$book->getMeta('isbn'); // ?string — null when there is no such meta
$book->getMeta('isbn', ''); // with a default
$book->getMetaInt('pages'); // ?int — the default when missing or not a number
$book->getMetaBool('featured'); // 1/0, yes/no, on/off, true/false
$book->getMetaArray('quotes'); // array — JSON or serialized, empty when it holds none
$book->hasMeta('isbn');Eager-load the meta for a list — WpSchema::query('post')->with('meta') — or each post runs a query of its own.
Writing goes straight to the table and into what was loaded, so the next read sees it:
php
$book->setMeta('pages', 412); // every row of the key gets the value, as update_post_meta()
$book->setMeta('featured', true); // stored as 1
$book->setMeta('quotes', ['…', '…']); // stored as JSON
$book->setMeta('isbn', null); // null deletes the key
$book->deleteMeta('isbn');Terms (TermTaxonomy), users (with IsWordPressUser) and comments have the same methods; each knows its own meta table.
In a Filament form, bind a field to a meta key with Kitloom\WpSchema\Filament\Forms\MetaField::bind($field, 'isbn'). In a Nova post resource, list the meta in protected static array $metaFields (request key => meta key) and the resource writes it after the post is saved.
Naming meta keys
- A key WordPress or a plugin already uses — use it as is, so migrated data keeps working:
_thumbnail_id,rank_math_title,_price. - A key of your own: short and plain (
isbn). Start it with_when editors should not see it as a custom field — WordPress hides_-prefixed meta from its Custom Fields box. - Name it once, as a constant.
Custom fields
Fields made in the admin with kitloom/custom-fields are read and written through the model. Values come back the way ACF's get_field() returns them unformatted: ids stay ids, a repeater is a list of rows.
php
$book->field('subtitle'); // 'A novel'
$book->setField('subtitle', 'A novel', $user); // checked as the edit form checks it
$book->setFields(['rating' => 4.5, 'featured' => true], $user);
use Kitloom\CustomFields\Facades\Fields;
Fields::get($model, 'subtitle');
Fields::option('footer_text'); // the options screen
Fields::set($model, 'subtitle', 'A novel', $user);A value the form would refuse throws a ValidationException keyed by the field's path; with an actor, the write is refused unless the user may edit the record. A saved post's fields announce the post as saved (PostSaved). Without kitloom/custom-fields installed, field() reads nothing and setField() throws.
Options
Site settings are rows of the options table — wp_options on a site moved from WordPress.
php
use Kitloom\Settings\Facades\Settings;
Settings::get('blogname');
Settings::get('reviews_out_of', 5); // see the order below
Settings::array('reviews_sources'); // an array, whether stored as JSON or serialized
Settings::has('reviews_out_of');get() answers the autoloaded value, then the setting's declared default, then a value stored without autoload, then the argument.
Options are read in one query, once per request, and only when something asks; Settings::refresh() drops what was read, and every write refreshes it for you. Read options at the moment you need them, not in the constructor of a singleton — under Octane the singleton outlives the request.
Writing goes through Kitloom\Settings\SettingWriter: a string as is, a yes/no as 1/0, an array as JSON.
php
use Kitloom\Settings\SettingWriter;
$writer = app(SettingWriter::class);
$writer->put('reviews_out_of', 10); // the system
$writer->put('reviews_sources', ['imdb', 'goodreads']); // stored as JSON
$writer->put('reviews_out_of', 10, $user); // asks the Gate first
$writer->forget('reviews_out_of');With an actor, the setting's declared permission is asked through the Gate (manage_options for one nobody declared) and an AuthorizationException is thrown when refused. A settings page in either admin writes through the writer for you.
Declaring settings
A module declares its settings, so they have types, defaults and rules, and show in php artisan settings:list. A Kitloom\Settings\Contracts\SettingsProvider yields SettingDefinitions and is tagged in register():
php
use Kitloom\Settings\Contracts\SettingsProvider;
use Kitloom\Settings\SettingDefinition;
use Kitloom\Settings\SettingRegistry;
final class ReviewsOptions implements SettingsProvider
{
public function settings(): iterable
{
yield new SettingDefinition(
name: 'reviews_out_of',
default: 5,
label: 'Ratings out of',
group: 'Reviews',
type: SettingDefinition::TYPE_INT,
rules: ['min:1', 'max:100'],
permission: 'manage_reviews',
);
}
}
$this->app->tag(ReviewsOptions::class, SettingRegistry::PROVIDER_TAG);Types are string (the default), int, bool, array and json. A declared setting is written as its type reads it and checked against its rules whoever writes it — a refused value throws a ValidationException keyed by the setting's name. protected: true keeps a setting to the system: it is never written for a user.
Name options after the module (reviews_out_of), never with the table prefix — that is the connection's.
How arrays are stored
WordPress stores arrays with PHP's serialize(). kitloom reads that format and writes JSON:
| Reads | Writes | |
|---|---|---|
| Arrays and maps | JSON, or a:2:{…} as WordPress wrote it | JSON |
| Booleans | 1/0 | 1/0 |
So a site moved from WordPress works as it is, and a value becomes JSON the next time it is saved. Serialized data is read with allowed_classes => false: a stored value cannot create objects.
Use the helpers instead of unserialize()/json_decode():
php
use Kitloom\WpSchema\Support\StoredArray;
StoredArray::decode($stored); // ?array — null when it holds no array
StoredArray::toArray($stored); // array — empty when it holds none
StoredArray::encode($array); // JSONgetMetaArray(), setMeta(), Settings::array() and SettingWriter do the same for meta and options. A module that keeps a plugin's data — Rank Math's redirect sources, ACF's field settings — reads both formats and writes JSON like the rest.
The one exception is php artisan wordpress:install, which writes WordPress's own format on purpose: it prepares a database for WordPress itself.
Raw SQL and the table prefix
The connection adds the table prefix (wp_ or any other) to every table the query builder names — DB::table('posts'), ->from('postmeta as file'), joins, Eloquent models. Raw SQL is the place where it does not: there the prefix is yours to add.
Laravel 13 types whereRaw(), selectRaw(), orderByRaw() and DB::raw() as literal strings. SQL built at runtime — with the prefix in it, or a column chosen by code — goes through Kitloom\WpSchema\Support\RawSql::of():
php
use Kitloom\WpSchema\Support\RawSql;
$grammar = $db->getQueryGrammar();
$prefix = $db->getTablePrefix();
$query->whereRaw(RawSql::of($grammar->wrap($column).' LIKE ?'), ['%'.$word.'%']);
$query->whereRaw(RawSql::of("instr({$prefix}content.post_content, {$prefix}file.meta_value) > 0"));RawSql only says "this string is SQL"; every value is still a binding. Option and meta names that WordPress builds with the prefix — {prefix}user_roles, {prefix}capabilities — take it from the same getTablePrefix().
Tables of your own
When a module's data is not posts or meta, give it a table — a migration that creates it only when it is missing, so a site that had the plugin keeps its rows:
php
if (! Schema::hasTable('rank_math_redirections')) {
Schema::create('rank_math_redirections', function (Blueprint $table): void {
// the plugin's columns, as the plugin creates them
});
}- Name it like the plugin it replaces, or after the module. The connection adds the site's prefix.
- Load the migrations from the provider (
loadMigrationsFrom): the application has nothing to publish. - Put the model in the registry under a key of your own.
- Store arrays in it as JSON, like everywhere else.
Events
React to content with the events of Kitloom\WpSchema\Events, not with eloquent.*: they come from every post, taxonomy and comment model, whoever declared it, and trash and restore are not Eloquent events at all.
| Package | Events |
|---|---|
kitloom/wp-schema | PostSaved, PostPublished, PostTrashed, PostRestored, PostDeleted, TermSaved, TermTrashed, TermRestored, TermDeleted, CommentPosted, CommentStatusChanged |
kitloom/settings | Kitloom\Settings\Events\SettingSaved, SettingDeleted — every option write, with the actor |
kitloom/permissions | Kitloom\Permissions\Events\RoleSaved, RoleDeleted, UserRoleChanged |
php
Event::listen(PostSaved::class, function (PostSaved $event): void {
if ($event->post->type === 'book') {
// …
}
});See Extending kitloom for the rest of the extension points.