Skip to content

Latest commit

 

History

History
877 lines (685 loc) · 32 KB

usage.md

File metadata and controls

877 lines (685 loc) · 32 KB

wp-database — Usage Guide

A small ActiveRecord ORM + fluent query/schema builder on top of WordPress' $wpdb. Models map to tables, queries are built fluently, results come back as hydrated model objects inside a Collection.

Intended audience: WordPress plugin developers comfortable with PHP and Composer. The API is modelled on Eloquent's; prior Eloquent experience is helpful but not required — every method used in this guide is documented in full here.


Install

Add the repository and require the package:

// composer.json
"repositories": [
  { "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-database" }
]
composer require bitapps/wp-database:dev-main

Namespace: BitApps\WPDatabase. (When bundled into a plugin with Strauss/ php-scoper the namespace is prefixed, e.g. BitApps\YourPlugin\Deps\BitApps\WPDatabase.)


Setup

The package wraps the global $wpdb, so it works inside WordPress with no connection config. Optionally set a plugin-specific table prefix once during boot — it is appended after the WordPress prefix (wp_ + plugin prefix):

use BitApps\WPDatabase\Connection;

Connection::setPluginPrefix('myplugin_'); // tables resolve as wp_myplugin_*

Other Connection helpers:

Connection::getPrefix();   // wp_ + plugin prefix
Connection::wpPrefix();    // wp_
Connection::enableQuery(); // start collecting executed queries
Connection::queries();     // array of executed queries (after enableQuery)
Connection::errors();      // array of query errors

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

use BitApps\WPDatabase\QueryBuilder;

QueryBuilder::$TIME_ZONE = 'UTC'; // otherwise wp_timezone_string()/gmt_offset is used

Defining models

Extend Model. Table name is auto-derived from the class name (pluralised, snake_cased) unless $table is set. 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'
    protected $prefix = '';          // overrides the plugin prefix when non-empty

    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', 'meta'];

    // Attribute casts (see "Attribute casting").
    protected $casts = [
        'meta'      => 'array',
        'is_active' => 'bool',
    ];

    // Soft deletes: delete() sets deleted_at instead of removing the row.
    // Reads exclude trashed rows by default; opt out with $soft_delete_scope = false.
    public $soft_deletes = true;
}
Property Purpose
$table Table name (without prefix). Auto-derived from the class name (pluralised, snake_cased) if unset.
$primaryKey Primary key column (default id).
$prefix When non-empty, replaces the plugin prefix for this model only. Table resolves to $wpdb->prefix . $prefix . $tableName. Leave empty (the default) to use the plugin prefix set via Connection::setPluginPrefix().
$fillable Mass-assignment allow-list. Unset = allow all non-timestamp, non-PK attributes.
$casts Map of column → cast type. See Attribute casting.
$timestamps Auto-set created_at/updated_at on insert/update (declared true in the base Model; set to false to disable). Requires the columns to exist — see timestamps() in docs/schema.md.
$soft_deletes Must be declared true on the model. delete() then sets deleted_at instead of removing the row. Reads exclude trashed rows by default. Use ->withTrashed() to include them, or ->onlyTrashed() to return only trashed rows. Declare public $soft_delete_scope = false; to opt out of the filter (reads return every row, including trashed). See Limitations and softDeletes() in docs/schema.md.

Creating records

// Instantiate, set, save
$contact = new Contact();
$contact->first_name = 'Ada';
$contact->email = 'ada@example.com';
$saved = $contact->save();        // returns the saved Model on success, false on failure

// Single-row insert — returns the created Model on success, false on failure
$contact = Contact::insert([
    'first_name' => 'Ada',
    'email'      => 'ada@example.com',
]);

// Bulk insert (array of rows) — always returns a Collection.
// Element type: Model on success (re-query hydrated); int (inserted ID) on
// the rare fallback where the post-insert re-query yields no rows.
$created = Contact::insert([
    ['first_name' => 'Ada',  'email' => 'ada@x.com'],
    ['first_name' => 'Grace','email' => 'grace@x.com'],
]);

// INSERT IGNORE — skip any row that collides with a unique/primary key. Single row or an array of rows.
Contact::query()->insertOrIgnore(['email' => 'ada@x.com', 'first_name' => 'Ada']);
Contact::query()->insertOrIgnore([
    ['email' => 'ada@x.com',   'first_name' => 'Ada'],
    ['email' => 'grace@x.com', 'first_name' => 'Grace'],
]);

save() inserts when the model is new and updates (dirty attributes only) when it already exists. On success it returns the model; on failure, false.

Array and object values are JSON-encoded (wp_json_encode) automatically across save(), update(), single-row and bulk insert(), and upsert() — pass the raw array/object, not a pre-encoded string.


Reading records

// find() applies WHERE conditions and returns a Collection — not a single Model
Contact::find(1);                        // by PK → Collection (empty if none)
Contact::find(['email' => 'a@x.com']);   // by attributes → Collection

// Use findOne() or first() when you want exactly one Model back
Contact::findOne(['email' => 'a@x.com']); // → Model or null
Contact::where('email', 'a@x.com')->first(); // → Model or null

Contact::first();                        // first row → Model or null
Contact::all();                          // all rows → Collection
Contact::get();                          // all rows → Collection
Contact::get(['id', 'email']);           // only some columns

Contact::where('is_active', 1)->get();   // filtered → Collection
  • get() always returns a Collection, whatever the row count — a LIMIT 1 read, a zero-row read and a failed query all included (the last two as an empty Collection). Callers never branch on the result shape.
  • find() sets WHERE conditions and calls get(), so it too always returns a Collection. It does not return a single Model, even when called with a single primary key.
  • For a single-row read use findOne(['col' => $val]) or chain ->first() on a builder. Both apply LIMIT 1 and return a Model, or null on a miss.
  • Test for rows with count($rows) > 0, not empty($rows) — an empty Collection is an object, so it is never empty().

The query builder

Every static call on a model opens a builder; chain freely. Use toSql() to inspect the SQL without executing.

Structured table, column, key, and alias inputs use simple identifier segments (letters, digits, and _; may start with a digit — host-generated WordPress prefixes such as 5c_ do — but never all digits). Qualified columns may join segments with dots where the API permits them. Do not pre-quote identifiers with backticks or pass expressions through structured arguments. Unknown table/schema qualifiers fail closed; register the table through the builder or use an explicit, reviewed raw API.

Select

Contact::select('id', 'email')->get();
Contact::select(['id', 'email'])->get();
Contact::select(['id', 'title AS t'])->get();       // column alias — qualifies `title`, keeps AS `t`
Contact::addSelect('phone')->get();                 // add to existing select
Contact::select('id', 'email')->distinct()->get();  // SELECT DISTINCT
Contact::selectRaw('COUNT(*) as total')->get();     // raw expression (NOT select())
Contact::selectRaw('SUM(amount) as amt', [])->get();

select() handles plain columns and column AS alias, back-tick-quoting them as identifiers. Pass raw SQL expressions or function calls (COUNT(*), …) to selectRaw() instead.

Projection channels compile in a stable order regardless of call order: structured select() / addSelect() columns first, framework-generated relation aggregate/existence columns next, and selectRaw() expressions last. Do not rely on method-call order to position a raw expression between generated relation columns.

Where

Contact::where('email', 'a@x.com')->get();              // col = value
Contact::where('age', '>=', 18)->get();                 // col op value
Contact::where('id', [1, 2, 3])->get();                 // value array → IN (...)
Contact::where('deleted_at', null)->get();              // null → IS NULL

Contact::where('a', 1)->where('b', 2)->get();           // AND
Contact::where('a', 1)->orWhere('b', 2)->get();         // OR

// Grouped / nested conditions via a closure
Contact::where('active', 1)
    ->where(function ($q) {
        $q->where('city', 'NYC')->orWhere('city', 'LA');
    })
    ->get();

// Helpers
Contact::whereIn('id', [1, 2, 3])->get();
Contact::whereNull('deleted_at')->get();
Contact::whereNotNull('email')->get();
Contact::whereBetween('age', 18, 65)->get();
Contact::orWhereBetween('score', 0, 50)->get();

// LIKE — pass the operator explicitly; the value is bound safely via %s
Contact::where('email', 'LIKE', '%@x.com')->get();

// Raw
Contact::whereRaw('YEAR(created_at) = %d', [2026])->get();
Contact::orWhereRaw('email LIKE %s', ['%@x.com'])->get();

Conditional clauses

Contact::query()
    ->when($search, fn ($q, $search) => $q->whereRaw('name LIKE %s', ["%{$search}%"]))
    ->when($onlyActive, fn ($q) => $q->where('is_active', 1), fn ($q) => $q->where('is_active', 0))
    ->get();

Ordering, grouping, having

Contact::orderBy('created_at')->desc()->get();
Contact::orderBy('name')->asc()->get();
Contact::orderByRaw('FIELD(status, %s, %s)', ['new', 'open'])->get();

Contact::groupBy('city')->having('city', '!=', '')->get();

A bare desc() or asc() call without a prior orderBy() defaults the order column to the model's primary key — e.g. Contact::desc()->get() emits ORDER BY id DESC.

Joins

Contact::query()
    ->join('orders', 'orders.contact_id', '=', 'contacts.id')
    ->leftJoin('notes', 'notes.contact_id', '=', 'contacts.id')
    ->get();
// also: rightJoin(), crossJoin(), on(), orOn()

// Compare a join column with bound values (never interpolated as SQL).
Contact::query()
    ->joinWhere('orders', 'orders.status', '=', 'open')
    ->onValue('orders.priority', '>=', 10)
    ->orOnValue('orders.kind', '=', 'featured')
    ->get();

// Developer-controlled expression. Bind every dynamic value.
Contact::query()
    ->join('orders', 'orders.contact_id', '=', 'contacts.id')
    ->onRaw('`wp_orders`.`created_at` < NOW()')
    ->orOnRaw('`wp_orders`.`priority` > %d', [10])
    ->get();

Pass unprefixed table names — join() prepends the model's full table prefix (the same one the model's own table uses, including wp_ for models with a custom $prefix). Qualify columns as table.column using the unprefixed table name: the builder resolves a qualifier that matches the model's own table or a joined table to its physical, prefixed name in select, where/having, ON, groupBy and orderBy. Already-prefixed names and registered aliases are recognized; unknown/schema-qualified names fail closed. Use table AS alias when declaring a join alias and from('alias') for the base-table alias.

from() aliases are supported for SELECT queries. UPDATE and DELETE reject a base-table alias before execution because their current compilers do not declare one; remove from() from write chains.

join(), on(), and orOn() are column-to-column APIs. They no longer guess whether the right operand is a number, quoted literal, or SQL function. Move constants to joinWhere() / onValue() and reviewed expressions to onRaw(). crossJoin() keeps this builder's four-operand conditional form and emits CROSS JOIN ... ON ..., which MySQL accepts as an inner-join synonym; it does not create an unqualified Cartesian product.

Limit / offset / pagination

Contact::take(10)->skip(20)->get();      // LIMIT 10 OFFSET 20

$page = Contact::where('is_active', 1)->paginate($pageNo = 1, $perPage = 20);
// [
//   'data'          => Collection,
//   'pages'         => 5,
//   'total'         => 93,
//   'current_total' => 20,
//   'current_page'  => 1,
//   'last_page'     => 5,
//   'per_page'      => 20,
// ]

Aggregates

Contact::where('is_active', 1)->count();   // int — always (returns 0, not null, on no rows)
Contact::max('score');                     // mixed|null — null when the result set is empty
Contact::min('score');                     // mixed|null — null when the result set is empty
Contact::avg('score');                     // mixed|null
Contact::sum('score');                     // mixed|null
Contact::aggregate('GROUP_CONCAT', 'tag'); // any bare-identifier function

aggregate() accepts any SQL function whose name is a bare identifier (letters, digits, underscore); other input is rejected. It does not emit COUNT(DISTINCT …) — distinct() is ignored by count()/paginate().

Inspecting the SQL

Contact::where('is_active', 1)->toSql();   // build the SQL string, do not run
Contact::where('is_active', 1)->prepare(); // prepared SQL (placeholders bound)

Collections

Multi-row reads return BitApps\WPDatabase\Collection, which implements ArrayAccess, IteratorAggregate, Countable and JsonSerializable — so foreach, $c[0], count($c) and json_encode($c) all work.

$contacts = Contact::where('is_active', 1)->get();

// Wrap an existing array in a Collection
$collection = Collection::make($items);

$contacts->map(fn ($c) => $c->email);
$contacts->filter(fn ($c) => $c->score > 50);
$contacts->pluck('email');          // Collection of emails
$contacts->first();                 // first item (optional callback + default)
$contacts->last();
$contacts->reduce(fn ($carry, $c) => $carry + $c->score, 0);
$contacts->reverse();
$contacts->all();                   // underlying plain array
$contacts->toArray();               // array of model arrays
$contacts->count();

Updating records

// On an existing model — set attributes then save; returns Model or false
$contact = Contact::findOne(['id' => 1]);
$contact->email = 'new@x.com';
$contact->save();        // returns the Model on success, false on failure

// Conditional bulk update — executes immediately, returns int (rows affected) or false
Contact::where('is_active', 0)->update(['status' => 'archived']);

update() on a builder executes immediately (it is not chainable). Set conditions before calling it. When updating a model instance through save(), the return value is the Model on success or false on failure.

Insert, update, save, bulk-insert, and upsert attribute keys must be simple identifiers. Bulk operations build a stable union of row keys in first-seen order and bind NULL for a key missing from a later row.


Deleting records

Contact::where('id', 1)->delete();         // DELETE ... WHERE id = 1

$contact = Contact::findOne(['id' => 1]);
$contact->delete();                        // deletes that row

Contact::destroy([1, 2, 3]);               // delete by primary keys
  • A delete() that produces no WHERE clause throws RuntimeException('SQL query is empty') — a guard against wiping the table. Use a raw TRUNCATE if you really mean to empty it.
  • With $soft_deletes = true, delete() sets deleted_at instead of removing the row, and reads exclude trashed rows by default. Use ->withTrashed() to include them, or ->onlyTrashed() to return only trashed rows. To opt out of the automatic filter (reads return every row, including trashed), declare public $soft_delete_scope = false;. See Limitations.
  • On a soft-delete model, forceDelete() emits a real DELETE (bypassing the soft rewrite) and restore() clears deleted_at. Both throw on a model without $soft_deletes.
Contact::where('id', 1)->forceDelete();    // real DELETE, even for soft-delete models
Contact::onlyTrashed()->where('id', 1)->restore();  // deleted_at = NULL

Upsert

Insert rows, updating on duplicate key. Pass a single row or an array of rows; the optional second argument lists the columns to update on conflict (defaults to all provided columns).

Contact::query()->upsert(
    ['email' => 'a@x.com', 'first_name' => 'Ada'],          // unique key: email
    ['first_name']                                          // columns to update on duplicate
);

Contact::query()->upsert([
    ['email' => 'a@x.com', 'first_name' => 'Ada'],
    ['email' => 'b@x.com', 'first_name' => 'Bob'],
]);

MySQL only — upsert() emits INSERT … ON DUPLICATE KEY UPDATE and will not work on other databases. See Limitations.

On a model with $timestamps = true, upsert() sets both created_at and updated_at on insert, and on a duplicate key it bumps updated_at (updated_at = VALUES(updated_at)) while preserving the original created_at.

Expression updates (upsertRaw)

When the duplicate-key update must reference the existing row — e.g. an atomic counter — use upsertRaw(). The second argument is a column => expression map applied on conflict instead of VALUES(col). Bind values inside an expression with the [expression, bindings] form.

Contact::query()->upsertRaw(
    ['email' => 'a@x.com', 'hits' => 1],   // unique key: email
    ['hits' => 'hits + 1']                 // ON DUPLICATE KEY UPDATE `hits` = hits + 1
);

Contact::query()->upsertRaw(
    ['email' => 'a@x.com', 'score' => 0],
    ['score' => ['score + %d', [5]]]       // bound value: score = score + 5
);

Unlike upsert(), upsertRaw() sets exactly the expressions you pass on duplicate key — updated_at is never auto-bumped, so an atomic counter is not clobbered; include updated_at yourself if you want to touch it. Expressions are developer-authored SQL and hardened against injection; the values you bind are always parameterized.


Attribute casting

Declare $casts to convert attribute values during fill() — both when hydrating query results and during mass-assignment. Supported types:

Cast Result
int (int) value
string (string) value
bool / boolean (bool) value
array json_decode($value, true)
object json_decode($value)
date DateTime from Y-m-d H:i:s
protected $casts = [
    'meta'       => 'array',
    'is_active'  => 'bool',
    'created_at' => 'date',
];

NULL values are returned as null (not cast). Add casts at runtime with withCast(), which returns the builder and is chainable:

Contact::query()->withCast(['is_active' => 'bool'])->get();

Relationships

Full reference: docs/relations.md — every relation type, eager/lazy loading, aggregates, and relation-specific limitations in one place. This section is a quick overview.

Define relationships as methods on the model. The relation method returns a query for the related model.

Key convention: $foreignKey is the column on the related model's table; $localKey is the column on the calling model's table. Always supply both explicitly when your column names differ from the ORM default ({callerTable}_id / id).

class Contact extends Model
{
    public function deals()
    {
        // foreignKey='contact_id' lives on the deals table
        // localKey='id' lives on the contacts table
        // Generated predicate: WHERE deals.contact_id IN (SELECT id FROM contacts)
        return $this->hasMany(Deal::class, 'contact_id', 'id');
    }

    public function profile()
    {
        // foreignKey='contact_id' on the profiles table
        // localKey='id' on the contacts table
        return $this->hasOne(Profile::class, 'contact_id', 'id');
    }
}

class Deal extends Model
{
    public function contact()
    {
        // Deal.contact_id references Contact.id
        // foreignKey='id' lives on the contacts (related) table
        // localKey='contact_id' lives on the deals (this) table
        // Generated predicate: WHERE contacts.id IN (SELECT contact_id FROM deals)
        return $this->belongsTo(Contact::class, 'id', 'contact_id');
    }
}

hasOne and belongsTo are identical. hasOne() is a direct alias of belongsTo() — both set the oneToOne relation type and return a single model. There is no separate reverse-direction implementation; the direction is determined entirely by the keys you provide and which model calls the method.

Available: hasOne(), hasMany(), belongsTo() — each takes ($model, $foreignKey = null, $localKey = null).

Many-to-many (belongsToMany)

Resolves a many-to-many relation through a pivot (junction) table. Pass the unprefixed pivot table name plus the parent/related key columns on it:

class Member extends Model
{
    protected $table = 'members';

    public function roles()
    {
        // pivot table role_user(member_id, role_id)
        return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id');
    }
}

With no $pivotTable the call keeps its legacy hasMany-style behaviour. Pivot values surface as flat reserved pivot_* attributes; add extra ones with ->withPivot([...]). Pivot relations are read-only — attach/detach/sync and aggregates over a pivot relation are not supported (the latter throw).

See docs/relations.md for the full signature, key-defaults table, withPivot, eager/lazy access, and limitations.

Eager loading

Contact::with('deals')->get();                   // load deals for each contact
Contact::with(['deals', 'profile'])->get();      // multiple relations

// Constrain the eager-loaded relation
Contact::with('deals', function ($q) {
    $q->where('status', 'open');
})->get();

// Alias the loaded relation key on the result model
Contact::with('deals as open_deals')->get();

// Access loaded relations
foreach (Contact::with('deals')->get() as $contact) {
    foreach ($contact->deals as $deal) { /* ... */ }
}

Relation aggregates & existence

Contact::withCount('deals')->get();           // adds `deals_count`
Contact::withSum('deals.amount')->get();      // adds `deals_sum`
Contact::withAvg('deals.amount')->get();      // adds `deals_avg`
Contact::withMin('deals.amount')->get();      // adds `deals_min`
Contact::withMax('deals.amount')->get();      // adds `deals_max`
Contact::withExists('deals')->get();          // adds bool-cast `deals_exists`

// Filter by relation existence
Contact::whereHas('deals')->get();
Contact::whereHas('deals', fn ($q) => $q->where('status', 'open'))->get();

// Filter by existence AND eager-load the same relation
Contact::withWhereHas('deals', fn ($q) => $q->where('status', 'open'))->get();

Aggregate columns are aliased <relation>_<function> by default (e.g. deals_count, deals_sum). Pass 'relation as alias' to use a custom column name:

Contact::withCount('deals as total_deals')->get();   // adds `total_deals`
Contact::withSum('deals.amount as revenue')->get();  // adds `revenue`

For withMin, withMax, withAvg, and withSum the column to aggregate must be specified as 'relation.column'; passing just the relation name defaults to *, which is meaningful only for withCount and withExists.


Model events

Models fire lifecycle events. Register handlers in a static boot() using the event registrars. A pre-event (saving, updating, deleting) that returns false aborts the operation. saving/saved fire on both insert and update.

class Contact extends Model
{
    protected static function boot()
    {
        static::saving(function ($model) {
            if (!$model->email) {
                return false;     // abort the save
            }
        });

        static::saved(fn ($model) => Log::info("saved {$model->id}"));

        // A handler can also be a class with a handle() method
        static::deleted(SyncDeletion::class);
    }
}

Subscribable events (registered via Closure using the methods below): retrieved, creating, created, saving, saved, updating, updated, deleting, deleted.

Boot hooks — override these protected static methods instead of registering a Closure: booting() (runs before boot()), booted() (runs after boot()). The boot() method itself is the standard entry point for registering event handlers.

The HasEvents trait reserves the method names boot, booting, booted, fireEvent, fireCustomEvent, registerEvent and the properties $events, $registeredEvents, $booted.


Transactions

use BitApps\WPDatabase\Connection;

Connection::startTransaction();
try {
    Contact::insert([...]);
    Deal::insert([...]);
    Connection::commit();
} catch (\Throwable $e) {
    Connection::rollback();
    throw $e;
}

The equivalent methods on QueryBuilder are deprecated — use Connection.


Raw queries

// Complex developer-authored SQL with typed dynamic structure and bound values.
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']
);

// Escape a literal percent as %%, including when no values are bound.
Contact::query()->rawPrepared('SELECT 100 %% 7 AS remainder');

// Reviewed, fully developer-controlled legacy SQL.
Contact::query()->unsafeRaw(
    "SELECT `id` FROM `wp_contacts` WHERE `status` = %s AND 'fixed' = 'fixed'",
    ['active']
);

rawPrepared() is a static-template boundary: the template must be written by a developer and must never come from request data, even when a bindings array is provided. It accepts only these structural markers:

  • {{identifier:key}}, compiled from the matching identifier-map entry;
  • {{direction:key}}, compiled from the matching direction-map entry as ASC or DESC.

Marker keys match [A-Za-z_][A-Za-z0-9_]*. Every map entry must be used and every marker must have a matching entry; duplicate markers reuse one map value. Identifiers are quoted by the compiler, so the template itself must not contain backticks.

Value placeholders are limited to unnumbered %s, %d, %f, and %F; use %% for a literal percent. The binding count must match the real placeholder count exactly. Numbered, flagged, %i, dangling, and unknown placeholders are rejected before $wpdb->prepare() or query execution.

To keep this API conservative rather than pretending to be a SQL lexer, templates reject single quotes, double quotes, backticks, #, --, /*, */, unresolved braces, and every semicolon except one optional trailing terminator. SQL that genuinely requires such static syntax belongs in reviewed unsafeRaw() code.

Choose the narrowest boundary:

structured query -> structured builder APIs
complex static SQL with dynamic structure -> rawPrepared typed markers
fully developer-controlled legacy SQL -> unsafeRaw
request interpolation into raw/unsafeRaw -> unsupported and vulnerable

raw() is deprecated and delegates to unsafeRaw() for compatibility. Both legacy entry points preserve $wpdb placeholder behavior, but neither makes an interpolated or request-provided SQL string safe.


Schema builder

Use Schema (a facade over Blueprint) to create, alter and drop tables. By design the table name is used as-is — call Schema::withPrefix($prefix) to apply a literal prefix, or Schema::withWpPrefix() to use the same prefix Models use (Connection::getPrefix()).

use BitApps\WPDatabase\Schema;

Schema::create('contacts', function ($table) {
    $table->id();
    $table->string('email');
    $table->string('first_name')->nullable();
    $table->bool('is_active')->defaultValue(1);
    $table->timestamps();
});

For the full reference — column types, modifiers, foreign keys, Schema::edit, Schema::drop, Schema::rename, and prefix scoping — see Schema builder reference.


Static vs instance calls (IDE notes)

All builder/relation methods can be called three ways:

Contact::where('id', 1)->get();          // static (magic via __callStatic)
Contact::query()->where('id', 1)->get(); // real static entry — best IDE support
(new Contact())->where('id', 1)->get();  // instance
  • Model::query() is a real static method returning a QueryBuilder; start chains with it for autocomplete and Ctrl/Cmd+Click navigation that work in every editor (PhpStorm and VS Code/Intelephense).
  • Terse static calls (Contact::where()) are forwarded through __call/__callStatic. @method static docblocks give autocomplete; the @mixin QueryBuilder annotation gives PhpStorm navigation to the real method.

Breaking changes

If you are upgrading, read breaking-changes.md — it covers the Collection return type, update()/save() behavior, select() quoting, the delete()-without-WHERE guard, null casting, and the relation method relocation.


Limitations & known issues

  • Relation limitations — belongsToMany pivot relations are read-only and single-key (no attach/detach/sync, no aggregates over a pivot relation), and belongsTo/hasOne share one oneToOne alias whose key naming is reversed from Laravel. Full detail: docs/relations.md.

  • Soft delete reads exclude trashed rows by default. softDeletes() adds a deleted_at column and delete() sets it. Reads automatically filter out trashed rows (deleted_at IS NULL); ->withTrashed() includes them and ->onlyTrashed() returns only trashed rows. Declare public $soft_delete_scope = false; on the model to opt out of the filter and read every row, including trashed ones. refresh() reloads a row by its own primary key with withTrashed(), so re-hydrating a trashed model still reports exists() === true and a following save() correctly UPDATEs.

  • upsert is MySQL-only. It generates INSERT … ON DUPLICATE KEY UPDATE, which is not portable to other databases. Workaround: use separate insert + update calls where cross-database portability is required.

  • Schema builder uses the literal table name by design. Schema::create() passes the name through as-is — no WordPress prefix is added automatically, so existing tables are never relocated. Use Schema::withPrefix('your_prefix_') for a literal prefix, or Schema::withWpPrefix() to match the prefix Models use (Connection::getPrefix()). The $prefix property intentionally defaults to null (not '') to preserve bare-table behaviour for plugins that rely on it. See Schema builder reference.