PHPackages                             mjkhajeh/wporm - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. mjkhajeh/wporm

ActiveLibrary[Database &amp; ORM](/categories/database)

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 build modern, maintainable database code in any WordPress project.

v3.15.0.2(today)6263↑43.8%1MITPHPPHP &gt;=7.4

Since Jun 7Pushed 2d ago3 watchersCompare

[ Source](https://github.com/mjkhajeh/wporm)[ Packagist](https://packagist.org/packages/mjkhajeh/wporm)[ RSS](/packages/mjkhajeh-wporm/feed)WikiDiscussions main Synced today

READMEChangelog (8)DependenciesVersions (89)Used By (1)

WPORM - Lightweight WordPress ORM
=================================

[](#wporm---lightweight-wordpress-orm)

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](https://private-user-images.githubusercontent.com/81983167/452540239-f84f6905-4279-4ee3-9e1f-9fb9a3fd2e51.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3ODMxNDU3ODAsIm5iZiI6MTc4MzE0NTQ4MCwicGF0aCI6Ii84MTk4MzE2Ny80NTI1NDAyMzktZjg0ZjY5MDUtNDI3OS00ZWUzLTllMWYtOWZiOWEzZmQyZTUxLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA3MDQlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNzA0VDA2MTEyMFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTNjMzc1YjExNGZkZDQ3ZWNhNTMyNjI3MWFlYThjZDI5NGM1YzQzYWQwZTkwNDU5MGVhNTlkZDY0OTM0ZmQwZDgmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.OFgafSS-CGum7aJgyiqmW3ZUPXuvnkYEhIfaQzuedVg)](https://private-user-images.githubusercontent.com/81983167/452540239-f84f6905-4279-4ee3-9e1f-9fb9a3fd2e51.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3ODMxNDU3ODAsIm5iZiI6MTc4MzE0NTQ4MCwicGF0aCI6Ii84MTk4MzE2Ny80NTI1NDAyMzktZjg0ZjY5MDUtNDI3OS00ZWUzLTllMWYtOWZiOWEzZmQyZTUxLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA3MDQlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNzA0VDA2MTEyMFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTNjMzc1YjExNGZkZDQ3ZWNhNTMyNjI3MWFlYThjZDI5NGM1YzQzYWQwZTkwNDU5MGVhNTlkZDY0OTM0ZmQwZDgmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.OFgafSS-CGum7aJgyiqmW3ZUPXuvnkYEhIfaQzuedVg)

Documentation
-------------

[](#documentation)

- [Methods list and documents](./Methods.md)
- [Blueprint and column types documents](./Blueprint.md)
- [Casts types and define custom casts](./CastsType.md)
- [DB usage and raw queries](./DB.md)
- [Debugging tips](./Debugging.md)

Features
--------

[](#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 &amp; 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 event hooks (`creating`, `updating`, `deleting`, etc.) via overridable methods, Eloquent-style `$dispatchesEvents` property mapping, 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
------------

[](#installation)

### With Composer (Recommended)

[](#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

[](#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
----------------

[](#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
-----------------

[](#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)

[](#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
-----------

[](#basic-usage)

### Creating a Record

[](#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()](#one-line-create-create) below.

### Querying Records

[](#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

[](#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

[](#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()

[](#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

[](#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

[](#creating-or-getting-records-firstorcreate-and-firstornew)

### Inserting Records: insertOrIgnore

[](#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

[](#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

[](#updating-a-record)

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

### Deleting a Record

[](#deleting-a-record)

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

### Truncating a Table

[](#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()

[](#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()

[](#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

[](#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 &amp; Utility Methods
--------------------------------

[](#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()

[](#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](./Methods.md#aggregates--utility-methods) for the full list with signatures.

Pagination
----------

[](#pagination)

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

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

[](#paginateperpage--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)

[](#simplepaginateperpage--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](./Methods.md) for more details and options.

Processing Large Datasets: chunk() and each()
---------------------------------------------

[](#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)

[](#chunkcount-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)

[](#eachcallback-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()

[](#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 executionSingle queryMultiple paginated queriesMemory modelOne model at a timeOne page at a timeEarly stopBreak out of `foreach`Return `false` from callbackBest forSimple iteration over huge setsComplex per-page logic or early-stopBoth approaches keep memory low — `cursor()` is simpler when you just need to loop through everything once.

Attribute Casting
-----------------

[](#attribute-casting)

Add a `$casts` property to your model:

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

Array Conversion and Casting
----------------------------

[](#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()
-----------------------

[](#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 &amp; Visible Attributes](#hidden--visible-attributes-hidden-and-visible) 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
--------------------------------------------------

[](#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
----------------------------------------

[](#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 &amp; Visible Attributes: $hidden and $visible
-----------------------------------------------------

[](#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
-----------

[](#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

[](#available-methods)

MethodReturnsDescription`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 `Collection`s, 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](./Methods.md#collection-methods).

### map() vs transform()

[](#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-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

[](#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

[](#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](./Methods.md#collection-methods) for the complete method list with full signatures.

Relationships
-------------

[](#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

[](#pivot-model-customization)

WPORM supports Eloquent-style pivot customization for `belongsToMany` relationships.

#### withPivot()

[](#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()

[](#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

[](#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

[](#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](#morph-map-short-type-aliases) 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

[](#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

[](#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 "&gt;= 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 `Post`s 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()
---------------------

[](#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

[](#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

[](#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

[](#disabling-global-scopes-for-an-eager-loaded-relation)

See [Per-relation global-scope control](#per-relation-global-scope-control-eager-loads) below — pass an options array instead of a plain closure to disable global scopes and/or apply a constraint together.

Eager Loading Counts: withCount()
---------------------------------

[](#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

[](#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

[](#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

[](#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()
-----------------------------------------------------------------

[](#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

[](#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

[](#custom-output-name-1)

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

[](#supported-relationship-types-1)

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

Model Events and $dispatchesEvents
----------------------------------

[](#model-events-and-dispatchesevents)

WPORM provides four complementary ways to respond to model lifecycle events.

### 1. Method overrides (back-compat)

[](#1-method-overrides-back-compat)

Override `creating()`, `updating()`, `deleting()` (and the soft-delete variants) directly on the model:

```
class User extends Model {
    protected function creating() {
        $this->name = sanitize_text_field($this->name);
    }
    protected function deleting() {
        // clean up related data
    }
}
```

### 2. $dispatchesEvents (Eloquent-style class mapping)

[](#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)

[](#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

[](#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

[](#supported-lifecycle-events)

Event classKey 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 restoreAll event objects extend `MJ\WPORM\Events\ModelEvent` and carry `$event->model`, the model instance that fired the event.

See [Methods.md](./Methods.md#dispatchesevents-and-eventdispatcher) for the full API reference.

---

Custom Attribute Accessors/Mutators
-----------------------------------

[](#custom-attribute-accessorsmutators)

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

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

Appended (Computed) Attributes
------------------------------

[](#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 `retrieved()`; use accessors instead.

Transactions
------------

[](#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)

[](#dbtransactionclosure-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

[](#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

[](#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

[](#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
--------------

[](#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
-------------------

[](#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](./Methods.md#raw-sql-expressions) for the full list with signatures.

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

[](#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--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--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

[](#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

[](#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](./Methods.md#subquery-support) for the full method signatures.

Combining Queries: union() / unionAll()
---------------------------------------

[](#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', '', 100)
    ->union(function ($query) {
        $query->where('votes', '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](./DB.md) for more details.

Query Logging &amp; Debugging
-----------------------------

[](#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
------------------------

[](#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', '=', date('Y-m-d H:i:s', strtotime('-30 days')));
    }
}
```

### Using Scope Classes

[](#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

[](#benefits-over-scope-methods)

`scope*()` methods`ScopeInterface` classesLocationOn the model classStandalone classesReusabilitySingle model onlyAny modelTestabilityRequires model instanceUnit-testable in isolationCompositionManualComposable via `applyScope()`Soft Deletes
------------

[](#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

[](#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

[](#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

[](#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
------------------------------

[](#prunable--massprunable-traits)

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

### Prunable

[](#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', '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)

[](#pipecallback)

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 &amp; Tips
--------------------------

[](#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 three complementary event approaches. (1) Override `creating()`, `updating()`, `deleting()` etc. directly on the model. (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. All three fire in that order per event. Any before-hook listener can cancel an operation by returning `false`. See [Methods.md]($dispatchesEvents-and-eventdispatcher) 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
------------

[](#contributing)

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

Credits
-------

[](#credits)

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

Security Note
-------------

[](#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
----------------

[](#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
---

[](#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
---------

[](#resources)

- [WordPress Plugin Developer Handbook](https://developer.wordpress.org/plugins/)
- [Laravel Eloquent ORM Documentation](https://laravel.com/docs/eloquent)

License Details
---------------

[](#license-details)

This project is licensed under the MIT License. See the LICENSE file or [MIT License](https://opensource.org/licenses/MIT) for details.

---

###  Health Score

49

—

FairBetter than 94% of packages

Maintenance100

Actively maintained with recent releases

Popularity21

Limited adoption so far

Community12

Small or concentrated contributor base

Maturity53

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~4 days

Total

88

Last Release

0d ago

Major Versions

v1.9.9 → v2.0.02025-06-26

v2.34.0 → v3.0.0.02026-06-29

### Community

Maintainers

![](https://www.gravatar.com/avatar/3b7e4293c9f0f03906ad75a2e557157b3c12919babd31bd1e7bf27694e753011?d=identicon)[mjkhajeh](/maintainers/mjkhajeh)

---

Top Contributors

[![mjkhajeh](https://avatars.githubusercontent.com/u/81983167?v=4)](https://github.com/mjkhajeh "mjkhajeh (295 commits)")

---

Tags

ormorm-frameworkwordpresswordpress-boilerplatewordpress-developmentwordpress-pluginwordpress-themewporm

### Embed Badge

![Health badge](/badges/mjkhajeh-wporm/health.svg)

```
[![Health](https://phpackages.com/badges/mjkhajeh-wporm/health.svg)](https://phpackages.com/packages/mjkhajeh-wporm)
```

###  Alternatives

[jdorn/sql-formatter

a PHP SQL highlighting library

3.9k117.2M118](/packages/jdorn-sql-formatter)[propel/propel1

Propel is an open-source Object-Relational Mapping (ORM) for PHP5.

8351.6M87](/packages/propel-propel1)[pgvector/pgvector

pgvector support for PHP

198741.5k12](/packages/pgvector-pgvector)[jfelder/oracledb

Oracle DB driver for Laravel

11518.4k](/packages/jfelder-oracledb)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
