Skip to main content

Models

Introduction

Each database table has a corresponding model, an ActiveRecord object you use to query that table and to insert, update and delete rows. The API is modelled on Eloquent's; the differences are called out throughout this page and on Relationships.

Defining a model

Extend Model. Every property is optional:

use BitApps\WPDatabase\Model;

class Contact extends Model
{
protected $table = 'contacts'; // default: class name, snake_cased + "s"
protected $primaryKey = 'id'; // default: 'id'
protected $prefix = ''; // overrides the plugin prefix when non-empty

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

protected $fillable = ['first_name', 'last_name', 'email', 'status'];

protected $casts = [
'meta' => 'array',
'is_active' => 'bool',
];

public $soft_deletes = true; // delete() sets deleted_at
public $soft_delete_scope = true; // reads exclude trashed rows (default)
}
PropertyPurpose
$tableTable name without prefix. Derived from the class name when unset: Contactcontacts.
$primaryKeyPrimary key column. Defaults to id.
$prefixReplaces the plugin prefix for this model. The WordPress prefix is still applied.
$timestampsAuto-fill created_at on insert and updated_at on insert and update.
$fillableMass-assignment allow-list. When unset, all attributes except the primary key and the timestamp columns are fillable.
$castsAttribute cast map; see Attribute casting.
$soft_deletesdelete() sets deleted_at instead of removing the row.
$soft_delete_scopeWhether reads exclude trashed rows. Defaults to true on soft-delete models; set false to opt out.

The table resolves as WordPress prefix + plugin prefix + $table, e.g. wp_myplugin_contacts. See Configuration for the prefix setup.

Retrieving models

A model is a query builder entry point: every method on the query builder is available:

$contacts = Contact::query()->get();
$contact = Contact::query()->find(5);
$active = Contact::query()->where('status', 'active')->orderBy('id')->desc()->get();

Multi-row reads return a Collection; single-row reads return one model, or [] when nothing matches.

Inserting and updating

// Insert: returns the hydrated model with its generated primary key
$contact = Contact::query()->insert([
'first_name' => 'Jane',
'email' => 'jane@example.com',
]);

// Update a loaded model
$contact->status = 'active';
$contact->save();

// Update by query
Contact::query()->where('status', 'pending')->update(['status' => 'active']);

save() inserts when the model has no matching row and updates when it does. Both save() and update() return false on failure. For the full write API, see Inserts on the Query Builder page.

Mass assignment

fill() applies an array of attributes, filtered through $fillable:

$input = map_deep(wp_unslash($_POST['contact'] ?? []), 'sanitize_text_field');

$contact = new Contact();
$contact->fill($input); // non-fillable keys are ignored
$contact->fill($input, true); // second arg forces past $fillable
$contact->save();

The Model constructor accepts the same array: new Contact(['email' => 'jane@example.com']).

Deleting models

Contact::query()->where('id', 5)->delete();
Contact::query()->destroy([1, 2, 3]);

On a soft-delete model, delete() sets deleted_at:

Contact::query()->onlyTrashed()->get(); // trashed rows only
Contact::query()->withTrashed()->get(); // trashed rows included
Contact::query()->where('id', 5)->restore(); // clear deleted_at
Contact::query()->where('id', 5)->forceDelete(); // remove the row

restore() and forceDelete() throw RuntimeException on models without $soft_deletes.

Attributes

Attributes are read and written as properties or array keys, since Model implements ArrayAccess:

$contact->email;
$contact['email'];

$contact->email = 'new@example.com';
isset($contact->email);
unset($contact->email);
$contact->getAttributes(); // all attributes as an array
$contact->getAttribute('email');
$contact->setAttribute('email', 'new@example.com');
$contact->toArray(); // attributes as an array
json_encode($contact); // Model implements JsonSerializable
note

toArray() returns [] when the model does not exist in the database. If existence is unknown, it is resolved by re-querying the row, so toArray() on a hydrated-but-unsaved model returns an empty array.

Accessors

Define get{Name}Attribute() to compute an attribute that has no column. It is resolved on first access and cached as an attribute:

class Contact extends Model
{
public function getFullNameAttribute()
{
return trim($this->first_name . ' ' . $this->last_name);
}
}

$contact->full_name; // "Jane Doe"

There is no matching mutator (set{Name}Attribute()) support.

Existence and change tracking

$contact->exists(); // does this row exist? (queries once, then caches)
$contact->refresh(); // re-check existence by primary key
$contact->getDirtyAttributes(); // attributes changed since hydration
$contact->getOriginal(); // attributes as hydrated
warning

isDirty() returns true unconditionally: it null-checks an array that is never null. Use getDirtyAttributes() and check for an empty array instead.

Attribute casting

$casts converts values whenever attributes are filled, both when hydrating query results and on mass assignment:

protected $casts = [
'meta' => 'array',
'is_active' => 'bool',
'created_at' => 'date',
];
CastResult
int / integer(int) value
float / double(float) value
string(string) value
bool / boolean(bool) value
array / jsonjson_decode($value, true)
objectjson_decode($value)
date / datetimeDateTime from the Y-m-d H:i:s format

null is passed through uncast, and an unknown cast name leaves the value untouched. Add casts per query with withCast():

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

Model events

Models fire lifecycle events. Register handlers in a static boot():

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

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

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

Available events: retrieved, creating, created, saving, saved, updating, updated, deleting, deleted.

  • saving / saved fire on both insert and update; creating / created only on insert, updating / updated only on update.
  • Returning false from saving, updating or deleting aborts the operation.
  • booting() and booted() are protected static hooks you override rather than register; they run before and after boot(), once per class.
note

HasEvents reserves the method names boot, booting, booted, fireEvent, fireCustomEvent, registerEvent, and the properties $events, $registeredEvents, $booted. Don't use those names for columns or your own methods.

Model helpers

$contact = Contact::query()->find(5);

$contact->getPrimaryKey(); // 'id'
$contact->getForeignKey(); // 'contacts_id', the relation key convention
$contact->getTable(); // 'wp_myplugin_contacts'
$contact->getTableWithoutPrefix();// 'contacts'
$contact->getTablePrefix(); // 'wp_myplugin_'
$contact->getPrefix(); // query/schema default prefix
$contact->getFillable(); // resolved fillable column list
$contact->unsetAttribute('meta'); // drop an attribute
$contact->setExists(true); // override the cached existence flag
$contact->newQuery(); // fresh QueryBuilder for this model
$contact->getQueryBuilder(); // the model's cached builder

getPrefix() returns the prefix used for queries and schema; getTablePrefix() returns the full physical prefix and keeps wp_ even when the model sets a custom $prefix. Use getTablePrefix() when you need the name a join or pivot table resolves to.

Events can also be fired by hand:

$contact->fireEvent('saved'); // run a registered handler
$contact->fireCustomEvent($callback, $contact); // run a Closure or handler class
Internal API

getInstanceFromBuilder(), isRelationValue(), setRelateAs() and getRelateAs() are public because hydration and relation loading call across object boundaries. They are not part of the supported surface; treat them as internal.

Collections

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

use BitApps\WPDatabase\Collection;

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

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

$collection = Collection::make($items); // wrap an existing array

That is the whole Collection API. There is no each(), sortBy(), groupBy(), keyBy(), sum(), or contains().