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)
}
| Property | Purpose |
|---|---|
$table | Table name without prefix. Derived from the class name when unset: Contact → contacts. |
$primaryKey | Primary key column. Defaults to id. |
$prefix | Replaces the plugin prefix for this model. The WordPress prefix is still applied. |
$timestamps | Auto-fill created_at on insert and updated_at on insert and update. |
$fillable | Mass-assignment allow-list. When unset, all attributes except the primary key and the timestamp columns are fillable. |
$casts | Attribute cast map; see Attribute casting. |
$soft_deletes | delete() sets deleted_at instead of removing the row. |
$soft_delete_scope | Whether 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
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
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',
];
| Cast | Result |
|---|---|
int / integer | (int) value |
float / double | (float) value |
string | (string) value |
bool / boolean | (bool) value |
array / json | json_decode($value, true) |
object | json_decode($value) |
date / datetime | DateTime 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/savedfire on both insert and update;creating/createdonly on insert,updating/updatedonly on update.- Returning
falsefromsaving,updatingordeletingaborts the operation. booting()andbooted()are protected static hooks you override rather than register; they run before and afterboot(), once per class.
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
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().