mjkhajeh/wporm

WPORM is a lightweight, Eloquent-inspired ORM for WordPress plugins and themes. It provides expressive, fluent query building, model relationships, schema management, attribute casting, and event hooks—while fully supporting the WordPress database API and table prefixing. WPORM makes it easy to buil

Maintainers

Package info

github.com/mjkhajeh/wporm

pkg:composer/mjkhajeh/wporm

Transparency log

Statistics

Installs: 266

Dependents: 1

Suggesters: 0

Stars: 5

Open Issues: 0

v3.16.0.0 2026-07-04 16:46 UTC

README

WPORM is a lightweight Object-Relational Mapping (ORM) library for WordPress plugins. It provides an Eloquent-like API for defining models, querying data, and managing database schema, all while leveraging WordPress's native $wpdb database layer.

wporm

Documentation

Features

  • Model-based data access: Define models for your tables and interact with them using PHP objects.
  • Schema management: Create and modify tables using a fluent schema builder.
  • Query builder: Chainable query builder for flexible and safe SQL queries.
  • Attribute casting: Automatic type casting for model attributes.
  • Relationships: Define hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough, hasOneThrough, and hasOneOfMany relationships, with eager loading via with(), relationship-count eager loading via withCount(), and existence filtering via whereHas()/has(). Polymorphic relationships (morphOne, morphMany, morphTo) are also supported, including an optional morphMap() for short type aliases.
  • Convenient creation: create() for a one-line insert + return model, plus updateOrCreate(), firstOrCreate(), and firstOrNew() for upsert-style lookups.
  • Aggregates & utilities: sum(), avg(), min(), max(), value(), pluck(), exists()/doesntExist(), and increment()/decrement().
  • Fail-fast lookups: findOrFail()/firstOrFail() (including array-of-ids lookups, and Collection::firstOrFail()) throw a ModelNotFoundException instead of silently returning null.
  • Re-fetching: fresh() returns a new instance with the current database state (optionally eager-loading relations); refresh() re-syncs the current instance in place — both Eloquent-style.
  • Batch processing: chunk() and each() for iterating large result sets in pages without loading everything into memory at once.
  • Serialization: toArray()/toJson()/__toString() on both models and collections, with $hidden/$visible support and safe (exception-on-failure) JSON encoding.
  • Raw SQL expressions: selectRaw(), whereRaw()/orWhereRaw(), groupByRaw(), havingRaw()/orHavingRaw(), and orderByRaw() for dropping down to raw SQL with safe, bound placeholders.
  • Subqueries: fromSub() / from() for derived tables, selectSub() for scalar subselects in the SELECT list, and whereSub()/whereInSub()/whereNotInSub() (plus OR variants) for subqueries in WHERE — all accepting a QueryBuilder, Closure, or raw SQL string, Eloquent-style.
  • Combining queries: union()/unionAll() to combine two or more queries' result sets, Eloquent-style.
  • Events: Model lifecycle events via booted() + static::creating(fn), Eloquent-style $dispatchesEvents property mapping, observers, and a standalone EventDispatcher for global listeners — no Laravel dependency required.
  • Functional chaining: tap() for inline side-effects (logging, debugging) that leave the builder unchanged, and pipe() to hand the builder off to a callback and return its result — both Eloquent-style, and available on QueryBuilder, Collection, and Model instances.
  • Rich Collections: Collection supports Eloquent-style sortBy()/sortByDesc(), groupBy(), keyBy(), unique(), flatMap(), mapToGroups(), each(), reduce(), values(), keys(), diff()/intersect()/merge(), push()/pull()/put(), implode(), when()/unless(), firstWhere(), and in-memory sum()/avg()/min()/max().
  • Global scopes: Add global query constraints to models.

Installation

With Composer (Recommended)

You can install WPORM via Composer. In your plugin or theme directory, run:

composer require mjkhajeh/wporm

Then include Composer's autoloader in your plugin bootstrap file:

require_once __DIR__ . '/vendor/autoload.php';

Manual Installation

  1. Place the ORM directory in your plugin folder.
  2. Include the ORM in your plugin bootstrap:
require_once __DIR__ . '/ORM/Helpers.php';
require_once __DIR__ . '/ORM/Events/ModelEvent.php';
require_once __DIR__ . '/ORM/Events/Events.php';
require_once __DIR__ . '/ORM/EventDispatcher.php';
require_once __DIR__ . '/ORM/Model.php';
require_once __DIR__ . '/ORM/QueryBuilder.php';
require_once __DIR__ . '/ORM/Blueprint.php';
require_once __DIR__ . '/ORM/SchemaBuilder.php';
require_once __DIR__ . '/ORM/ColumnDefinition.php';
require_once __DIR__ . '/ORM/DB.php';
require_once __DIR__ . '/ORM/Collection.php';
require_once __DIR__ . '/ORM/ModelNotFoundException.php';

Defining a Model

Create a model class extending MJ\WPORM\Model:

use MJ\WPORM\Model;
use MJ\WPORM\Blueprint;

class Parts extends Model {
    protected $table = 'parts';
    protected $fillable = ['id', 'part_id', 'qty', 'product_id'];
    protected $timestamps = false;

    public function up(Blueprint $blueprint) {
        $blueprint->id();
        $blueprint->integer('part_id');
        $blueprint->integer('product_id');
        $blueprint->integer('qty');
        $blueprint->index('product_id');
    }
}

Note: Just build your columns on the $blueprint passed into up() — WPORM reads the schema directly from it via $blueprint->toSql(). You do not need to (and should not) manually assign $this->schema anymore; up(Blueprint $blueprint) is now the single source of truth for table schema.

Note: When using $table in custom SQL queries, do not manually add the WordPress prefix (e.g., $wpdb->prefix). The ORM automatically handles table prefixing. Use $table = (new User)->getTable(); as shown in the next, which returns the fully-prefixed table name.

Schema Management

Create or update tables using the model's up method and the SchemaBuilder:

use MJ\WPORM\SchemaBuilder;

$schema = new SchemaBuilder($wpdb);
$schema->create('parts', function($table) {
    $table->id();
    $table->integer('part_id');
    $table->integer('product_id');
    $table->integer('qty');
    $table->index('product_id');
});

SchemaBuilder::create() automatically wraps your column definitions in a full CREATE TABLE {prefix}parts (...) {charset_collate}; statement (using $wpdb->get_charset_collate()) before handing it to WordPress's dbDelta(), and prefixes the table name for you — you only need to supply the bare table name and build columns on $table, as shown above.

Throws a \RuntimeException if dbDelta() reports a failure (check $wpdb->last_error for details).

Unique Indexes (Eloquent-style)

You can add a unique index to a column using Eloquent-style chaining:

$table->string('email')->unique();
$table->integer('user_id')->unique('custom_index_name');

For multi-column unique indexes, use:

$table->unique(['col1', 'col2']);

This works for all column types and matches Eloquent's API.

Basic Usage

Creating a Record

$part = new Parts(['part_id' => 1, 'product_id' => 2, 'qty' => 10]);
$part->save();

Prefer a one-liner? Parts::create([...]) does the same thing (instantiate + save()) in a single call — see One-Line Create: create() below.

Querying Records

// Get all parts
$all = Parts::all();

// Find by primary key
$part = Parts::find(1);

// Where clause
$parts = Parts::query()->where('qty', '>', 5)->orderBy('qty', 'desc')->limit(10)->get(); // Limit to 10 results

// Raw ORDER BY example
$parts = Parts::query()->where('qty', '>', 5)
    ->orderByRaw('FIELD(name, ?, ?)', ['Widget', 'Gadget'])
    ->limit(10)
    ->get();

// This allows custom SQL ordering, e.g. sorting by a specific value list. Bindings are safely passed to $wpdb->prepare.

// First result
$first = Parts::query()->where('product_id', 2)->first();

Querying by a Specific Column

You can easily retrieve records by a specific column using the query builder's where method. For example, to get all parts with a specific product_id:

$parts = Parts::query()->where('product_id', 123)->get();

Or, to get the first user by email:

$user = User::query()->where('email', 'user@example.com')->first();

You can also use other comparison operators:

$recentUsers = User::query()->where('created_at', '>=', '2025-01-01')->get();

This approach works for any column in your table.

Finding a Record or Failing: findOrFail and firstOrFail

When a missing record should be treated as an error rather than handled as null, use findOrFail() / firstOrFail() (Eloquent-style). They behave exactly like find() / first() — same single query, same retrieved() event — except they throw a MJ\WPORM\ModelNotFoundException instead of returning null when nothing matches.

use MJ\WPORM\ModelNotFoundException;

// Find by primary key, or throw
try {
    $user = User::findOrFail(1);
} catch (ModelNotFoundException $e) {
    wp_die('User not found', '', ['response' => 404]);
}

// Works mid-chain on the query builder too
$user = User::with('posts')->findOrFail(1);
$user = User::query()->withTrashed()->findOrFail(1);

// Find multiple records by an array of ids — returns a Collection.
// find() simply omits any ids that don't exist; findOrFail() throws
// if ANY of them are missing, listing every missing id.
$users = User::find([1, 2, 3]);        // Collection of whichever ids exist
try {
    $users = User::findOrFail([1, 2, 3]);
} catch (ModelNotFoundException $e) {
    // $e->getIds() === [2, 3] if only id 1 existed
}

// First match by attributes, or throw
$user = User::firstOrFail(['email' => 'user@example.com']);

// Or build up arbitrary constraints on the query builder, then fail if empty
$user = User::query()
    ->where('active', true)
    ->orderBy('created_at', 'desc')
    ->firstOrFail();

// Collection::firstOrFail() — same idea, but on an already-fetched
// Collection (e.g. after in-memory filtering), where re-running a query
// isn't an option:
$activeAdmins = User::query()->where('role', 'admin')->get()
    ->filter(fn($u) => $u->active);
try {
    $admin = $activeAdmins->firstOrFail();
} catch (ModelNotFoundException $e) {
    // no active admins found
}

ModelNotFoundException extends PHP's built-in \RuntimeException, and exposes getModel() (the model class that was queried) and getIds() (the id(s) passed to findOrFail() — a single value, or the array of missing ids for an array lookup; null for firstOrFail()/Collection::firstOrFail()) so error handlers can respond appropriately (e.g. a JSON 404) without parsing the message string.

One-Line Create: create()

WPORM provides a create static method, similar to Laravel Eloquent, for instantiating a new model with the given attributes, saving it, and returning the instance — all in one call.

Usage:

// One-line insert + return model
$user = User::create([
    'name' => 'John Doe',
    'email' => 'user@example.com',
]);

echo $user->id; // the newly-inserted primary key
  • Attributes are mass-assigned through the same $fillable/$guarded rules as new Model([...]) — any attribute not allowed through mass assignment is silently skipped, exactly like the constructor.
  • Equivalent to (and a shorthand for):
    $user = new User(['name' => 'John Doe', 'email' => 'user@example.com']);
    $user->save();
  • Returns the model instance regardless of whether the underlying save() succeeded; check $user->exists (or your own validation beforehand) if you need to confirm the insert actually happened.

Creating or Updating Records: updateOrCreate

WPORM provides an updateOrCreate method, similar to Laravel Eloquent, for easily updating an existing record or creating a new one if it doesn't exist.

Usage:

// Update if a user with this email exists, otherwise create a new one
$user = User::updateOrCreate(
    ['email' => 'user@example.com'],
    ['name' => 'John Doe', 'country' => 'US']
);

// Disable global scopes for this call
$user = User::updateOrCreate(
    ['email' => 'user@example.com'],
    ['name' => 'John Doe', 'country' => 'US'],
    false // disables global scopes
);
  • The first argument is an array of attributes to search for.
  • The second argument is an array of values to update or set if creating.
  • The optional third argument disables global scopes if set to false (default is true).
  • Returns the updated or newly created model instance.

This is useful for upsert operations, such as syncing data or ensuring a record exists with certain values.

Creating or Getting Records: firstOrCreate and firstOrNew

Inserting Records: insertOrIgnore

WPORM provides an insertOrIgnore method, similar to Laravel Eloquent, for inserting one or multiple records and ignoring duplicate key errors (such as unique constraint violations).

Usage:

// Insert a single user, ignore if email already exists
$success = User::insertOrIgnore([
    'email' => 'user@example.com',
    'name' => 'Jane Doe',
    'country' => 'US'
]);

// Insert multiple users, ignore duplicates
$data = [
    ['email' => 'user1@example.com', 'name' => 'User One'],
    ['email' => 'user2@example.com', 'name' => 'User Two'],
    ['email' => 'user1@example.com', 'name' => 'User One Duplicate'], // duplicate email
];
$success = User::insertOrIgnore($data);
  • Returns true if the insert(s) succeeded or were ignored due to duplicate keys.
  • Returns false on other errors.
  • Uses MySQL's INSERT IGNORE for safe upsert-like behavior.

This is useful for bulk imports or situations where you want to avoid errors on duplicate records.

Bulk Upsert: upsert

WPORM provides an Eloquent-style upsert method for inserting or updating multiple records in a single query. It uses MySQL's INSERT ... ON DUPLICATE KEY UPDATE syntax for maximum efficiency.

Signature:

Model::upsert(array $values, array|string $uniqueBy, array|null $update = null)

Parameters:

  • $values — An array of records (each an associative array) to insert or update.
  • $uniqueBy — The column(s) that uniquely identify a record (must have a unique or primary key constraint in the database).
  • $update — (Optional) The columns to update when a duplicate is found. If omitted or null, all columns except $uniqueBy are updated automatically.

Examples:

// Upsert multiple records — insert new ones, update existing by email
User::upsert([
    ['email' => 'alice@test.com', 'name' => 'Alice', 'votes' => 1],
    ['email' => 'bob@test.com', 'name' => 'Bob', 'votes' => 2],
], ['email'], ['name', 'votes']);

// Auto-detect update columns (updates all columns except the unique key)
User::upsert([
    ['email' => 'alice@test.com', 'name' => 'Alice Updated', 'votes' => 10],
], 'email');

// Single record upsert
User::upsert(
    ['email' => 'alice@test.com', 'name' => 'Alice', 'votes' => 5],
    ['email'],
    ['votes']
);

// Also available via DB::table() for raw table queries
use MJ\WPORM\DB;

DB::table('users')->upsert([
    ['email' => 'alice@test.com', 'name' => 'Alice', 'votes' => 1],
    ['email' => 'bob@test.com', 'name' => 'Bob', 'votes' => 2],
], ['email'], ['name', 'votes']);
  • If timestamps are enabled on the model, created_at and updated_at are handled automatically.
  • Returns the number of affected rows, or false on failure.
  • If no update columns are specified and none can be inferred, falls back to INSERT IGNORE behavior.

WPORM also provides firstOrCreate and firstOrNew methods, similar to Laravel Eloquent, for convenient record retrieval or creation.

firstOrCreate Usage:

// Get the first user with this email, or create if not found
$user = User::firstOrCreate(
    ['email' => 'user@example.com'],
    ['name' => 'Jane Doe', 'country' => 'US']
);

// Disable global scopes for this call
$user = User::firstOrCreate(
    ['email' => 'user@example.com'],
    ['name' => 'Jane Doe', 'country' => 'US'],
    false // disables global scopes
);
  • Returns the first matching record, or creates and saves a new one if none exists.
  • The optional third argument disables global scopes if set to false (default is true).

firstOrNew Usage:

// Get the first user with this email, or instantiate (but do not save) if not found
$user = User::firstOrNew(
    ['email' => 'user@example.com'],
    ['name' => 'Jane Doe', 'country' => 'US']
);

// Disable global scopes for this call
$user = User::firstOrNew(
    ['email' => 'user@example.com'],
    ['name' => 'Jane Doe', 'country' => 'US'],
    false // disables global scopes
);
if (!$user->exists) {
    $user->save(); // Save if you want to persist
}
  • Returns the first matching record, or a new (unsaved) instance if none exists.
  • The optional third argument disables global scopes if set to false (default is true).

These methods are useful for ensuring a record exists, or for preparing a new record with default values if not found.

Updating a Record

$part = Parts::find(1);
$part->qty = 20;
$part->save();

Deleting a Record

$part = Parts::find(1);
$part->delete();

Truncating a Table

You can quickly remove all rows from a model's table using truncate() on the model query builder:

// Remove all records from the table
Parts::query()->truncate();

Refetching a Model: fresh() and refresh()

When the underlying row may have changed since you loaded a model — another process updated it, you just ran an increment()/update() elsewhere, or you simply want to double-check the current state — use fresh() or refresh() to pull the current database state, Eloquent-style.

$user = User::find(1);

// fresh() — returns a NEW instance with current DB values; $user itself is untouched
$freshUser = $user->fresh();
$freshUser = $user->fresh('posts'); // optionally eager-load relations, like with()

// refresh() — re-fetches and overwrites the CURRENT instance in place
$user->refresh();
echo $user->name; // now reflects whatever is in the database right now
  • fresh($with = []) never mutates the original model — it returns a brand-new instance (or null if the row no longer exists). Pass a relation name or array of names to eager-load them on the fresh instance.
  • refresh() mutates $this and returns it for chaining, clearing any previously eager-loaded relations (they may now be stale). Throws MJ\WPORM\ModelNotFoundException if the row no longer exists.
  • Both query strictly by primary key and bypass global scopes, and neither includes soft-deleted rows — if the row has since been soft-deleted, fresh() returns null and refresh() throws, matching Eloquent's own behavior.

Cloning a Model: replicate()

Duplicate an existing model without saving — the primary key, timestamps, and soft-delete column are excluded automatically. Modify the clone and call save() to create a new record:

$post = Post::find(1);
$clone = $post->replicate();
$clone->title = 'Copy of ' . $post->title;
$clone->save();

// Exclude additional attributes
$clone = $post->replicate(['slug', 'meta']);
  • replicate($except = []) returns a new, unsaved instance with all attributes copied except the primary key, created_at, updated_at, soft-delete column, and any keys you pass in $except.
  • Relations are not copied — only scalar attributes.
  • $clone->exists is false, so the next save() triggers an INSERT.

Checking Creation Status: wasRecentlyCreated

After saving a model, use wasRecentlyCreated to check if the save triggered an INSERT (new record) or an UPDATE (existing record):

$user = new User(['name' => 'John']);
$user->save();
$user->wasRecentlyCreated; // true

$user->name = 'Jane';
$user->save();
$user->wasRecentlyCreated; // false
  • wasRecentlyCreated is true only after save() triggers an INSERT.
  • Resets to false at the start of every save() call.
  • Useful in save hooks or after-save workflows:
$user->save();
if ($user->wasRecentlyCreated) {
    Mail::to($user)->send(new WelcomeEmail($user));
}

Aggregates & Utility Methods

WPORM provides Eloquent-style aggregate and utility methods on the query builder for common lookups, so you don't always need to fetch full models just to compute a number or check a single value.

// Sum, average, min, max
$totalQty   = Parts::query()->where('product_id', 2)->sum('qty');
$avgPrice   = Product::query()->avg('price');     // or ->average('price')
$cheapest   = Product::query()->min('price');
$mostExpensive = Product::query()->max('price');

// Get a single column's value from the first matching row
$email = User::query()->where('id', 1)->value('email');

// Get a flat array of a column's values (optionally keyed by another column)
$emails     = User::query()->pluck('email');
$emailsById = User::query()->pluck('email', 'id');

// Existence checks
if (User::query()->where('email', $email)->exists()) {
    // already taken
}
if (User::query()->where('email', $email)->doesntExist()) {
    // free to use
}

increment() / decrement()

Bump a numeric column up or down in a single atomic UPDATE statement — no need to read the value, add to it in PHP, then write it back.

// Instance usage — scoped automatically to this model's primary key
$user = User::find(1);
$user->increment('votes');                 // votes + 1
$user->increment('votes', 5);               // votes + 5
$user->increment('votes', 1, [
    'last_voted_at' => current_time('mysql'),
]);

$user->decrement('credits');                // credits - 1
$user->decrement('credits', 3);             // credits - 3

// Query builder usage — affects every row matching the query
User::query()->where('active', true)->increment('votes');
User::query()->where('role', 'admin')->increment('credits', 10);
User::query()->where('subscription', 'expired')->decrement('seats');
  • If the model uses timestamps, updated_at is touched automatically (unless you pass it yourself via the optional $extra array).
  • The instance form keeps the in-memory model in sync with the new value, so you don't need to refresh()/re-fetch afterward.

See Methods.md for the full list with signatures.

Pagination

WPORM supports Eloquent-style pagination with the following methods on the query builder:

paginate($perPage = 15, $page = null)

Returns a paginated result array with total count and page info:

$result = User::query()->where('active', true)->paginate(10, 2);
// $result = [
//   'data' => Collection,
//   'total' => int,
//   'per_page' => int,
//   'current_page' => int,
//   'last_page' => int,
//   'from' => int,
//   'to' => int
// ]

simplePaginate($perPage = 15, $page = null)

Returns a paginated result array without total count (more efficient for large tables):

$result = User::query()->where('active', true)->simplePaginate(10, 2);
// $result = [
//   'data' => Collection,
//   'per_page' => int,
//   'current_page' => int,
//   'next_page' => int|null
// ]

See Methods.md for more details and options.

Processing Large Datasets: chunk() and each()

When you need to iterate over a large number of records, loading them all into memory at once with get() isn't practical. chunk() and each() solve this Eloquent-style, by running the query in pages (using the same limit()/offset() mechanism as paginate()) and feeding results to a callback as they come in.

chunk($count, $callback)

Runs the query in pages of $count records, calling $callback once per page with a Collection of models:

User::query()->where('active', true)->chunk(100, function ($users) {
    foreach ($users as $user) {
        // ...
    }
});

The callback also receives the current page number as a second argument, and can return false to stop processing early:

Order::query()->chunk(200, function ($orders, $page) {
    foreach ($orders as $order) {
        if ($order->total > 1_000_000) {
            return false; // stops chunk() immediately
        }
    }
});

each($callback, $count = 1000)

Like chunk(), but calls $callback once per individual model instead of once per page, while still fetching records from the database in pages internally (default page size: 1000). The callback receives the model and a running zero-based index:

User::query()->where('active', true)->each(function ($user, $index) {
    // process one $user at a time
});

// Customize the internal page size
User::query()->each(function ($user) {
    // ...
}, 500);

Just like chunk(), returning false from the callback stops processing early.

Both methods automatically respect any where()/join()/soft-delete scoping already applied to the query, since they're built on the same query builder instance.

cursor()

Returns a generator that yields models one at a time — the query executes once, but models are hydrated lazily as you iterate. This is ideal for huge datasets where you want foreach simplicity without loading every model into memory upfront:

foreach (User::query()->where('active', true)->cursor() as $user) {
    // process $user one at a time — only one model in memory at a time
}

// Works with static method too
foreach (User::cursor() as $user) {
    // ...
}

cursor() vs chunk()/each():

cursor() chunk() / each()
Query execution Single query Multiple paginated queries
Memory model One model at a time One page at a time
Early stop Break out of foreach Return false from callback
Best for Simple iteration over huge sets Complex per-page logic or early-stop

Both approaches keep memory low — cursor() is simpler when you just need to loop through everything once.

Attribute Casting

Add a $casts property to your model:

protected $casts = [
    'qty' => 'int',
    'meta' => 'json',
];

Array Conversion and Casting

  • Call ->toArray() on a model or a collection to get an array representation with all casts applied.
  • Built-in types (e.g. 'int', 'bool', 'float', 'json', etc.) are handled natively and will not be instantiated as classes.
  • Custom cast classes must implement MJ\WPORM\Casts\CastableInterface.

Example:

protected $casts = [
    'user_id'    => 'int',
    'from'       => Time::class, // custom cast
    'to'         => Time::class, // custom cast
    'use_default'=> 'bool',
    'status'     => 'bool',
];

$model = Times::find(1);
$array = $model->toArray();

$collection = Times::query()->get();
$arrays = $collection->toArray();
  • Custom cast classes will be instantiated and their get() method called.
  • Built-in types will be cast using native PHP logic.

Serialization: toJson()

In addition to toArray(), models and collections can be converted directly to a JSON string with toJson() (Eloquent-style):

$user = User::find(1);
$json = $user->toJson();                 // '{"id":1,"name":"Jane",...}'
$pretty = $user->toJson(JSON_PRETTY_PRINT);

$users = User::query()->where('active', true)->get();
$json = $users->toJson();                 // JSON array of every user
  • toJson($options = 0) internally calls toArray() and JSON-encodes the result, so it respects $fillable/casts and, importantly, $hidden/$visible (see Hidden & Visible Attributes below) — sensitive columns stay out of the JSON output the same way they stay out of toArray().
  • $options is passed straight through to PHP's json_encode() (e.g. JSON_PRETTY_PRINT, JSON_UNESCAPED_UNICODE).
  • If encoding fails — e.g. an attribute contains malformed UTF-8, or a cast produced a NAN/INF float — toJson() throws a \JsonException describing the failure, rather than silently returning false. Wrap calls in a try/catch if you need to handle that case explicitly:
try {
    $json = $user->toJson();
} catch (\JsonException $e) {
    // log / handle the encoding failure
}
  • Both Model and Collection also implement __toString(), so they can be used directly in string contexts and will produce the same output as toJson():
echo $user;                    // same as echo $user->toJson();
$log = "Created user: {$user}";

echo $users;                   // same as echo $users->toJson();

Mass Assignment Protection: $fillable and $guarded

WPORM protects against unintended mass assignment, just like Eloquent. Use $fillable to whitelist attributes that can be set via fill(), the constructor, __set() (including array access like $model['name'] = ...), updateOrCreate(), firstOrCreate(), or firstOrNew(). Use $guarded (default: ['id']) to blacklist attributes instead — anything not in $guarded is mass-assignable. $guarded is only checked when $fillable is empty.

class User extends Model {
    protected $fillable = ['name', 'email'];
}

$user = new User(['name' => 'Jane', 'is_admin' => true]);
$user->is_admin; // null — not in $fillable, so it was never set

// Or, blacklist style:
class Post extends Model {
    protected $guarded = ['id', 'is_published']; // everything else is mass-assignable
}

// Block all mass assignment:
class StrictModel extends Model {
    protected $guarded = ['*'];
}

Note: Hydrating a model from a database row (e.g. via find(), get(), all()) always populates every column, regardless of $fillable/$guarded — these protections only apply to mass assignment of user-supplied data.

$touches — Auto-Update Parent Timestamps

When a child model is saved, you can automatically update the updated_at timestamp of its parent relationships using the $touches property:

class Comment extends Model {
    protected $touches = ['post'];

    public function post() {
        return $this->belongsTo(Post::class);
    }
}

// When a comment is saved, the parent post's updated_at is also updated
$comment = Comment::find(1);
$comment->body = 'Updated comment';
$comment->save();

// The parent Post's updated_at now reflects the comment save time
$post = Post::find($comment->post_id);

You can touch multiple relationships:

class Comment extends Model {
    protected $touches = ['post', 'author'];

    public function post() {
        return $this->belongsTo(Post::class);
    }

    public function author() {
        return $this->belongsTo(User::class);
    }
}
  • Only works with belongsTo relationships (parent models)
  • The parent model must have timestamps = true (default)
  • Touching happens after a successful save (insert or update)

Hidden & Visible Attributes: $hidden and $visible

To keep sensitive columns (passwords, tokens, API secrets, etc.) out of toArray()/toJson() output — and therefore out of API responses or logs — set $hidden on your model, Eloquent-style:

class User extends Model {
    protected $fillable = ['name', 'email', 'password'];
    protected $hidden = ['password', 'remember_token'];
}

$user = User::find(1);
$user->toArray(); // 'password' and 'remember_token' are excluded
$user->toJson();  // same — toJson() JSON-encodes the result of toArray()

Hidden attributes are still fully accessible on the model object itself ($user->password works fine) — they're only stripped when the model is converted to an array or JSON.

You can also use $visible as an allow-list instead — only the listed keys will appear in the output:

class User extends Model {
    protected $visible = ['id', 'name', 'email'];
}

For one-off overrides on a single instance, use makeHidden() / makeVisible() (both return $this for chaining):

$user = User::find(1); // $hidden = ['password']

$user->makeVisible('password')->toArray(); // reveal it just this once
$user->makeHidden('email')->toArray();     // hide an extra field just this once

Collection::toArray() / Collection::toJson() call each model's own toArray(), so $hidden/$visible are respected automatically for lists of models too:

$users = User::query()->get();
$users->toArray(); // every user in the list has 'password' excluded

$fillable/$guarded and $hidden/$visible solve two different problems and are meant to be used together for sensitive columns: $fillable/$guarded control what can be written via mass assignment, while $hidden/$visible control what's read back out via serialization.

Collections

All multi-result queries (get(), all(), etc.) return a Collection instance. Collections provide a fluent, Eloquent-style API for working with arrays of models.

Available Methods

Method Returns Description
all() array Get the underlying array of items
first() mixed Get the first item, or null if the collection is empty. Falsy values (0, false, '') are returned correctly.
firstOrFail() mixed Get the first item, or throw ModelNotFoundException if the collection is empty
last() mixed Get the last item
count() int Number of items
isEmpty() bool Whether the collection is empty
toArray() array Convert all items to arrays
toJson($options = 0) string JSON-encode the collection (via toArray()); throws \JsonException on encoding failure
__toString() string Same output as toJson(), for use in string contexts (e.g. echo $collection;)
filter(callable) Collection Return a new filtered collection
map(callable) Collection Return a new collection with transformed items
transform(callable) $this Transform items in-place (mutating)
each(callable) $this Iterate items; return false from the callback to stop early
reduce(callable, $initial) mixed Reduce the collection to a single value
flatMap(callable) Collection Map then flatten the result by one level
sortBy($key, $desc = false) Collection Sort by column name or callback, preserving keys
sortByDesc($key) Collection Shorthand for sortBy($key, true)
groupBy($key) Collection Group into a Collection of Collections, keyed by value
keyBy($key) Collection Re-key items by column name or callback
unique($key = null) Collection Get unique items, optionally by column name or callback
values() Collection Reset keys to sequential integers
keys() Collection Get a collection of the keys
diff($items) Collection Items not present in the given array/Collection
intersect($items) Collection Items present in the given array/Collection
merge($items) Collection Merge another array/Collection in (array_merge() semantics)
push($value) $this Append an item (mutating)
pull($key, $default) mixed Remove and return an item by key (mutating)
put($key, $value) $this Set an item by key (mutating)
implode($glue, $key = null) string Join items into a string, optionally extracting a column first
when($value, callable, callable) mixed Conditionally run a callback against the collection
unless($value, callable, callable) mixed Inverse of when()
firstWhere($key, $op, $val) mixed First item matching a simple condition
mapToGroups(callable) Collection Map each item to a [groupKey => value] pair, then group
sum($key = null) int|float Sum of values (in-memory, over already-fetched items)
avg($key = null) / average($key = null) int|float|null Average of values
min($key = null) / max($key = null) mixed|null Min/max of values
tap(callable) $this Pass the collection to a callback for side-effects, return the collection unchanged
pipe(callable) mixed Pass the collection to a callback, return whatever the callback returns
pluck($key, $indexKey) array Extract a single column from each item
contains($value) bool Check if a value exists (strict)
slice($offset, $length) Collection Slice the collection
reverse() Collection Reverse item order
after($value) Collection Items after the first occurrence of a value

Full signatures and examples for the newer methods (each, reduce, flatMap, sortBy/sortByDesc, groupBy, keyBy, unique, values, keys, diff, intersect, merge, push/pull/put, implode, when/unless, firstWhere, mapToGroups, sum/avg/min/max) are in Methods.md.

map() vs transform()

map() returns a new collection, leaving the original unchanged. transform() modifies the collection in-place and returns $this for chaining — just like Eloquent.

$users = User::query()->where('active', true)->get();

// map() — returns a new collection, original is unchanged
$names = $users->map(function ($user) {
    return $user->name;
});

// transform() — mutates the collection in-place
$users->transform(function ($user) {
    $user->name = strtoupper($user->name);
    return $user;
});

tap() and pipe() on Collection (and Model)

tap() and pipe() work on Collection the same way they work on QueryBuilder — letting you insert side-effects or delegate to another layer anywhere in a fluent chain without restructuring it. They're also available directly on Model instances (e.g. $user->tap(...)), so a single model fetched via find()/first()/create() can be inspected or piped without breaking the chain either.

// tap() — passes the collection to the callback, discards the return value,
// and continues with the same collection. Ideal for logging/inspection.
$emails = User::query()->get()
    ->filter(fn($u) => $u->active)
    ->tap(fn($c) => error_log('Active users: ' . $c->count()))
    ->pluck('email');

// pipe() — passes the collection to the callback and returns whatever
// the callback returns. Terminates or transforms the chain.
$result = User::query()->get()
    ->filter(fn($u) => $u->active)
    ->pipe(fn($c) => $c->pluck('email'));

// Useful for handing off to a service or presenter:
$dto = User::query()->get()
    ->pipe([$userPresenter, 'toDto']);

// tap()/pipe() on a single Model instance — operates on the model itself:
$user = User::create(['name' => 'Jane'])
    ->tap(fn($u) => error_log("Created user #{$u->id}"));

$dto = User::find(1)->pipe(fn($u) => $userPresenter->toDto($u));

Key differences:

  • tap($cb) — always returns $this (the collection, model, or query builder); callback return value is ignored. Use for side-effects.
  • pipe($cb) — returns whatever the callback returns. Use to produce a final result or delegate to another layer.

Other Examples

$users = User::query()->where('role', 'admin')->get();

// Filter
$active = $users->filter(function ($user) {
    return $user->active;
});

// Pluck emails
$emails = $users->pluck('email');

// Pluck emails keyed by id
$emailMap = $users->pluck('email', 'id');

// Slice and reverse
$lastFive = $users->slice(-5)->reverse();

// Check existence
if ($users->isEmpty()) {
    // No results
}

Collections also implement ArrayAccess, Countable, and IteratorAggregate, so you can use them in foreach loops, access items by index ($users[0]), and pass them to count().

Grouping, Sorting, and Aggregating

Collections support Eloquent-style grouping, sorting, deduplication, and in-memory aggregates over the items already fetched — useful when you've already loaded a result set and want to reorganize or summarize it without issuing another query.

$users = User::query()->get();

// Sort by a column (or callback), preserving keys
$byName = $users->sortBy('name');
$byVotesDesc = $users->sortByDesc('votes');

// Group into a Collection of Collections, keyed by value
$byRole = $users->groupBy('role');
foreach ($byRole as $role => $group) {
    echo "$role: " . $group->count();
}

// Re-key by a column — handy for fast lookups by id/email
$byEmail = $users->keyBy('email');
$jane = $byEmail['jane@example.com'] ?? null;

// Deduplicate, optionally by a column
$uniqueDomains = $users->unique(fn($u) => strstr($u->email, '@'));

// flatMap — map then flatten one level
$allTags = $posts->flatMap(fn($post) => $post->tags);

// mapToGroups — compute both the group key and stored value in one pass
$namesByRole = $users->mapToGroups(fn($u) => [$u->role => $u->name]);

// In-memory aggregates over already-fetched items
$totalVotes = $users->sum('votes');
$avgVotes = $users->avg('votes');
$mostVotes = $users->max('votes');

// firstWhere — first match by a simple condition
$admin = $users->firstWhere('role', 'admin');

// when()/unless() — conditional chaining
$result = $users->when($onlyActive, fn($c) => $c->filter(fn($u) => $u->active));

// each() — iterate with early-stop support
$users->each(function ($user) {
    if ($user->banned) {
        return false; // stops iteration
    }
});

// reduce() — fold to a single value
$totalLogins = $users->reduce(fn($carry, $u) => $carry + $u->login_count, 0);

// implode() — join a column's values into a string
$names = $users->implode(', ', 'name');

See Methods.md for the complete method list with full signatures.

Relationships

WPORM supports Eloquent-style relationships. You can define them in your model using the following methods:

  • hasOne: One-to-one

    public function profile() {
        return $this->hasOne(Profile::class, 'user_id');
    }
  • hasMany: One-to-many

    public function posts() {
        return $this->hasMany(Post::class, 'user_id');
    }
  • hasOneOfMany: Single record from many (with ordering)

    public function latestPost() {
        return $this->hasOneOfMany(Post::class)->latestOfMany();
    }
    
    public function largestOrder() {
        return $this->hasOneOfMany(Order::class)->largestOfMany('total');
    }

    Use latestOfMany(), oldestOfMany(), largestOfMany(), or smallestOfMany() to specify which record to return. Access as a property for automatic resolution.

  • belongsTo: Inverse one-to-one or many

    public function user() {
        return $this->belongsTo(User::class, 'user_id');
    }

    belongsTo() returns a QueryBuilder (just like hasOne/hasMany), so it is lazy and chainable: $comment->belongsTo(User::class, 'user_id')->where('active', 1)->first(). Accessing it as a property ($comment->user) automatically resolves it to a single model via first().

  • belongsToMany: Many-to-many (with optional pivot table and keys)

    public function roles() {
        return $this->belongsToMany(Role::class, 'user_role', 'user_id', 'role_id');
    }

    Pivot table naming: If $pivotTable is omitted, WPORM follows Eloquent's convention — the lowercased, singular basenames of both models, alphabetically sorted, joined with an underscore, and automatically prefixed (e.g. User + Role{prefix}role_user). Pass an explicit pivot table name (with or without the prefix) to override this.

    Join column: The related table is joined on its own $primaryKey (not a hardcoded id), so this works correctly even if the related model uses a custom primary key.

Pivot Model Customization

WPORM supports Eloquent-style pivot customization for belongsToMany relationships.

withPivot()

Select additional pivot table columns to be accessible via $model->pivot:

$tags = $post->tags()->withPivot('order', 'active')->get();
foreach ($tags as $tag) {
    echo $tag->pivot->order;
    echo $tag->pivot->active;
}

withTimestamps()

Include pivot table timestamps (created_at, updated_at) automatically:

$tags = $post->tags()->withTimestamps()->get();
foreach ($tags as $tag) {
    echo $tag->pivot->created_at;
    echo $tag->pivot->updated_at;
}

using() — Custom Pivot Class

Use a custom pivot class for additional logic:

use MJ\WPORM\Pivot;

class TagPost extends Pivot {
    public function isPriority(): bool {
        return ($this->order ?? 0) < 10;
    }
}

$tags = $post->tags()->using(TagPost::class)->get();
foreach ($tags as $tag) {
    if ($tag->pivot->isPriority()) {
        // ...
    }
}
  • hasManyThrough: Has-many-through

    public function comments() {
        return $this->hasManyThrough(Comment::class, Post::class, 'user_id', 'post_id');
    }

    Key convention (matches Eloquent):

    • $firstKey — the foreign key on the through table (Post) that points back to this model (User). Defaults to {this_model}_id, e.g. user_id.
    • $secondKey — the foreign key on the related table (Comment) that points to the through table (Post). Defaults to {through_model}_id, e.g. post_id.
    • $localKey — the primary key on this model (User), defaults to $primaryKey.

    In other words: User → (Post.user_id) → Post → (Comment.post_id) → Comment.

  • hasOneThrough: One-to-one through an intermediate model

    // Country hasOneThrough Capital, through Land:
    public function capital() {
        return $this->hasOneThrough(Capital::class, Land::class, 'country_id', 'land_id');
    }

    Same key convention as hasManyThrough, but returns a single model (or null) instead of a collection.

Polymorphic Relationships: morphOne, morphMany, morphTo

A polymorphic relationship lets a model belong to more than one other model type using a single association — e.g. a Comment that can belong to either a Post or a Video, or an Image that can belong to a Post or a User. Instead of a single foreign key column, the related table carries two columns: a *_type column storing the owning model's class (or a short morph map alias), and a *_id column storing its primary key.

  • morphOne: One-to-one polymorphic, defined on the owning model.
    // Post owns a single Image via imageable_type / imageable_id
    class Post extends Model {
        public function image() {
            return $this->morphOne(Image::class, 'imageable');
        }
    }
  • morphMany: One-to-many polymorphic, defined on the owning model.
    // Post and Video both own many Comments via commentable_type / commentable_id
    class Post extends Model {
        public function comments() {
            return $this->morphMany(Comment::class, 'commentable');
        }
    }
    class Video extends Model {
        public function comments() {
            return $this->morphMany(Comment::class, 'commentable');
        }
    }
  • morphTo: The inverse side, defined on the related (child) model. Resolves to whichever model class is actually named in this row's own *_type column.
    class Comment extends Model {
        protected $fillable = ['commentable_type', 'commentable_id', 'body'];
    
        public function commentable() {
            return $this->morphTo('commentable');
        }
    }
    
    $comment = Comment::find(1);
    $owner = $comment->commentable; // a Post or Video instance, depending on commentable_type

    Unlike every other relationship method, morphTo() requires the morph name as its first argument (e.g. 'commentable') — PHP has no cheap, reliable way to recover the calling method's own name at runtime, so it can't be inferred automatically the way Eloquent's reflection-based version does.

Column naming: By default, morphOne($related, $name) / morphMany($related, $name) / morphTo($name) use {$name}_type and {$name}_id (e.g. 'imageable'imageable_type / imageable_id). Pass explicit $type/$id arguments to override either column name:

$this->morphOne(Image::class, 'imageable', 'img_type', 'img_id');

Schema: Add both columns wherever you store the polymorphic relation — typically a string/varchar *_type column and an unsigned-integer *_id column, usually indexed together:

public function up(Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->string('commentable_type');
    $table->unsignedBigInteger('commentable_id');
    $table->index(['commentable_type', 'commentable_id']);
}

Morph Map: Short Type Aliases

By default, the *_type column stores the fully-qualified class name (e.g. App\Models\Post). Register a morphMap() to store a short string instead (e.g. post) — this keeps stored values stable even if you rename or move a class later:

use MJ\WPORM\Model;

Model::morphMap([
    'post'  => Post::class,
    'video' => Video::class,
]);

Call this once during plugin bootstrap, before any polymorphic relations are queried or saved. Once registered:

  • Writing: morphOne()/morphMany() automatically store the alias ('post') instead of the FQCN when building their query, via getMorphClass().
  • Reading: morphTo() automatically resolves the alias back to the real class via getMorphedModel().

morphMap() merges into the existing map by default; pass true as the second argument to replace it entirely: Model::morphMap([...], true). Model::getMorphMap() returns the currently registered map.

If a *_type value doesn't match any registered alias, it's treated as a literal class name automatically (Eloquent's default, un-mapped behavior) — so morphMap() is entirely optional and safe to add or skip per-model.

All relationship methods (hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough, morphOne, morphMany, morphTo) return a lazy, chainable QueryBuilder when called directly — e.g. $user->posts()->where('published', 1)->get(). When accessed as a property instead (e.g. $user->posts, $comment->user, $post->comments, $comment->commentable), WPORM automatically resolves the query for you: hasOne/belongsTo/morphOne/morphTo-style relations resolve to a single model (or null), and hasMany/belongsToMany/hasManyThrough/morphMany-style relations resolve to a Collection.

Note: Every relationship method embeds metadata about its type and keys on the returned QueryBuilder (its "relation context"). This is what powers property-access resolution, with() eager loading, and whereHas()/has() — there's no reflection or guesswork involved, so eager loading and existence filtering work correctly for all relationship types, including belongsToMany, hasManyThrough, and the polymorphic relations.

Relationship Existence Filtering: whereHas, orWhereHas, has

  • whereHas('relation', function($q) { ... }): Filter models where the relation exists and matches constraints.
  • orWhereHas('relation', function($q) { ... }): OR version of whereHas.
  • has('relation', '>=', 2): Filter models with at least (or exactly, or at most) N related records. Operator and count are optional (defaults to ">= 1"). Implemented as a correlated COUNT(*) subquery, so the count comparison is enforced precisely (not just existence).

Examples:

// Users with at least one post
User::query()->has('posts')->get();

// Users with at least 5 posts
User::query()->has('posts', '>=', 5)->get();

// Users with exactly 2 posts
User::query()->has('posts', '=', 2)->get();

// Users with at least one published post
User::query()->whereHas('posts', function($q) {
    $q->where('published', 1);
})->get();

// Works for belongsToMany and hasManyThrough too:
User::query()->whereHas('roles', function($q) {
    $q->where('name', 'admin');
})->get();

// Works for morphOne/morphMany too — posts that have at least one comment:
Post::query()->has('comments')->get();
Post::query()->whereHas('comments', function($q) {
    $q->where('approved', 1);
})->get();

Note on whereHas()/has() with morphTo: these filter from the "many" side (morphOne/morphMany, e.g. filtering Posts by their comments()), which is fully supported. Filtering from the morphTo side itself (e.g. Comment::query()->whereHas('commentable', ...)) is inherently ambiguous for polymorphic relations — the related table isn't known until each row is read — so, matching Eloquent's own constraints in this area, it resolves against a single row's own currently-loaded type and is best avoided in bulk query construction; eager-load with with('commentable') and filter in PHP instead if you need to inspect the resolved related model across many rows.

Eager Loading: with()

To avoid N+1 query problems, load relations up front with with() instead of accessing them lazily per-model. All relationship types (hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough, morphOne, morphMany, morphTo) are supported.

// Eager load a single relation
$users = User::with('posts')->get();

// Eager load multiple relations
$users = User::with(['posts', 'profile'])->get();

// Works the same on an instance query chain
$users = User::query()->where('active', true)->with('posts')->get();

// And with first()
$user = User::with('posts')->where('id', 1)->first();

// Polymorphic relations work the same way
$posts = Post::with('comments')->get();
$comments = Comment::with('commentable')->get(); // resolves Post/Video per row

with() runs exactly one extra query per relation for hasOne/hasMany/belongsTo/belongsToMany/hasManyThrough/morphOne/morphMany (not one per model), regardless of how many parent rows were fetched — it batches all parent keys into a single WHERE ... IN (...) (or, for belongsToMany/hasManyThrough, a single joined query), then distributes results back onto each parent model in memory. morphTo() is the one exception: since different rows may point to different related model classes, it runs one batched query per distinct type present in the result set (still no N+1 — typically just 1–2 extra queries even with mixed types).

Constraining an eager-loaded relation

Pass a closure to add extra WHERE constraints to the relation's query:

$users = User::with(['posts' => function($q) {
    $q->where('published', 1)->orderBy('created_at', 'desc');
}])->get();

Result shape

  • hasOne / belongsTo / morphOne / morphTo relations resolve to a single model instance (or null if none matched).
  • hasMany / belongsToMany / hasManyThrough / morphMany relations resolve to a Collection (empty if none matched).

This applies whether the relation was eager-loaded via with() or accessed lazily as a property (e.g. $user->posts, $post->user).

Disabling global scopes for an eager-loaded relation

See Per-relation global-scope control below — pass an options array instead of a plain closure to disable global scopes and/or apply a constraint together.

Eager Loading Counts: withCount()

When you only need to know how many related records each model has — not the records themselves — withCount() is far cheaper than with(): it adds a single {relation}_count integer attribute to every result, computed via one grouped COUNT(*) ... GROUP BY query per relation, rather than loading every related row.

// Adds an integer `posts_count` attribute to every user
$users = User::withCount('posts')->get();
foreach ($users as $user) {
    echo $user->posts_count;
}

// Multiple relations at once — one extra query per relation
$users = User::withCount(['posts', 'comments'])->get();

// Works the same on an instance query chain, and combines with with()
$users = User::query()->where('active', true)->withCount('posts')->get();

Constraining a count

Pass a closure to add extra WHERE constraints to the count's underlying query, same as with():

// Only count published posts
$users = User::withCount(['posts' => function($q) {
    $q->where('published', 1);
}])->get();

Custom output name

Use "relation as alias" to control the attribute name WPORM writes the count to — handy when calling withCount() more than once for the same relation with different constraints:

$users = User::withCount([
    'posts',
    'posts as published_posts_count' => function($q) {
        $q->where('published', 1);
    },
])->get();

foreach ($users as $user) {
    echo $user->posts_count;            // all posts
    echo $user->published_posts_count;  // published posts only
}

Supported relationship types

hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough, morphOne, and morphMany are all supported, mirroring with()'s coverage. morphTo is not supported for counting (the related table isn't known until each row is read, the same limitation Eloquent has) — counted relations of that type always resolve to 0.

The resulting {relation}_count is a plain integer attribute, not an eager-loaded relation — it appears automatically in toArray()/toJson() output (subject to $hidden/$visible, same as any other attribute) and does not require accessing $user->posts to read it.

Aggregate Sub-Selects: withSum(), withAvg(), withMin(), withMax()

Similar to withCount(), these methods compute a single aggregate value across a related column and attach it as a plain attribute — without loading the related records themselves. Each uses one grouped query per relation (SUM/AVG/MIN/MAX ... GROUP BY), never one query per row.

// Adds a `orders_sum_total` float attribute to every user
$users = User::withSum('orders', 'total')->get();
foreach ($users as $user) {
    echo $user->orders_sum_total;
}

// Average, minimum, and maximum
$users = User::withAvg('reviews', 'rating')->get();
$users = User::withMin('orders', 'total')->get();
$users = User::withMax('orders', 'total')->get();

// Multiple relations at once
$users = User::withSum(['orders', 'payments'], 'amount')->get();

Constraining an aggregate

Pass a closure to add extra WHERE constraints to the aggregate's underlying query:

// Only sum completed orders
$users = User::withSum(['orders' => function($q) {
    $q->where('status', 'completed');
}], 'total')->get();

Custom output name

Use "relation as alias" to control the attribute name:

$users = User::withSum([
    'orders',
    'orders as completed_orders_sum' => function($q) {
        $q->where('status', 'completed');
    },
], 'total')->get();

foreach ($users as $user) {
    echo $user->orders_sum;                // all orders
    echo $user->completed_orders_sum;      // completed only
}

Supported relationship types

Same as withCount(): hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough, morphOne, and morphMany. morphTo is not supported — the aggregate resolves to null.

Model Events and $dispatchesEvents

WPORM provides four complementary ways to respond to model lifecycle events, matching Eloquent's event architecture.

1. boot() / booted() + static event helpers (Eloquent-style)

Override booted() in your model to register event closures. The model class is booted once, on first use:

class User extends Model {
    protected static function booted() {
        static::creating(function ($user) {
            $user->slug = Str::slug($user->name);
        });

        static::saving(function ($user) {
            $user->email = strtolower($user->email);
        });

        static::retrieved(function ($user) {
            // Runs after model is fetched from DB
        });
    }
}

2. $dispatchesEvents (Eloquent-style class mapping)

Map lifecycle event short-names to listener classes. The listener must expose a handle(\MJ\WPORM\Events\ModelEvent $event) method.

use MJ\WPORM\Events\Creating;
use MJ\WPORM\Events\Deleted;

class LogUserCreating {
    public function handle(Creating $event): void {
        error_log('Creating user: ' . $event->model->email);
    }
}

class CleanupUserData {
    public function handle(Deleted $event): void {
        wp_delete_user_meta($event->model->id, 'auth_token');
    }
}

class User extends Model {
    protected $fillable = ['name', 'email'];

    public $dispatchesEvents = [
        'creating' => LogUserCreating::class,
        'deleted'  => CleanupUserData::class,
    ];
}

Halting an operation: Return false from any before-hook listener to cancel the operation. save(), delete(), and restore() return false when halted.

class ValidateEmail {
    public function handle(Creating $event) {
        if (empty($event->model->email)) {
            return false; // aborts save()
        }
    }
}

3. Observers (Eloquent-style)

Observer classes contain lifecycle methods that fire automatically for a specific model. Register an observer with Model::observe() — the observer's methods receive the model directly (not the event object), matching Eloquent's API.

class UserObserver {
    public function creating(User $user) {
        $user->slug = \sanitize_title($user->name);
    }

    public function created(User $user) {
        // Send welcome email, create default profile, etc.
        wp_mail($user->email, 'Welcome!', '...');
    }

    public function updated(User $user) {
        if ($user->isDirty('email')) {
            // Email changed — send verification
        }
    }

    public function deleted(User $user) {
        // Clean up related data
        wp_delete_user_meta($user->id, 'auth_token');
    }
}

// Register once (e.g. in a plugin bootstrap file)
User::observe(UserObserver::class);

// Also accepts an instance
User::observe(new UserObserver());

Halting from an observer: Return false from a before-hook method (creating, updating, saving, deleting, etc.) to cancel the operation:

class PreventDoublePost {
    public function creating($model) {
        if (session_status() === PHP_SESSION_ACTIVE && $_SESSION['just_saved'] ?? false) {
            return false;
        }
    }
}

Observer API:

  • Model::observe($observer) — register an observer (class name or instance)
  • Model::getObservers() — get all registered observers for this model
  • Model::forgetObservers($class) — remove one observer (or all if null)
  • Model::flushAllObservers() — remove all observers from all models (useful in tests)

Supported observer methods: retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, softDeleting, softDeleted, restoring, restored

4. Global listeners via EventDispatcher

Register listeners that fire for every model that raises an event, regardless of model class:

use MJ\WPORM\EventDispatcher;
use MJ\WPORM\Events\Creating;
use MJ\WPORM\Events\Saved;

// Closure
EventDispatcher::listen(Creating::class, function(Creating $event) {
    error_log(get_class($event->model) . ' is being created');
});

// Class-string (must have handle() method)
EventDispatcher::listen(Saved::class, \App\Listeners\AuditLog::class);

// Remove listeners
EventDispatcher::forget(Creating::class); // one event
EventDispatcher::forget();                // all events

Supported lifecycle events

Event class Key in $dispatchesEvents Fires when
Events\Retrieved retrieved after fetch from DB
Events\Saving saving before INSERT or UPDATE
Events\Saved saved after INSERT or UPDATE
Events\Creating creating before INSERT
Events\Created created after INSERT
Events\Updating updating before UPDATE
Events\Updated updated after UPDATE
Events\Deleting deleting before hard DELETE
Events\Deleted deleted after hard DELETE
Events\SoftDeleting softDeleting before soft delete
Events\SoftDeleted softDeleted after soft delete
Events\Restoring restoring before restore
Events\Restored restored after restore

All event objects extend MJ\WPORM\Events\ModelEvent and carry $event->model, the model instance that fired the event.

See Methods.md for the full API reference.

Custom Attribute Accessors/Mutators

public function getQtyAttribute() {
    return $this->attributes['qty'] * 2;
}

public function setQtyAttribute($value) {
    $this->attributes['qty'] = $value / 2;
}

Appended (Computed) Attributes

You can add computed (virtual) attributes to your model's array/JSON output using the $appends property, just like in Eloquent.

protected $appends = ['user'];

public function getUserAttribute() {
    return get_user_by('id', $this->user_id);
}
  • Appended attributes are included in toArray() and JSON output.
  • The value is resolved via a get{AttributeName}Attribute() accessor or, if not present, by a public property.
  • Do not set appended attributes in a retrieved event callback; use accessors instead.

Transactions

WPORM provides an Eloquent-style DB::transaction() for safely wrapping multiple database operations in a single atomic transaction — no manual beginTransaction() / commit() / rollBack() calls required.

DB::transaction(Closure $callback, int $attempts = 1)

The callback is executed inside a transaction. If it returns without throwing, the transaction is committed and the callback's return value is forwarded to the caller. If any exception or error is thrown, the transaction is rolled back and the exception is re-thrown automatically.

use MJ\WPORM\DB;

// Basic usage — commit on success, rollback on any exception
$user = DB::transaction(function() {
    $u = User::create(['name' => 'Alice', 'email' => 'alice@example.com']);
    Profile::create(['user_id' => $u->id, 'bio' => 'Hello!']);
    return $u; // returned value is forwarded to the caller
});

echo $user->id; // the newly created user

// The transaction callback can return any value, or nothing at all
DB::transaction(function() {
    Order::query()->where('status', 'pending')->update(['status' => 'processing']);
    // no return needed for side-effect-only work
});

Automatic Deadlock Retry

Pass a second argument to retry the entire callback automatically on MySQL deadlock (error 1213) or lock-wait timeout (error 1205) — the same behaviour as Laravel's DB::transaction():

// Try up to 3 times before giving up
DB::transaction(function() {
    Inventory::query()->where('product_id', 42)->decrement('stock');
    Order::create(['product_id' => 42, 'qty' => 1]);
}, 3);

On any non-retryable exception, or after all retry attempts are exhausted, the last exception is re-thrown to the caller unchanged.

Also Available on the Query Builder

transaction() is available directly on a QueryBuilder instance for cases where you already have one:

User::query()->transaction(function() {
    User::create(['name' => 'Bob']);
    // ...
});

Manual Transaction Control

For situations where you need explicit control over the transaction boundary (e.g. across multiple request steps or within a class that manages state), the lower-level methods remain available:

$query = Parts::query();
$query->beginTransaction();
try {
    // ... multiple operations ...
    $query->commit();
} catch (\Throwable $e) {
    $query->rollBack();
    throw $e;
}

Prefer DB::transaction() over the manual approach — it guarantees the transaction is always cleaned up, even when the callback throws a non-\Exception \Throwable (e.g. a PHP Error).

Custom Queries

You can execute custom SQL queries using the underlying $wpdb instance or by extending the model/query builder. For example:

// Using the query builder for a custom select
$results = Parts::query()
    ->select(['part_id', 'SUM(qty) as total_qty'])
    ->where('product_id', 2)
    ->orderBy('total_qty', 'desc')
    ->limit(5) // Limit to top 5 parts
    ->get();

// Plain column aliasing also works: ->select(['user_id as uid', 'email'])

// Selecting all columns from a specific (joined) table with `.*` is also supported:
// ->select(['parts.*', 'products.name as product_name'])

// Using $wpdb directly for full custom SQL
global $wpdb;
$table = (new Parts)->getTable();
$results = $wpdb->get_results(
    $wpdb->prepare("SELECT part_id, SUM(qty) as total_qty FROM $table WHERE product_id = %d GROUP BY part_id", 2),
    ARRAY_A
);

You can also add custom static methods to your model for more complex queries:

class Parts extends Model {
    // ...existing code...
    public static function partsWithMinQty($minQty) {
        return static::query()->where('qty', '>=', $minQty)->get();
    }
}

// Usage:
$parts = Parts::partsWithMinQty(5);

Raw SQL Expressions

When the fluent query builder can't cleanly express what you need — SQL functions, computed columns, vendor-specific syntax — drop down to raw SQL for individual clauses with selectRaw(), whereRaw()/orWhereRaw(), groupByRaw(), and havingRaw()/orHavingRaw() (alongside the existing orderByRaw()). Bindings use the same %s-style placeholders as the rest of WPORM and are passed straight through to $wpdb->prepare(), so they're just as safe as the regular query builder methods — and they can be freely mixed with non-raw calls in the same query.

// selectRaw() — add a raw expression to the SELECT list (combine with select())
$products = Product::query()
    ->select('name')
    ->selectRaw('price * %s as adjusted_price', [1.1])
    ->get();

// whereRaw() / orWhereRaw() — raw WHERE conditions
$orders = Order::query()
    ->whereRaw('YEAR(created_at) = %s AND MONTH(created_at) = %s', [2025, 6])
    ->get();

$products = Product::query()
    ->where('featured', true)
    ->orWhereRaw('price > %s', [1000])
    ->get();

// groupByRaw() — group by a SQL expression instead of a plain column
$dailyTotals = Order::query()
    ->selectRaw('DATE(created_at) as day, SUM(total) as total')
    ->groupByRaw('DATE(created_at)')
    ->get();

// havingRaw() / orHavingRaw() — raw HAVING conditions
$bigSpenders = Order::query()
    ->groupBy('user_id')
    ->havingRaw('SUM(total) > %s', [1000])
    ->get();

See Methods.md for the full list with signatures.

Subqueries: fromSub(), from(), selectSub(), whereSub() / whereInSub()

WPORM supports Eloquent-style subqueries (subselects and derived tables) in the SELECT, FROM, and WHERE clauses. Every method accepts a QueryBuilder instance, a Closure that receives a fresh builder, or a raw SQL string. Bindings propagate automatically — you never need to manage them by hand.

from() — Change Table or Use a Derived Table

from() is overloaded just like Eloquent's — it either changes the current query's target table (plain string, no alias) or uses a subquery as the FROM source (any subquery form + alias):

// Plain table change — updates the query's FROM table
$query = User::query()->from('admins')->where('active', 1)->get();

// Or change mid-chain (useful in scopes / dynamic queries)
$query = DB::table('orders')->from('invoices')->where('paid', 1)->get();

// Subquery / derived-table form — identical to fromSub()
// Closure form
$result = User::query()
    ->from(function($q) {
        $q->from('orders')
          ->select(['user_id', 'SUM(total) as revenue'])
          ->groupBy('user_id');
    }, 'order_totals')
    ->where('revenue', '>', 500)
    ->orderBy('revenue', 'desc')
    ->get();

// QueryBuilder form
$sub = Order::query()
    ->select(['user_id', 'SUM(total) as revenue'])
    ->groupBy('user_id');

$result = User::query()->from($sub, 'order_totals')->where('revenue', '>', 500)->get();

// Raw SQL string form
$result = DB::table(
    'SELECT user_id, SUM(total) as revenue FROM orders GROUP BY user_id',
    'order_totals'
)->where('revenue', '>', 100)->get();

Note: A string $alias is required when passing a Closure, QueryBuilder, or raw SQL string as the first argument. from() throws \InvalidArgumentException if a non-string subquery is passed without an alias. Providing an alias alongside a plain string table name makes from() treat that string as a raw SQL subquery expression — matching Eloquent's behaviour.

fromSub() — Derived Tables

fromSub() is the explicit derived-table form. It is equivalent to from($query, $alias) and is kept for API compatibility and explicitness:

// Closure form (inline)
$result = DB::table(function($q) {
    $q->from('orders')
      ->select(['user_id', 'SUM(total) as revenue'])
      ->groupBy('user_id');
}, 'order_totals')
->where('revenue', '>', 500)
->orderBy('revenue', 'desc')
->get();

// QueryBuilder form
$sub = Order::query()
    ->select(['user_id', 'SUM(total) as revenue'])
    ->groupBy('user_id');

$result = DB::table($sub, 'order_totals')
    ->where('revenue', '>', 500)
    ->get();

// On an existing model query
$activeUsers = User::query()
    ->fromSub(function($q) {
        $q->from('users')->where('active', 1)->select('*');
    }, 'active_users')
    ->orderBy('name')
    ->get();

selectSub() — Scalar Subselects

Add a subquery to the SELECT list, aliased as a virtual column on each returned row.

$users = User::query()
    ->select(['id', 'name'])
    ->selectSub(function($q) {
        $q->from('posts')
          ->selectRaw('COUNT(*)')
          ->whereColumn('user_id', 'users.id');
    }, 'post_count')
    ->selectSub(function($q) {
        $q->from('orders')
          ->selectRaw('SUM(total)')
          ->whereColumn('user_id', 'users.id');
    }, 'order_total')
    ->get();

foreach ($users as $user) {
    echo $user->post_count;
    echo $user->order_total;
}

whereSub() / whereInSub() — Subqueries in WHERE

// WHERE id IN (subquery) — shorthand
User::query()->whereInSub('id', function($q) {
    $q->from('role_user')->select('user_id')->where('role_id', 1);
})->get();

// WHERE id NOT IN (subquery)
User::query()->whereNotInSub('id', function($q) {
    $q->from('banned_users')->select('user_id');
})->get();

// WHERE total > (SELECT AVG(total) FROM orders)
Order::query()->whereSub('total', '>', function($q) {
    $q->from('orders')->selectRaw('AVG(total)');
})->get();

// OR variants
User::query()
    ->where('is_superadmin', 1)
    ->orWhereInSub('id', function($q) {
        $q->from('role_user')->select('user_id')->where('role_id', 2);
    })
    ->get();

// Mix with existing QueryBuilder
$adminIds = DB::table('role_user')->select('user_id')->where('role_id', 1);
User::query()->whereInSub('id', $adminIds)->get();

All subquery methods (whereSub, orWhereSub, whereInSub, whereNotInSub, orWhereInSub, orWhereNotInSub) fully participate in the same binding-order pipeline as the rest of WPORM — safe to combine with whereRaw(), havingRaw(), selectRaw(), and unions on the same query.

See Methods.md for the full method signatures.

Combining Queries: union() / unionAll()

WPORM supports Eloquent-style query unions via union() and unionAll() on the query builder. union() combines this query's result set with another query's, removing duplicate rows (SQL UNION); unionAll() does the same but keeps duplicates (SQL UNION ALL). Both accept either an already-built query (your own, or another model's) or a closure that builds the second branch inline against the same model.

// Combine with another already-built query
$highVotes = User::query()->where('votes', '>', 100);
$lowVotes  = User::query()->where('votes', '<', 10);
$users = $highVotes->union($lowVotes)->get();

// Combine using a closure
$users = User::query()
    ->where('votes', '>', 100)
    ->union(function ($query) {
        $query->where('votes', '<', 10);
    })
    ->get();

// Chain as many union()/unionAll() calls as you need
$users = User::query()->where('role', 'admin')
    ->union(User::query()->where('role', 'editor'))
    ->unionAll(User::query()->where('role', 'owner'))
    ->orderBy('name')
    ->get();

A few things to know:

  • The outer query's own orderBy()/latest()/oldest()/limit()/offset() (if set) apply to the combined result set — not to either branch individually — exactly like Eloquent/Laravel. If a branch itself has its own ordering or limiting, WPORM automatically wraps that branch in parentheses so its ordering/limiting is preserved rather than ambiguously merged into the outer query.
  • get(), first(), paginate()/simplePaginate(), count(), exists()/doesntExist(), pluck(), and the aggregates (sum(), avg()/average(), min(), max()) all correctly operate on the combined result set when unions are present — not just the base query's rows.
  • Both sides of a union must select the same number of columns (a SQL requirement). With the closure form, the second branch defaults to the same model's * selection, so column counts line up automatically unless you call select() inside the closure.
  • Soft-delete scoping is applied independently to the outer query and to every union branch, just as if you had called get() on each one separately.
  • union()/unionAll() apply to read queries only; they have no effect on update()/delete().

See Methods.md for the full method signatures.

Raw Table Queries with DB::table()

WPORM now supports Eloquent-style raw table queries using the DB class:

use MJ\WPORM\DB;

// Update posts with IDs 3, 4, 5
db::table('post')
    ->whereIn('id', [3, 4, 5])
    ->update(['title' => 'Updated Title']);

// Select rows from any table
db::table('custom_table')->where('status', 'active')->get();

See DB.md for more details.

Query Logging & Debugging

WPORM provides a centralized query logging system for debugging and profiling:

use MJ\WPORM\DB;

// Enable query logging
DB::enableQueryLog();

// Run queries
User::where('active', true)->get();
Post::where('published', true)->limit(10)->get();

// Get logged queries
$queries = DB::getQueryLog();
foreach ($queries as $q) {
    echo "{$q['time']}ms: {$q['query']}\n";
}

// Register a listener for real-time monitoring
DB::listen(function($sql, $bindings, $time) {
    if ($time > 100) {
        error_log("[SLOW QUERY] {$time}ms: {$sql}");
    }
});

// Get stats
echo "Queries: " . DB::queryCount() . "\n";
echo "Total time: " . DB::queryTime() . "ms\n";

// Clear the log
DB::flushQueryLog();

Complex Where Statements

WPORM now supports complex nested where/orWhere statements using closures, similar to Eloquent:

$users = User::query()
    ->where(function ($query) {
        $query->where('country', 'US')
              ->where(function ($q) {
                  $q->where('age', '>=', 18)
                    ->orWhere('verified', true);
              });
    })
    ->orWhere(function ($query) {
        $query->where('country', 'CA')
              ->where('subscribed', true);
    })
    ->get();

You can still use multiple where calls for AND logic, and orWhere for OR logic:

$parts = Parts::query()
    ->where('qty', '>', 5)
    ->where('product_id', 2)
    ->orWhere('qty', '<', 2)
    ->get();

Note: For very advanced SQL, you can always use $wpdb directly.

Note: where()/orWhere() detect nested groups via instanceof \Closure, so column names that happen to match PHP function names (e.g. trim, count, date) are treated as plain column names, not as closures — ->where('count', 5) works exactly as expected.

You can also use $wpdb directly for complex SQL logic:

global $wpdb;
$table = (new User)->getTable();
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM $table WHERE (country = %s AND (age >= %d OR verified = %d)) OR (country = %s AND subscribed = %d)",
        'US', 18, 1, 'CA', 1
    ),
    ARRAY_A
);

Using newQuery()

The newQuery() method returns a fresh query builder instance for your model. This is useful when you want to start a new query chain, especially in custom scopes or advanced use cases. It is functionally similar to query(), but is a common convention in many ORMs.

Example:

// Start a new query chain for the User model
$query = User::newQuery();
$activeUsers = $query->where('active', true)->get();

You can use newQuery() anywhere you would use query(). Both methods are available for convenience and compatibility with common ORM patterns.

Timestamp Columns

You can customize how WPORM handles timestamp columns in your models. By default, models will automatically manage created_at and updated_at columns if $timestamps = true (the default).

Example: Customizing Timestamp Column Names

use MJ\WPORM\Model;
use MJ\WPORM\Blueprint;

class Article extends Model {
    protected $table = 'articles';
    protected $fillable = ['id', 'title', 'content', 'created_on', 'changed_on'];
    protected $timestamps = true; // default is true
    protected $createdAtColumn = 'created_on';
    protected $updatedAtColumn = 'changed_on';

    public function up(Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('content');
        $table->timestamp('created_on');
        $table->timestamp('changed_on');
    }
}

With this setup, WPORM will automatically set created_on and changed_on when you create or update an Article record.

Example: Disabling Timestamps

If you do not want WPORM to manage any timestamp columns, set $timestamps = false in your model:

use MJ\WPORM\Model;
use MJ\WPORM\Blueprint;

class LogEntry extends Model {
    protected $table = 'log_entries';
    protected $fillable = ['id', 'message'];
    protected $timestamps = false;

    public function up(Blueprint $table) {
        $table->id();
        $table->string('message');
    }
}

In this case, WPORM will not attempt to set or update any timestamp columns automatically.

Global Scopes

You can define global scopes on your model to automatically apply query constraints to all queries for that model.

Example:

class Post extends \MJ\WPORM\Model {
    protected static function boot() {
        parent::boot();
        static::addGlobalScope('published', function($query) {
            $query->where('status', 'published');
        });
    }
}

All queries will now include status = 'published' automatically:

$posts = Post::all(); // Only published posts

To disable global scopes for a query:

$allPosts = Post::query(false)->get(); // disables all global scopes
// or
$allPosts = Post::query()->withoutGlobalScopes()->get();

To remove a specific global scope at runtime:

Post::removeGlobalScope('published');

Per-relation global-scope control (eager loads)

You can disable global scopes for a specific relation when using with() to eager-load relations. Pass an array for the relation with the optional key disableGlobalScopes set to true and an optional constraint callable. This affects only the related query used to load that relation.

Examples:

// Disable global scopes for the 'topics' relation only
$department = Departments::query(false)
    ->with(['topics' => ['disableGlobalScopes' => true]])
    ->orderBy('id', 'desc')
    ->first();

print_r($department->topics);
// Disable global scopes and also apply a constraint to the related query
$dept = Departments::query()
    ->with([ 
        'topics' => [
            'disableGlobalScopes' => true,
            'constraint' => function($q) { $q->where('active', true); }
        ]
    ])
    ->first();

You can still use the shorthand closure form for simple constraints (unchanged):

$dept = Departments::query()->with(['topics' => function($q) {
    $q->where('active', true);
}])->first();

Query Scopes as Classes

WPORM supports reusable, testable scope classes via ScopeInterface. Unlike scope*() methods (which live on the model), scope classes are standalone, shareable, and easy to unit test.

Defining a Scope Class

use MJ\WPORM\Scopes\ScopeInterface;
use MJ\WPORM\QueryBuilder;
use MJ\WPORM\Model;

class ActiveScope implements ScopeInterface {
    public function apply(QueryBuilder $query, Model $model): void {
        $query->where('active', true);
    }
}

// Or extend the abstract base class
use MJ\WPORM\Scopes\Scope;

class RecentScope extends Scope {
    public function apply(QueryBuilder $query, Model $model): void {
        $query->where('created_at', '>=', date('Y-m-d H:i:s', strtotime('-30 days')));
    }
}

Using Scope Classes

// Register as a global scope (auto-instantiated from class-string)
User::addGlobalScope('active', ActiveScope::class);

// Apply ad-hoc (one-off, no global registration)
$users = User::query()->applyScope(new ActiveScope())->get();

// Works with any model
$posts = Post::query()->applyScope(new ActiveScope())->get();

Benefits Over scope*() Methods

scope*() methods ScopeInterface classes
Location On the model class Standalone classes
Reusability Single model only Any model
Testability Requires model instance Unit-testable in isolation
Composition Manual Composable via applyScope()

Soft Deletes

WPORM supports Eloquent-style soft deletes, allowing you to "delete" records without actually removing them from the database. To enable soft deletes on a model, set the $softDeletes property to true:

class User extends Model {
    protected $softDeletes = true;
    // Optionally customize the deleted_at column:
    // protected $deletedAtColumn = 'deleted_at';
    // Optionally set the soft delete type (see below)
    // protected $softDeleteType = 'timestamp'; // or 'boolean'
}

Soft Delete Strategies: Timestamp vs Boolean Flag

WPORM supports two soft delete strategies:

  1. Timestamp column (default, Eloquent-style):

    • Uses a deleted_at (or custom) column to store the deletion datetime.
    • Set $softDeletes = true; and (optionally) $deletedAtColumn = 'deleted_at'; in your model.
    • Example:
      class User extends Model {
          protected $softDeletes = true;
          // protected $deletedAtColumn = 'deleted_at'; // optional
          // protected $softDeleteType = 'timestamp'; // optional, default
      }
    • In your migration/schema:
      $table->timestamp('deleted_at')->nullable();
  2. Boolean flag column:

    • Uses a boolean column (e.g., deleted) to indicate soft deletion (1 = deleted, 0 = not deleted).
    • Set $softDeletes = true;, $deletedAtColumn = 'deleted', and $softDeleteType = 'boolean'; in your model.
    • Example:
      class Product extends Model {
          protected $softDeletes = true;
          protected $deletedAtColumn = 'deleted'; // boolean column
          protected $softDeleteType = 'boolean'; // enable boolean-flag mode
      }
    • In your migration/schema:
      $table->boolean('deleted')->default(0);

How it works

  • Timestamp mode:
    • delete() sets deleted_at to the current datetime.
    • restore() sets deleted_at to null.
    • Queries exclude rows where deleted_at is not null (unless withTrashed() or onlyTrashed() is used).
    • Bulk update() and delete() on the query builder also exclude soft-deleted rows.
  • Boolean mode:
    • delete() sets deleted to 1 (true).
    • restore() sets deleted to 0 (false).
    • Queries exclude rows where deleted is true (unless withTrashed() or onlyTrashed() is used).
    • Bulk update() and delete() on the query builder also exclude soft-deleted rows.

Example Usage

// Timestamp soft deletes (default)
$user = User::find(1);
$user->delete(); // sets deleted_at
User::query()->withTrashed()->get(); // includes soft-deleted
User::query()->onlyTrashed()->get(); // only soft-deleted
$user->restore(); // sets deleted_at to null

// Boolean flag soft deletes
$product = Product::find(1);
$product->delete(); // sets deleted = 1
Product::query()->withTrashed()->get(); // includes deleted
Product::query()->onlyTrashed()->get(); // only deleted
$product->restore(); // sets deleted = 0

Prunable / MassPrunable Traits

WPORM provides two traits for automatic cleanup of old records, similar to Eloquent's Prunable and MassPrunable.

Prunable

The Prunable trait processes records one at a time, firing model events (deleting/deleted) for each. Use this when you need to run logic during pruning or have event-driven workflows.

use MJ\WPORM\Prunable;

class AuditLog extends Model {
    use Prunable;

    public function prunable() {
        // Prune records older than 90 days
        return static::query()->where('created_at', '<', now()->subDays(90));
    }
}

// Run the pruning
$pruned = AuditLog::prune();
echo "Pruned {$pruned} records";

MassPrunable

The MassPrunable trait processes records in chunks using direct SQL DELETE queries. Model events are not fired. Use this for large datasets where performance is critical.

use MJ\WPORM\MassPrunable;

class AuditLog extends Model {
    use MassPrunable;

    public function prunable() {
        return static::query()->where('created_at', '<', now()->subDays(90));
    }
}

// Run the mass pruning (default chunk size: 1000)
$pruned = AuditLog::prune();

// Custom chunk size
$pruned = AuditLog::prune(5000);

Prunable vs MassPrunable

Prunable MassPrunable
Processing One record at a time In chunks (default: 1000)
Model events Yes (deleting, deleted) No
Memory usage Low (uses cursor) Low (uses pluck + chunk)
Best for Event-driven logic, small datasets Large datasets, performance-critical

Conditional Queries: when()

WPORM supports Eloquent-style conditional queries using the when() method. This allows you to add query constraints only if a given condition is true, making your code more readable and dynamic.

Usage:

// Add a where clause only if $isActive is true
$users = User::query()
    ->when($isActive, function ($query) {
        $query->where('active', true);
    })
    ->get();

// You can also provide a default callback for the false case
$users = User::query()
    ->when($country, function ($query, $country) {
        $query->where('country', $country);
    }, function ($query) {
        $query->where('country', 'US'); // fallback
    })
    ->get();
  • The first argument is the condition value.
  • The second argument is a callback executed if the condition is truthy.
  • The optional third argument is a callback executed if the condition is falsy.

This method is available on both the query builder and as a static method on models.

Functional Chaining: tap() and pipe()

WPORM supports Eloquent-style tap() and pipe() on the query builder for functional, chainable patterns.

tap($callback)

Passes the query builder to the given callback for side-effects, then returns the builder unchanged. The callback's return value is always discarded. Use this for logging, debugging, or applying a reusable decorator without breaking the fluent chain.

$users = User::query()
    ->where('active', true)
    ->tap(function ($query) {
        error_log('[Debug] SQL: ' . $query->toSql());
    })
    ->orderBy('name')
    ->get();

// Accepts any callable:
$query->tap([$this, 'applyDefaultScopes'])->get();

pipe($callback)

Passes the query builder to the given callback and returns whatever the callback returns. Unlike tap(), pipe() terminates or transforms the fluent chain — the callback's return value replaces the builder. Use this to hand the builder off to a repository function or a reusable scope and return its result inline.

// Execute a scope and return the Collection:
$users = User::query()
    ->where('active', true)
    ->pipe(function ($query) {
        return $query->orderBy('name')->get();
    });

// Inject repository logic mid-chain:
$result = User::query()
    ->pipe([$userRepo, 'applySearchFilters'])
    ->paginate(20);

Summary:

  • tap($cb) — always returns $this; callback return value is ignored. Use for side-effects.
  • pipe($cb) — returns whatever the callback returns. Use to produce a result or delegate to another layer.

Troubleshooting & Tips

  • Table Prefixing: Always use $table = (new ModelName)->getTable(); to get the correct, prefixed table name for custom SQL. Do not manually prepend $wpdb->prefix.
  • Model Booting: If you add static boot methods or global scopes, ensure you call them before querying if not using the model's constructor.
  • Schema Changes: Your model's up(Blueprint $blueprint) method is the single source of truth for the table schema — WPORM reads it via $blueprint->toSql() automatically, so you no longer need to assign $this->schema yourself. If you change up(), you may need to drop and recreate the table or use the SchemaBuilder's table() method for migrations.
  • Reusing a Query Builder: It's safe to call toSql(), count(), get(), etc. multiple times (or in combination, as paginate() does internally) on the same query instance — soft-delete constraints and HAVING bindings are only applied once per instance and won't duplicate or misalign bindings on repeat calls.
  • Constructing Models: new Model(['id' => 5]) (or any attributes) only fills the model's attributes in memory — it does not query the database. Use Model::find($id) to load an existing record.
  • Events: WPORM supports four complementary event approaches. (1) Override booted() and use static::creating(fn), static::saving(fn), etc. to register event closures — this is the recommended Eloquent-style approach. (2) Use $dispatchesEvents to map event names to listener classes — the listener must expose a handle(\MJ\WPORM\Events\ModelEvent $event) method. (3) Register global listeners via EventDispatcher::listen(EventClass::class, $listener) to respond to any model's events. (4) Use observers via Model::observe(). All fire in that order per event. Any before-hook listener can cancel an operation by returning false. See Methods.md for full API.
  • Extending Casts: Implement MJ\WPORM\Casts\CastableInterface for custom attribute casting logic.
  • Testing: Always test your queries and schema changes on a staging environment before deploying to production.

Contributing

Contributions, bug reports, and feature requests are welcome! Please open an issue or submit a pull request.

Credits

WPORM is inspired by Laravel's Eloquent ORM and adapted for the WordPress ecosystem.

Security Note

  • Always validate and sanitize user input, even when using the ORM. The ORM helps prevent SQL injection, but you are responsible for data integrity and security.

Performance Tips

  • Use indexes for columns you frequently query (e.g., foreign keys, search fields). The ORM's schema builder supports $table->index('column').
  • For large datasets, use pagination and limit/offset queries to avoid memory issues:
    // For large datasets, use limit and offset for pagination:
    $usersPage2 = User::query()->orderBy('id')->limit(20)->offset(20)->get(); // Get users 21-40

FAQ

Q: Why is my table not created?

  • A: Ensure your model's up(Blueprint $blueprint) method correctly builds the columns on the $blueprint argument (WPORM reads the schema from it automatically). Check for errors in your column definitions, and check $wpdb->last_error for SQL errors.

Q: How do I debug a failed query?

  • A: Use $wpdb->last_query and $wpdb->last_error after running a query to inspect the last executed SQL and any errors.

Q: Can I use this ORM outside of WordPress?

  • A: No, it is tightly coupled to WordPress's $wpdb and plugin environment.

Resources

License Details

This project is licensed under the MIT License. See the LICENSE file or MIT License for details.