A fluent SQL query builder for WordPress. Built on $wpdb, with meta table support, relations, migrations, and a typed model layer
composer require alaca/queryableUse DB::table() for direct queries
use Queryable\DB;
// Simple queries
$posts = DB::table('posts')->select('ID', 'post_title')->where('post_status', 'publish')->getAll();
$user = DB::table('users')->find('ID', 1);
// If you want to work with meta and relations using DB class, you will have to configure the schema in DB::init()
DB::init([
'schema' => [
'posts' => [
'meta' => [
'table' => 'postmeta', // table name is auto prefixed
'foreignKey' => 'post_id',
'primaryKey' => 'ID',
'aliases' => [
'price' => '_product_price',
'color' => '_product_color',
],
],
'relations' => [
'comments' => [
'table' => 'comments',
'foreignKey' => 'comment_post_ID',
'primaryKey' => 'ID',
'type' => 'hasMany',
],
],
],
],
]);
$products = DB::table('posts')
->select('ID', 'post_title')
->withMeta('price', 'color')
->where('post_type', 'product')
->orderBy('price', 'DESC')
->getAll();Define a model class per table with typed public properties
use Queryable\Model;
use Queryable\Schema\Table;
class Campaign extends Model
{
protected string $table = 'campaigns';
protected string $version = '1.0.0';
public int $id;
public string $name;
public string $slug;
public float $price;
public int $stock;
public ?string $created_at = null;
protected function meta(): array
{
return [
'table' => 'campaign_meta',
'foreignKey' => 'campaign_id',
'primaryKey' => 'id',
'aliases' => [
'budget' => '_campaign_budget',
'channel' => '_campaign_channel',
'tags' => [
'key' => '_campaign_tags',
'multiple' => true
],
],
];
}
protected function relations(): array
{
return [
'entries' => [
'table' => 'campaign_entries',
'foreignKey' => 'campaign_id',
'primaryKey' => 'id',
'type' => 'hasMany',
],
];
}
}
// Schema is defined separately
Campaign::schema(function (Table $table) {
$table->id();
$table->string('name');
$table->string('slug', 100)->unique();
$table->decimal('price', 8, 2)->default(0);
$table->integer('stock')->default(0);
$table->datetime('created_at')->nullable();
});
// Query results are Campaign instances
$campaign = Campaign::query()->find('slug', 'summer');
$campaign instanceof Campaign; // true
$campaign->name;
$campaign->price; // float, auto-cast from DB - should be unsigned int in real app!
Campaign::query()->select('id', 'name')->withMeta('budget')->where('status', 'active')->getAll();
Campaign::query()->insert(['name' => 'Summer Sale', 'slug' => 'summer-sale']);
Campaign::query()->where('id', 1950)->update(['name' => 'Updated']);
Campaign::query()->where('id', 1950)->delete();- Base Query
- Scopes
- Select
- Save
- Lifecycle Hooks
- Insert
- Update
- Delete
- Where Clauses
- Joins
- Ordering, Grouping, Limit & Offset
- Having
- Union
- Aggregates
- Find & Exists
- Pluck
- Meta Tables
- Relations
- Migrations
- Transactions
- Raw Queries
- Conditional Clauses
- Clone
- SQL Output
Apply default conditions to every query a model makes. Useful for models that represent a subset of a shared table
class Product extends Model
{
protected string $table = 'posts';
protected string $primaryKey = 'ID';
public int $ID;
public string $post_title;
public string $post_status;
protected static function baseQuery(ModelQueryBuilder $builder): ModelQueryBuilder
{
return $builder->where('post_type', 'product');
}
}
// Every query automatically includes WHERE post_type = 'product'
Product::query()->where('post_status', 'publish')->getAll();
Product::query()->find('ID', 1911);
Product::query()->count('ID');Define reusable query filters as static methods on your model
class Campaign extends Model
{
protected string $table = 'campaigns';
public static function active(): ModelQueryBuilder
{
return static::query()->where('status', 'active');
}
public static function p2p(): ModelQueryBuilder
{
return static::query()->where('campaign_type', 'p2p');
}
}
Campaign::active()->getAll();
Campaign::active()->orderBy('name')->limit(10)->getAll();
Campaign::p2p()->getAll();Create or update a model instance
// Create new
$campaign = Campaign::make();
$campaign->name = 'Summer';
$campaign->slug = 'summer';
$campaign->price = 29.99;
$campaign->save();
// make() also takes an optional attributes array, applied the same way
// $model['key'] = $value would be: a declared property is set directly,
// anything else lands in the extras bag.
$campaign = Campaign::make(['name' => 'Summer', 'slug' => 'summer']);
$campaign->save();
// Update existing
$campaign = Campaign::query()->find('id', 1950);
$campaign->name = 'Updated';
$campaign->save();The primary key defaults to id. Override $primaryKey in your model if needed:
class Post extends Model
{
protected string $table = 'posts';
protected string $primaryKey = 'ID';
}save() reads the primary key to decide between an insert and an update, so it
needs the database to generate that key. On a table with an AUTO_INCREMENT
column that is what happens. On any other table the decision is wrong in one
direction or the other and wrong silently, so save() refuses those tables and
insert() writes them instead:
$value = EntryValue::make(['entry_id' => 9, 'field_key' => 'fld_00000000000a']);
$value->insert();insert() writes every column the model holds, including the key columns, and
never reads the key back. There is no create-or-update helper: making a read and
a write atomic has to be stated in SQL (INSERT ... ON DUPLICATE KEY UPDATE),
and this layer cannot do it for you.
Override onBeforeSave() and onSave() to run logic before and after save()
class Campaign extends Model
{
protected string $table = 'campaigns';
protected function onBeforeSave(): void
{
if (empty($this->slug)) {
$this->slug = sanitize_title($this->name);
}
}
protected function onSave(): void
{
do_action('campaign_saved', $this);
}
}onBeforeSave() runs before the database write. Any property changes made there are included in the save
onSave() runs after the database write
// Single row
Campaign::query()->insert(['name' => 'Summer', 'slug' => 'summer']);
// Returns: QueryResult { affectedRows: 1, insertId: 1 }
// bulk insert
Campaign::query()->insert([
['name' => 'Summer', 'slug' => 'summer'],
['name' => 'Winter', 'slug' => 'winter'],
]);
// insert with meta (meta fields are separated automatically)
Campaign::query()->withMeta('budget', 'channel')->insert([
'name' => 'Summer',
'slug' => 'summer',
'budget' => '1950', // campaign_meta table
'channel' => 'email', // campaign_meta table
]);
// upsert (insert or update on duplicate key)
Campaign::query()->upsert(
['name' => 'Summer', 'slug' => 'summer'],
['slug'], // conflict columns
['name'], // columns to update
);The INSERT column list is always backtick-quoted, so a data key named after a reserved word (cursor, trigger, ...) still compiles to valid SQL.
Campaign::query()->where('id', 1)->update(['name' => 'Updated']);
// update meta
Campaign::query()->withMeta('budget')->where('id', 1)->update([
'name' => 'Updated',
'budget' => '1911',// updates meta row
]);
// Increment / Decrement
Campaign::query()->where('id', 1)->increment('stock');
Campaign::query()->where('id', 1)->increment('stock', 5);
Campaign::query()->where('id', 1)->decrement('stock', 3);
// raw update
Campaign::query()->where('id', 1)->updateRaw("stock = stock + 1");The UPDATE SET clause quotes column names the same way the INSERT column list does.
Campaign::query()->where('id', 1)->delete();
// meta rows are deleted automatically if the model has meta config
// truncate (clears both the table and meta table)
Campaign::query()->truncate();// all columns
Campaign::query()->getAll();
// select specific columns
Campaign::query()->select('id', 'name', 'slug')->getAll();
// single row (LIMIT 1, returns null if not found)
Campaign::query()->select('id', 'name')->get();
// distinct
Campaign::query()->distinct()->select('status')->getAll();
// select with alias
Campaign::query()->select(['id' => 'campaign_id'])->getAll();
// raaw select
Campaign::query()->selectRaw('COUNT(*) as total')->get();
// Subquery in FROM
DB::table(function ($qb) {
$qb->table('orders')->select('user_id')->groupBy('user_id');
},
'order_totals')->selectRaw('user_id, total')->getAll();// basic
Campaign::query()->where('status', 'active')->getAll();
Campaign::query()->where('status', 'active')->orWhere('status', 'pending')->getAll();
// LIKE
Campaign::query()->whereLike('name', 'summer')->getAll(); // LIKE '%summer%'
Campaign::query()->whereLike('name', 'summer%')->getAll(); // LIKE 'summer%'
// IN / NOT IN
Campaign::query()->whereIn('id', [1, 2, 3])->getAll();
Campaign::query()->whereNotIn('status', ['draft', 'trash'])->getAll();
// BETWEEN
Campaign::query()->whereBetween('price', 10, 100)->getAll();
// NULL
Campaign::query()->whereIsNull('deleted_at')->getAll();
Campaign::query()->whereIsNotNull('email')->getAll();
// column comparison (no value escaping), useful for JOINS
Campaign::query()->whereColumn('users.id', 'orders.user_id')->getAll();
// nested groups
Campaign::query()->where('status', 'active')
->orWhere(function ($qb) {
$qb->where('role', 'admin')->where('verified', 1);
})
->getAll();
// WHERE status = 'active' OR (role = 'admin' AND verified = 1)
// Subquery
Campaign::query()->whereIn('id', function ($qb) {
$qb->table('orders')->select('campaign_id')->where('total', 100, '>');
})->getAll();
// EXISTS
Campaign::query()->whereExists(function ($qb) {
$qb->table('orders')->select('id')->whereRaw('orders.campaign_id = campaigns.id');
})->getAll();
// Raw
Campaign::query()->whereRaw('created_at > NOW() - INTERVAL 30 DAY')->getAll();All where methods have or variants: orWhere, orWhereLike, orWhereIn, orWhereBetween, orWhereIsNull, orWhereExists, orWhereColumn.
Campaign::query()->leftJoin('entries', 'campaigns.id', 'entries.campaign_id')->getAll();
Campaign::query()->innerJoin('entries', 'campaigns.id', 'entries.campaign_id')->getAll();
Campaign::query()->rightJoin('entries', 'campaigns.id', 'entries.campaign_id')->getAll();
Campaign::query()->crossJoin('statuses')->getAll();
// with alias
Campaign::query()->leftJoin('entries', 'campaigns.id', 'e.campaign_id', 'e')->getAll();
// raw join
Campaign::query()->joinRaw('LEFT JOIN entries e ON campaigns.id = e.campaign_id')->getAll();Campaign::query()->orderBy('name')->getAll();
Campaign::query()->orderBy('name', 'DESC')->getAll();
Campaign::query()->orderByRaw('RAND()')->getAll();
Campaign::query()->groupBy('status')->getAll();
Campaign::query()->groupBy('status', 'channel')->getAll();
Campaign::query()->groupByRaw('YEAR(created_at)')->getAll();
Campaign::query()->limit(10)->offset(20)->getAll();
// order and group by meta keys
Campaign::query()->withMeta('budget')->orderBy('budget', 'DESC')->getAll();
Campaign::query()->withMeta('channel')->groupBy('channel')->getAll();Campaign::query()->groupBy('status')->havingCount('id', '>', 5)->getAll();
Campaign::query()->groupBy('status')->havingSum('stock', '>', 100)->getAll();
Campaign::query()->groupBy('status')->havingAvg('price', '>', 50)->getAll();
Campaign::query()->groupBy('status')->havingMin('price', '>', 10)->getAll();
Campaign::query()->groupBy('status')->havingMax('price', '<', 1000)->getAll();
Campaign::query()->groupBy('status')->havingRaw('COUNT(id) > 5')->getAll();
// combine with OR
Campaign::query()->groupBy('status')
->havingSum('stock', '>', 100)
->orHavingAvg('price', '>', 200)
->getAll();$drafts = Campaign::query()->where('status', 'draft')->select('id', 'name');
Campaign::query()->where('status', 'active')->select('id', 'name')->union($drafts)->getAll();
// Union ALL
Campaign::query()->select('id')->unionAll($drafts)->getAll();Campaign::query()->count('id'); // int
Campaign::query()->sum('price'); // float
Campaign::query()->avg('price'); // float
Campaign::query()->min('price'); // float
Campaign::query()->max('price'); // float
// with conditions
Campaign::query()->where('status', 'active')->count('id');// find by column. returns model instance or null
Campaign::query()->find('id', 1950);
Campaign::query()->find('slug', 'summer');
// check if rows exist. returns bool
Campaign::query()->where('slug', 'summer')->exists();Returns a flat array of a single column values:
Campaign::query()->pluck('name');
// ['Save the dolphins', 'All alcoholic beverages 20% off', 'Yeah, no']Meta tables follow the WordPress pattern: a separate table with meta_key and meta_value columns linked by a foreign key
Define meta config in your models meta() method or in DB::init():
protected function meta(): array
{
return [
'table' => 'campaign_meta', // meta table name (auto-prefixed)
'foreignKey' => 'campaign_id', // FK in meta table
'primaryKey' => 'id', // PK in main table
'aliases' => [ // meta keys aliases
'budget' => '_campaign_budget',
'channel' => '_campaign_channel',
'tags' => [
'key' => '_campaign_tags',
'multiple' => true
],
],
];
}Aliases let you use friendly names instead of ugly meta key names:
// without aliases, you have to write
->withMeta('_campaign_budget')
// with aliases
->withMeta('budget')Some meta keys can have multiple rows with the same key. Mark these with 'multiple' => true:
'aliases' => [
'budget' => '_campaign_budget', // single value
'tags' => [
'key' => '_campaign_tags',
'multiple' => true // multiple values
],
],Single values
$campaign = Campaign::query()->select('id')->withMeta('budget')->get();
$campaign->budget; // 5000Multiple values are parsed into arrays:
$campaign = Campaign::query()->select('id')->withMeta('tags')->groupBy('id')->get();
$campaign->tags; // ['promo', 'seasonal', 'email']withMeta() makes meta keys available in select, where, orderBy, groupBy
// get specific keys
Campaign::query()->select('id', 'name')
->withMeta('budget', 'channel')
->where('budget', 1000, '>') // WHERE meta_budget.meta_value > 1000
->orderBy('budget', 'DESC') // ORDER BY meta_budget.meta_value DESC
->groupBy('channel') // GROUP BY meta_channel.meta_value
->getAll();
// get campaign with all meta keys
Campaign::query()->select('id', 'name')->withMeta()->getAll();// Insert: meta fields are auto-separated from table columns
Campaign::query()->withMeta('budget', 'tags')->insert([
'name' => 'Summer',
'slug' => 'summer',
'budget' => '1911', // 1 row in campaign_meta
'tags' => ['promo', 'seasonal'], // 2 rows in campaign_meta
]);
// Update: single meta upserted, multiple meta replaced
Campaign::query()->withMeta('budget', 'tags')->where('id', 1)->update([
'budget' => '1950', // updates existing meta row
'tags' => ['promo', 'holiday'], // deletes old rows, inserts new
]);
// Delete: meta rows are deleted before campaign
Campaign::query()->withMeta('budget')->where('id', 1)->delete();Relations define how tables are connected. The parent table's rows are fetched first, then related rows are loaded in a separate query and attached
protected function relations(): array
{
return [
'entries' => [
'table' => 'campaign_entries',
'foreignKey' => 'campaign_id',
'primaryKey' => 'id',
'type' => 'hasMany', // hasMany | hasOne | belongsTo
],
];
}// eager load relations with with()
$campaign = Campaign::query()->select('id', 'name')->with('entries')->get();
// hasMany returns an array
$campaign->entries;
// [
// ['id' => 1, 'email' => 'a@test.com'],
// ['id' => 2, 'email' => 'b@test.com'],
// ]
// hasOne returns single row or null
$campaign->profile; // ['id' => 1, 'bio' => '...'] or nullRelations work with both get() and getAll().
Each model defines its table structure via schema() and a $version string. Calling migrate() runs dbDelta()
Schema is defined separately from the model class using a static callback
class Campaign extends Model
{
protected string $table = 'campaigns';
protected string $version = '1.0.0';
public int $id;
public string $name;
// ...
}
Campaign::schema(function (Table $table) {
$table->id();
$table->string('name');
$table->string('slug', 100)->unique();
$table->decimal('price', 8, 2)->default(0);
$table->integer('stock')->default(0);
$table->boolean('active')->default(true);
$table->text('description')->nullable();
$table->datetime('created_at')->nullable();
});When you change the schema, bump the version
protected string $version = '1.1.0'; // added 'priority' columnCall migrate() on plugin activation:
register_activation_hook(__FILE__, function () {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
Campaign::migrate();
CampaignEntry::migrate();
});Force migration regardless of version
Campaign::migrate(true);| Method | MySQL Type |
|---|---|
$table->id() |
BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY |
$table->string('name') |
VARCHAR(255) NOT NULL |
$table->string('code', 50) |
VARCHAR(50) NOT NULL |
$table->char('field_key', 16) |
CHAR(16) NOT NULL |
$table->binary('content_hash', 32) |
BINARY(32) NOT NULL |
$table->text('body') |
TEXT NOT NULL |
$table->longText('content') |
LONGTEXT NOT NULL |
$table->integer('count') |
INT NOT NULL |
$table->bigInteger('views') |
BIGINT NOT NULL |
$table->smallInteger('row_index') |
SMALLINT NOT NULL |
$table->tinyInteger('priority') |
TINYINT NOT NULL |
$table->float('rating') |
FLOAT NOT NULL |
$table->decimal('price', 8, 2) |
DECIMAL(8,2) NOT NULL |
$table->boolean('active') |
TINYINT(1) NOT NULL |
$table->date('birth_date') |
DATE NOT NULL |
$table->datetime('published_at') |
DATETIME NOT NULL |
$table->timestamp('verified_at') |
TIMESTAMP NOT NULL |
$table->json('settings') |
JSON NOT NULL |
$table->enum('status', ['a', 'b']) |
ENUM('a','b') NOT NULL |
Models with array properties on json() columns roundtrip transparently. Arrays are JSON-encoded on save and decoded on read.
class Donation extends Model
{
protected string $table = 'donations';
public int $id;
public ?array $gateway_metadata = null;
public ?array $source_attribution = null;
}
Donation::schema(function (Table $t) {
$t->id();
$t->json('gateway_metadata')->nullable();
$t->json('source_attribution')->nullable();
});
$d = Donation::make();
$d->gateway_metadata = ['stripe' => 'pi_123', 'fee' => 25];
$d->save();
$reloaded = Donation::query()->find('id', $d->id);
$reloaded->gateway_metadata; // ['stripe' => 'pi_123', 'fee' => 25]Notes:
nullstaysnullin both directions.- If the property type is
?string, the user supplied string is written verbatim. - Encoding uses
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHESso multibyte characters and URLs stay readable in the DB. - Column metadata is derived once per class per request from the registered
schema()callback and cached.
$table->string('email')->unique();
$table->integer('stock')->default(0)->unsigned();
$table->datetime('deleted_at')->nullable();
$table->boolean('featured')->default(false);
$table->bigInteger('user_id')->unsigned()->references('users', 'ID')->onDelete('CASCADE');
$table->string('country', 2)->index();Available modifiers: ->nullable(), ->unique(), ->primary(), ->unsigned(), ->charset($charset, $collate = null), ->default($value), ->references($table, $column), ->onDelete($action), ->index($name = null)
->charset() emits CHARACTER SET x COLLATE y between the type and the NULL constraint, which is the only position MySQL accepts. Omit the second argument to take the character set's default collation.
$table->char('field_key', 16)->charset('ascii', 'ascii_bin');
$table->string('projection_state', 16)->charset('ascii');A composite primary key is declared on the table. It replaces every column-level ->primary(), including the implicit one id() sets, because a table may carry only one and dbDelta cannot drop one afterwards. Unknown column names throw InvalidArgumentException.
$table->primary(['entry_id', 'field_key', 'row_index', 'value_index']);
// PRIMARY KEY (`entry_id`, `field_key`, `row_index`, `value_index`)rowFormat() and collation() set the trailing table options. ROW_FORMAT=DYNAMIC is what raises the InnoDB index key limit from 767 to 3072 bytes.
$table->rowFormat('DYNAMIC');
$table->collation('utf8mb4', 'utf8mb4_unicode_520_ci');
// ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci ROW_FORMAT=DYNAMICA model declares the collation its tables are created with, rather than inheriting $wpdb:
class EntryValue extends Model
{
protected array $collation = ['utf8mb4', 'utf8mb4_unicode_520_ci'];
}Leave the property empty to fall back to whatever $wpdb reports.
Single-column indexes can be declared fluently on the column:
$table->string('country', 2)->index(); // KEY idx_country (`country`)
$table->string('country', 2)->index('cc_idx'); // KEY cc_idx (`country`)Composite indexes are declared on the table:
$table->index(['status', 'paid_at']); // KEY idx_status_paid_at (`status`,`paid_at`)
$table->index(['status', 'paid_at'], 'idx_pay_status'); // KEY idx_pay_status (`status`,`paid_at`)Composite unique indexes use Table::unique()
$table->unique(['gateway', 'external_id']); // UNIQUE KEY uk_gateway_external_id (`gateway`,`external_id`)
$table->unique(['gateway', 'external_id'], 'uk_gw_ext'); // UNIQUE KEY uk_gw_ext (`gateway`,`external_id`)When no name is supplied, names are generated as idx_ / uk_ + the column list joined with underscores, lowercased. Names longer than 64 characters (MySQL's identifier limit) are truncated with a 6-char hash suffix to stay collision-safe.
Output is dbDelta()-compatible: uppercase KEY on its own line with tight (col1,col2) formatting. Column names in compile()'s output (column definitions, PRIMARY KEY/UNIQUE KEY/KEY/FOREIGN KEY column lists) are always backtick-quoted, so a column named after a MySQL reserved word (trigger, cursor, ...) still compiles to valid DDL.
If the model has a meta() config, migrate() automatically creates the meta table
protected function meta(): array
{
return [
'table' => 'campaign_meta',
'foreignKey' => 'campaign_id',
'primaryKey' => 'id',
];
}
// Campaign::migrate() creates both tables// using a model
Campaign::transaction(function () {
Campaign::query()->insert(['name' => 'A', 'slug' => 'a']);
Campaign::query()->insert(['name' => 'B', 'slug' => 'b']);
});
// using DB facade
DB::transaction(function () {
DB::table('campaigns')->insert(['name' => 'A', 'slug' => 'a']);
DB::table('campaigns')->insert(['name' => 'B', 'slug' => 'b']);
});
// auto-commits on success, rollbacks if an exception is thrownDB::raw('SELECT * FROM wp_posts WHERE post_status = %s', ['publish']);Parameters use $wpdb->prepare() for escaping
Conditionally add clauses
$status = $_GET['status'] ?? null;
Campaign::query()->select('id', 'name')
->when($status, fn ($qb) => $qb->where('status', $status))
->getAll();Create an independent copy to build query variants
$base = Campaign::query()->select('id', 'name');
$active = $base->clone()->where('status', 'active');
$drafts = $base->clone()->where('status', 'draft');Get the generated SQL without executing:
Campaign::query()->select('id', 'name')->where('status', 'active')->toSQL();
// SELECT id, name FROM wp_campaigns WHERE status = 'active'