Skip to main content

Database: Getting Started

Introduction​

bitapps/wp-database is a small ActiveRecord ORM with a fluent query and schema builder on top of WordPress' $wpdb. Models map to tables, queries are built fluently, and results come back as hydrated model objects inside a Collection. The API is modelled on Eloquent's, but it is not Eloquent. This page and the Query Builder reference document exactly what is supported.

Installation​

The package is on Packagist:

composer require bitapps/wp-database

The namespace is BitApps\WPDatabase. Plugins built on this boilerplate prefix their dependencies at install time with imposter, so the import becomes YourPlugin\Deps\BitApps\WPDatabase.

Supported environment​

RequirementValue
PHP>= 8.0 (only runtime dependency)
DatabaseMySQL / MariaDB, via $wpdb
WordPressNo explicit minimum declared by the package. It needs the global $wpdb and runs inside a loaded WordPress environment
MySQL-specific SQL

Some features emit MySQL-specific SQL: upsert() generates INSERT … ON DUPLICATE KEY UPDATE, and Schema::renameColumn() emits RENAME COLUMN (MySQL 8.0+). The package is not portable to other databases.

Configuration​

There is no connection config. The package wraps the global $wpdb, so it works inside WordPress as-is. Optionally set a plugin-specific table prefix once during boot; it is appended after the WordPress prefix:

use BitApps\WPDatabase\Connection;

Connection::setPluginPrefix('myplugin_'); // tables resolve as wp_myplugin_*
Connection::getPrefix(); // wp_ + plugin prefix, e.g. "wp_myplugin_"
Connection::wpPrefix(); // WordPress prefix only, e.g. "wp_"

You can pin a timezone for generated created_at / updated_at timestamps:

use BitApps\WPDatabase\QueryBuilder;

QueryBuilder::$TIME_ZONE = 'UTC'; // otherwise WordPress' timezone settings are used

Defining a model​

Every query starts from a model. Extend Model; the table name is auto-derived from the class name (snake_cased, pluralised) unless $table is set, and the WordPress + plugin prefix is added automatically:

use BitApps\WPDatabase\Model;

class Contact extends Model
{
protected $table = 'contacts'; // optional; default derived from class name
protected $primaryKey = 'id'; // default 'id'

public $timestamps = true; // auto-maintain created_at / updated_at

// Mass-assignment allow-list. Omit to allow all attributes.
protected $fillable = ['first_name', 'last_name', 'email', 'status'];
}

Builder chains can start three ways:

Contact::query()->where('id', 1)->get(); // real static entry, best IDE support
Contact::where('id', 1)->get(); // static magic via __callStatic
(new Contact())->where('id', 1)->get(); // instance

Prefer Model::query(). It is a real static method returning a QueryBuilder, so autocomplete and click-through navigation work in every IDE.

See Models for the full property reference, casting, events and collections.

Running raw queries​

There are no Connection::select() / insert() / update() / delete() shortcut methods. Raw SQL runs through the builder's raw APIs, or straight through the $wpdb passthroughs.

rawPrepared​

For complex developer-authored SQL with dynamic structure and bound values, use rawPrepared(). Identifiers and sort directions go through typed markers; values go through $wpdb->prepare() placeholders:

Contact::query()->rawPrepared(
'SELECT {{identifier:column}} FROM {{identifier:table}}'
. ' WHERE {{identifier:status}} = %s'
. ' ORDER BY {{identifier:column}} {{direction:sort}}',
['active'],
[
'column' => 'wp_contacts.created_at',
'table' => 'wp_contacts',
'status' => 'wp_contacts.status',
],
['sort' => 'DESC']
);

rawPrepared() is a static-template boundary: the template must be written by a developer and must never come from request data. Rules the compiler enforces:

  • Structural markers: {{identifier:key}} and {{direction:key}} (compiled to ASC / DESC). Every map entry must be used and every marker must have a matching entry.
  • Value placeholders: unnumbered %s, %d, %f, %F only; %% for a literal percent. The binding count must match the placeholder count exactly.
  • Rejected tokens: single quotes, double quotes, backticks, #, --, /*, */, and every semicolon except one optional trailing terminator.

unsafeRaw​

For fully developer-controlled, reviewed SQL that needs syntax rawPrepared() rejects:

Contact::query()->unsafeRaw(
'SELECT `id` FROM `wp_contacts` WHERE `status` = %s',
['active']
);

A SELECT returns the result rows ($wpdb->last_result); any other statement returns the number of affected rows. raw() still exists but is deprecated; it delegates to unsafeRaw().

warning

Neither raw() nor unsafeRaw() makes an interpolated SQL string safe. Never concatenate request data into raw SQL; bind it with placeholders.

$wpdb passthroughs​

Connection forwards unknown static calls to $wpdb, so its native API remains available:

Connection::query('SET SESSION sql_mode = ""');
Connection::prepare('SELECT * FROM wp_contacts WHERE id = %d', [5]);
Connection::get_results('SELECT * FROM wp_contacts');

Connection::prop('insert_id'); // read any $wpdb property
Connection::prop('last_error');

Transactions​

Transactions are manual: start, then commit or roll back:

use BitApps\WPDatabase\Connection;

Connection::startTransaction();

try {
Contact::query()->insert(['email' => 'jane@example.com']);
Deal::query()->insert(['contact_id' => 1, 'amount' => 500]);

Connection::commit();
} catch (\Throwable $e) {
Connection::rollback();

throw $e;
}
note

There is no closure-based transaction() helper, no savepoints, and no nesting support. The startTransaction() / commit() / rollback() methods on QueryBuilder are deprecated; use Connection. MyISAM tables silently ignore transactions; the package does not check the storage engine.

Debugging & logging​

Inspecting a query's SQL​

$query = Contact::query()->where('status', 'active');

$query->toSql(); // "SELECT * FROM `wp_contacts` WHERE `wp_contacts`.`status` = %s"
$query->getBindings(); // ["active"]
$query->prepare(); // fully bound SQL via $wpdb->prepare()

toSql() returns the SQL with $wpdb placeholders; getBindings() returns the values in placeholder order; prepare() returns the final bound statement. There is no dd() or dump().

Query log​

Logging is opt-in and collects every executed query for the rest of the request:

Connection::enableQuery(); // start collecting

// ... run queries ...

Connection::queries(); // array of executed SQL strings
Connection::errors(); // array of $wpdb errors
note

There is no disableQuery() and the log is unbounded, so enable it for debugging only, not in production code paths.

Error handling​

Query builder execution does not throw on database errors; it returns false. Check the last error when a query fails:

$result = Contact::query()->insert(['email' => null]);

if ($result === false) {
error_log(Connection::prop('last_error'));
}

The schema builder is the opposite: Schema / Blueprint re-throw $wpdb errors as RuntimeException. Invalid builder input (bad operators, identifiers, join types) also throws RuntimeException immediately, before any SQL runs.

To run a query that is expected to fail without WordPress printing the error, wrap it:

Connection::suppressError(); // silence $wpdb and stash its current error state
Connection::query($sql);
Connection::restoreErrorState(); // put the previous state back
warning

The stashed state lives in a single static slot, so these calls do not nest: an inner suppressError() overwrites the outer one's saved state. Blueprint also uses them internally: a blueprint that is built but never executed leaves $wpdb->suppress_errors on.