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.
Models
The WordPress tables are Eloquent models in kitloom/wp-schema, 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 |
|---|---|---|
post, attachment | posts | Models\Post, Models\Attachment |
post_meta | postmeta | Models\PostMeta |
term, term_taxonomy, term_meta | terms, term_taxonomy, termmeta | Models\Term, Models\TermTaxonomy, Models\TermMeta |
comment, comment_meta | comments, commentmeta | Models\Comment, Models\CommentMeta |
user, user_meta | users, usermeta | the application's user, Models\UserMeta |
option | options | Models\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 ModelRegistry
Kitloom\WpSchema\ModelRegistry holds the class for each key. Three parties register classes — the packages, the application's config('kitloom-wp-schema.models'), a module or a theme with keys of its own — 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).useris free: 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,
],A module's own models
A module puts its models in the registry under keys of its own, so an application can swap them, and reads the class back from the registry everywhere — in its admin resources too:
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: the resource's model follows the registry
$resource::$model = $this->app->make(ModelRegistry::class)->class(Review::KEY);A module's records that are posts of its own type are a model extending Post with a global scope on post_type — what kitloom:make-module --post-type generates — declared to the site with PostTypeObject(model: Review::class).
Meta
Reading
php
$post->getMetaValue('rating'); // ?string — null when there is no such meta
$term->getMetaValue('color'); // term_taxonomy rows have meta too
$post->meta; // all rows, through the registry's post_meta modelgetMetaValue() reads from the loaded meta relation. Eager-load it for a list — WpSchema::query('post')->with('meta') — or each post runs a query of its own.
Meta values are strings, as WordPress keeps them. Turn them into types on the model, with an accessor:
php
protected function rating(): Attribute
{
return Attribute::get(fn (): int => (int) $this->getMetaValue('rating'));
}Custom fields made with kitloom/custom-fields are read with CustomFields::get($post, 'subtitle') or $post->acf_subtitle, which decode them by their field type.
Writing
In a Filament form, bind the field to a meta key — it reads, writes, and deletes the meta when the field is emptied:
php
MetaField::bind(TextInput::make('rating')->numeric(), 'rating');
// with conversions
MetaField::bind(
Toggle::make('featured'),
'featured',
toState: fn (?string $value): bool => $value === '1',
toStored: fn (mixed $value): string => $value ? '1' : '0',
);In a Nova post resource, list the meta in $postMeta (request key => meta key) and the resource writes it after the post is saved. Elsewhere:
php
use Kitloom\WpSchema\Filament\Forms\MetaField;
MetaField::write($post, 'rating', '4'); // null deletes itor the meta model itself:
php
WpSchema::query('post_meta')->updateOrCreate(
['post_id' => $post->ID, 'meta_key' => 'rating'],
['meta_value' => '4'],
);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 (
rating). 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.
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); // the stored value, then a declared default, then this one
Settings::array('reviews_sources'); // an array, whether stored as JSON or serialized
Settings::has('reviews_out_of');Options are read in one query and cached; Settings::refresh() drops the cache, and the writer below refreshes it for you. Read options at the moment you need them, not in a constructor of a singleton — under Octane the singleton outlives the request.
Writing:
php
use Kitloom\Settings\SettingWriter;
app(SettingWriter::class)->put('reviews_out_of', 10);
app(SettingWriter::class)->put('reviews_sources', ['imdb', 'goodreads']); // stored as JSON
app(SettingWriter::class)->forget('reviews_out_of');A settings page in either admin does this for you: each field writes the option named by the field.
Name options after the module (reviews_out_of, kitloom_inactive_modules), 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); // JSONSettings::array() and SettingWriter do the same for 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.
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.
- Put the model in the registry under a key of your own.
- Store arrays in it as JSON, like everywhere else.