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.