This release (next tag after 1.11) contains backward-incompatible changes
to the query builder, model, relations and schema blueprint. Because the public
API and runtime behavior change, it is a major version bump.
- 1. Summary
- 2. Breaking changes
- 3. Behavioral changes
- 4. New features
- 5. Deprecations
- 6. Migration checklist
| # | Area | Change | Impact |
|---|---|---|---|
| 1 | Model | get() returns Collection, not array |
High |
| 2 | QueryBuilder | update() no longer chainable, runs immediately |
High |
| 3 | QueryBuilder | save() returns Model|false (was bool/int) |
High |
| 4 | QueryBuilder | select() back-tick quotes columns; raw exprs break |
High |
| 5 | QueryBuilder | delete() with no WHERE throws (was: table wipe) |
High |
| 6 | Model | NULL values no longer cast | Medium |
| 7 | QueryBuilder | raw()/exec() non-SELECT return changed |
Medium |
| 8 | QueryBuilder | with() signature changed (old Closure-only form gone) |
Medium |
| 9 | QueryBuilder | withCount() removed → relation aggregate |
Medium |
| 10 | Relations | addRelation() signature string → array, void |
Medium |
| 11 | Blueprint | binary() removed |
Low |
| 12 | QueryBuilder | exception type/message changed in exec() |
Low |
| 13 | Model | soft-delete reads exclude trashed by default (opt out with $soft_delete_scope = false) |
Medium |
| 14 | Composer | minimum PHP raised to 8.2 (was 7.4) — package no longer installs on PHP < 8.2 | High |
Multi-row reads now return BitApps\WPDatabase\Collection instead of a plain
PHP array. find() always returns a Collection (even for a single PK); use
findOne() or ->first() for a single model. Empty results return [].
// Before
$users = User::where('active', 1)->get(); // array<User>
is_array($users); // true
array_map($fn, $users); // OK
// After
$users = User::where('active', 1)->get(); // Collection<User>
is_array($users); // false ❌
array_map($fn, $users); // TypeError ❌Why it breaks: is_array() checks fail; native array_* functions reject
the object.
Migration: Collection implements ArrayAccess, IteratorAggregate,
Countable, JsonSerializable — so foreach, $users[0], count($users) and
json_encode($users) keep working. Replace native array calls with collection
methods:
$users->map($fn); // instead of array_map
$users->filter($fn); // instead of array_filter
$users->pluck('id'); // column extraction
$users->first(); // first item (or matching callback)
$users->last();
$users->reduce($fn, $initial);
$users->all(); // underlying plain array
$users->toArray(); // array of model arraysSilent-data-loss trap: an
if (is_array($result)) { … }"got rows?" guard now inverts — a non-empty read is aCollection(is_array→ false) while a zero-row read is a real[](is_array→ true) — so the branch runs only when there is nothing to process, silently dropping data whenever rows exist. Replace such guards withempty($result)/!empty($result).
// Before — returned $this, deferred execution
$qb->update(['name' => 'x'])->where('id', 1)->save();
// After — executes now, returns result (not $this)
User::where('id', 1)->update(['name' => 'x']); // runs UPDATE immediatelyIf the bound model already exists, update() now delegates to save()
(dirty-attribute update). On a fresh model it builds the update from all
attributes and executes.
Why it breaks: any chain calling a method after update() breaks —
update() no longer returns the builder.
Migration: set conditions before update(); drop trailing
->save()/->exec().
Don't chain after
update():update()returns the result (Model|falsefor an existing model,int|falsefor a fresh one), never the builder, so a trailing builder call breaks. On an existing model$model->update([...])->save()now works — the->save()lands on the returned Model and no-ops (redundant) — but on a fresh model theintreturn still fatals (save()on int). Drop the trailing->save();update()already persisted. (Before thesave()0-row fix in §3, the existing-model chain also fataled on any no-op update.)
// Before
$result = $model->save();
// insert -> bool(true) ; update -> int rows_affected
// After
$result = $model->save();
// success -> the Model instance ; failure -> bool(false)Why it breaks: code relying on true / rows_affected int now receives a
Model object on success.
Migration: treat the return as truthy for success; read affected rows via
Connection::prop('rows_affected') if needed.
select() and addSelect() pass every column through prepareColumnName(),
which wraps the name as `table`.`column` unless it already contains a ..
// Before
->select('COUNT(*) as total') // emitted: COUNT(*) as total
// After
->select('COUNT(*) as total') // emitted: `COUNT(*) as total` ❌ invalid SQLWhy it breaks: a raw SQL expression or function call passed to select() is
quoted as a single identifier.
A plain column AS alias is handled — prepareColumnName() qualifies the
column and keeps the alias separate, so ->select(['id', 'title AS t']) emits
`table`.`id`, `table`.`title` AS `t`. Only expressions/function calls
(COUNT(*), SUM(amount) AS amt, …) still need selectRaw():
->select(['title AS t']) // ✅ simple column alias
->selectRaw('COUNT(*) as total') // expressions / functions
->selectRaw('SUM(amount) as amt', $bindings)Gotcha — an expression may "accidentally" survive:
prepareColumnName()passes a column through untouched only when it already contains a.. Soselect(['CONCAT("https://example.com/…", col) as x'])emits valid SQL merely because the URL contains a dot — the identical code breaks on a dotless host (http://localhost/…). Never rely on this; route any function/expression throughselectRaw().
A delete producing no WHERE clause now returns empty SQL, and exec() throws
instead of running DELETE FROM table (which previously emptied the table).
// Before
User::delete(); // ⚠️ deleted every row
// After
User::delete(); // throws RuntimeException('SQL query is empty')Why it breaks (intentionally): prevents accidental full-table deletes.
Migration: add an explicit condition — User::where('id', $id)->delete(). To
truly empty a table, use a raw TRUNCATE/DELETE query.
castTo() short-circuits and returns null when the value is null, instead of
running it through the configured cast.
// casts = ['count' => 'int', 'flag' => 'bool']
// Before
$m->count; // null -> (int) null => 0
$m->flag; // null -> false
// After
$m->count; // null (unchanged)
$m->flag; // null (unchanged)Why it breaks: nullable cast columns now surface null rather than the
zero-value (0, false, '', epoch DateTime).
Migration: null-check or coalesce — $m->count ?? 0.
raw() returns the last result set only for SELECT queries. For
non-SELECT statements (INSERT/UPDATE/DELETE/DDL) it returns the underlying
Connection::query() result instead of last_result.
Migration: for write statements, check truthiness / Connection::prop()
rather than expecting a result set.
with() stays on QueryBuilder but gains a richer signature and is also
reachable statically/instance via the model (see §4.4).
// Before
public function with($relation) // string | Closure
// After
public function with($relation, $callback = null) // string | array, optional ClosureEager-loading works in every call style:
User::with('posts')->get();
User::with('posts', fn ($q) => $q->where('published', 1))->get();
User::with(['posts', 'profile'])->get();Why it breaks: the old single-arg Closure form (with(fn ($q) => …)) is
gone — pass the relation name first, the constraint second.
The old no-arg withCount() that appended COUNT(*) as count to the select is
gone. withCount() now lives in Relations, requires a relation name, and
emits a correlated sub-select.
// Before
->withCount()->get(); // COUNT(*) as count
// After
User::withCount('posts')->get(); // adds posts_count sub-selectMigration: for a plain row count use ->count(); for relation counts use
withCount($relation). See §4.2 for the full aggregate family.
Runtime fatal: a leftover no-arg
->withCount()— including one buried in a relation method that is later eager-loaded viawith('rel')(the relation resolver invokes the method to validate it) — now throwsArgumentCountError; the relation-name parameter is required.
// Before
public function addRelation($relation) // string; resolved method, returned query
// After
public function addRelation(array $relation) // merges relation array, returns voidWhy it breaks: callers passing a string, or using the return value, break.
Migration: use with() / the new relation methods instead of calling
addRelation() directly.
The binary() column modifier was deleted.
$table->string('hash')->binary(); // ❌ Call to undefined methodMigration: express the binary/collation requirement another way (e.g. a raw column type) until a replacement is provided.
A null/empty prepared query now throws RuntimeException('SQL query is empty')
instead of Exception('SQL query is null').
RuntimeException extends Exception, so catch (\Exception $e) still works.
Code matching on the message string, or catching the base Exception type
specifically for this case, should be reviewed.
A model with public $soft_deletes = true; previously returned all rows —
trashed and non-trashed alike — unless it also opted in with
$soft_delete_scope = true. Reads now inject deleted_at IS NULL by default,
so trashed rows no longer appear.
class Post extends Model
{
public $soft_deletes = true; // reads now hide trashed rows automatically
}
Post::all(); // excludes trashed rows
Post::withTrashed()->get(); // include trashed
Post::onlyTrashed()->get(); // only trashedMigration: to keep the old unfiltered behavior, declare the opt-out flag:
public $soft_delete_scope = false; // reads return every row, including trashedrefresh() reloads a row by its own primary key with withTrashed(), so
re-hydrating a trashed model still reports exists() === true.
composer.json require.php changed from ^7.4 || ^8.0 to >=8.2. The package
no longer installs on PHP 7.4, 8.0, or 8.1.
Why it breaks: a plugin whose own require.php still allows < 8.2 can no
longer resolve this package version via Composer.
Migration: raise the consuming plugin's minimum PHP to 8.2 (and its runtime)
before upgrading. Stay on the previous release if you must support older PHP.
The test suite runs on PHPUnit 11 (composer test); the compatibility gate now
targets 8.2-.
Not signature breaks, but observable runtime differences.
count()now counts the primary key (COUNT(pk)) via the newaggregate()helper, instead ofCOUNT(*). Rows with a NULL primary key are no longer counted. Returnsint; a real0is preserved (not coerced tonull).min()/max()run on a clone of the builder, so calling them no longer mutates the current query'sselect/selectRawstate.- Bulk insert id collection fixed — inserted ids are now derived from
rows_affectedstarting atlastInsertId()(previously off by one / partial). Bulk inserts now return all created models. - NULL columns persist as SQL
NULLin insert/update/upsert, instead of being coerced to an empty string''. save()decides success from the query result, not the affected/returned id. A successful UPDATE that changes no rows (an idempotent re-save where no value differs) and a successful INSERT into a table with a manual/composite key (no auto-increment id) both now return the Model instead offalse—exec()returnsfalseonly on a real DB error/cancel. The auto-increment id is still assigned to the primary key when present. Genuine errors still returnfalse.paginate()defaultsselectto*when empty and computes the count before applying limit/offset; pagination with explicitselectcolumns and count no longer conflict.Modelnow fires lifecycle events during read/write — see §4.3. A model with custom logic in overridden write paths may observe new event callbacks.- Internal layout: the three traits moved to
BitApps\WPDatabase\Concernsand SELECT compilation extracted toBitApps\WPDatabase\Query\Grammar. Public classes are unchanged; only code importing those internals directly is affected. upsert()now managesupdated_atfor$timestampsmodels: it inserts bothcreated_atandupdated_at, and on a duplicate key bumpsupdated_at = VALUES(updated_at)while preservingcreated_at— replacing the prior behavior that leftupdated_atuntouched and mappedupdated_at = VALUES(created_at). The generated SQL changes for upsert on timestamped models.belongsToMany()positional args 2 and 3 changed meaning — from(foreignKey, localKey)to(pivotTable, foreignPivotKey)(see §4.6).belongsToMany($model)with no extra args is byte-identical to before (legacy null-pivot path). Any call passing positional arg 2+ now takes the pivot path, treating arg 2 as the pivot table name. Zero known callers across consumers; flagged for completeness.- Bulk
insert()andupsert()now JSON-encode array/object values viawp_json_encode, matchingsave()/update(). Previously a multi-rowinsert([[...]])orupsert()bound an array as the literal"Array"and an object threw — both repaired. Scalar values are unchanged. - Invalid-SQL / crash repairs (output changes only for previously-broken
input; working inputs are byte-identical):
whereIn('c', [])/where('c', [])now emit0 = 1(was invalidIN ());where('c', '=', null)and other operator+null forms emitIS [NOT] NULL(was a truncated, value-less clause); awhere/whereInvalue that is an object or a nested array iswp_json_encoded (was a fatal / binding mismatch);aggregate(fn, '*')emitsCOUNT(*)(was invalidCOUNT(\t`.*)); an emptysave()(no changed columns) skips the query and returns the model (was a malformedUPDATE … SET);insert([]), empty bulk rows, andupsert($v, [])no longer emit malformed SQL; a singleinsert()` row whose first value is an array no longer crashes. take()/skip()cast their argument toint— blocksLIMIT/OFFSETinjection; numeric input is byte-identical.orderBy()/groupBy()validate the column as a plain identifier (^[A-Za-z0-9_.]+$) and throwRuntimeExceptionotherwise — blocksORDER BY/GROUP BYinjection. Plain/qualified identifiers emit byte-identical SQL; pass raw expressions throughorderByRaw()`.- Cast aliases
integer/float/double/json/datetimenow work (map onto the existing casters) — they were previously silent no-ops returning the raw value. - Bulk
insert()aligns ragged rows by column — a row whose keys differ from the first row no longer silently shifts values into the wrong columns (uniform rows unchanged). - Eager-loaded empty relations resolve to
[]without a re-query. A parent with no related rows previously storednull, so accessing the relation fired a fresh lazy query (an N+1) that returned an emptyCollection. It now holds[]directly — no extra query. The value is empty either way (count()0, falsy); the type for an empty eager relation is now a plain array, matching a non-empty eager relation. join()table prefix corrected for custom-$prefixmodels. Join (and pivot) tables now carry the model's full table prefix via the newModel::getTablePrefix()(wp_plus the plugin prefix), matching the model's own table. Default-$prefixmodels are unchanged (getTablePrefix()equalsgetPrefix()there); custom-$prefixmodels that previously lostwp_on joins now match their own table.
Returned from multi-row reads. Methods: make(), all(), map(), filter(),
reduce(), pluck(), first(), last(), reverse(), toArray(), plus
Countable / ArrayAccess / IteratorAggregate / JsonSerializable.
Added to the Relations trait (all usable statically via the model):
User::withCount('posts')->get(); // posts_count
User::withMin('posts.score')->get();
User::withMax('posts.score')->get();
User::withAvg('posts.score')->get();
User::withSum('posts.score')->get();
User::withExists('posts')->get(); // bool-cast posts_exists
User::whereHas('posts')->get(); // filter by existence
User::whereHas('posts', fn ($q) => $q->where('published', 1))->get();
User::withWhereHas('posts', $cb)->get(); // filter + eager-loadRelation names support an as alias: withCount('posts as total_posts').
Subscribable events — register in a model's boot() via the named static
registrars: retrieved, saving, saved, updating, updated, deleting,
deleted. Boot hooks — override the protected static methods booting()
(runs before boot()) and booted() (runs after) instead of using a registrar.
protected static function boot()
{
static::saving(fn ($model) => /* ... */);
static::deleted(MyDeletedHandler::class); // class with handle()
}A pre-event (saving/updating/deleting) returning false aborts the query.
creating/createdevents fire internally but have no public registrar — usesaving/savedinstead, which fire on both insert and update.
⚠️ The trait reserves the method namesboot,booting,booted,fireEvent,fireCustomEvent,registerEventand the properties$events,$registeredEvents,$bootedon subclasses. A child model already defining any of these will collide.
with(), withCount(), withMin/Max/Avg/Sum/Exists(), whereHas(),
withWhereHas() are defined on QueryBuilder and forwarded from the model via
__call/__callStatic. All three call styles work and produce identical SQL:
User::with('posts')->get(); // static
User::where('id', '>', 0)->with('posts')->get(); // chained on the builder
(new User())->with('posts')->get(); // instanceWhy on the builder, not the model: PHP only routes through
__callStaticfor methods that are not declared on the class. While these methods lived on the model'sRelationstrait,Model::with(...)threwError: Non-static method ...::with() cannot be called statically. Moving them toQueryBuilder(wherewhere()/select()already live) restores static access. Relation definitions (hasMany,belongsTo, …) remain on the model.
Return type: these now return a QueryBuilder (previously the trait
versions returned the Model). Both are chainable to ->get(), and forward
unknown methods to each other, so existing chains keep working.
IDE navigation: Model carries @mixin \BitApps\WPDatabase\QueryBuilder
(instead of a hand-maintained @method static list), so Ctrl/Cmd+Click on a
forwarded call jumps to the real QueryBuilder method. For a fully
type-navigable entry that works identically across IDEs, start the chain with
the new real static Model::query():
User::query()->with('posts')->where('active', 1)->get();- QueryBuilder:
addSelect(),selectRaw(),orderByRaw(),upsert(),when(),toSql(),clone(),aggregate(),prepareColumnName(),withCast()(chainable), and__call()forwarding to the bound model. - Model:
query()(canonical static builder entry),toArray(),getPrefix(),getTablePrefix()(full table prefix —wp_+ plugin prefix — for join/pivot table names),withCast(array $casts),bool/booleancast. - Connection:
startTransaction(),commit(),rollback(). - Model (soft-delete):
forceDelete()(realDELETE, bypasses the soft rewrite) andrestore()(clearsdeleted_at). Both throw on a non-soft-delete model. - Blueprint:
unique($column = null)— optional arg (backward compatible) for composite/explicit unique indexes. - QueryBuilder:
static $TIME_ZONEto set the timezone statically;$select/$selectRaware nowpublic(wereprotected).
belongsToMany now resolves a true many-to-many relation through a pivot
(junction) table for reads — eager with() and lazy $model->relation:
public function roles()
{
return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id');
}
Member::with('roles')->get(); // eager
$member->roles; // lazyFull signature
belongsToMany($model, $pivotTable = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null).
Omitted keys derive from the package FK convention (members_id, roles_id).
withPivot([...]) selects extra pivot columns, exposed flat on each related
model as pivot_<col> attributes (the parent link is always exposed as
pivot_<foreignPivotKey>). When $pivotTable is null the method keeps its
legacy behaviour (resolves like hasMany), so existing belongsToMany($model)
calls are unaffected.
Out of scope (read-only): attach/detach/sync, and
withCount/whereHas/aggregates over a pivot relation (these throw
RuntimeException). See the usage doc's Limitations for the full list.
| Deprecated | Replacement |
|---|---|
QueryBuilder::startTransaction() |
Connection::startTransaction() |
QueryBuilder::commit() |
Connection::commit() |
QueryBuilder::rollback() |
Connection::rollback() |
Still functional, but migrate — they may be removed in a future release.
- Replace
is_array()/array_*on query results withCollectionmethods (§2.1). - Remove method calls chained after
update(); setwhere()before it (§2.2). - Update code reading
save()'s return asbool/int; it returns the model now (§2.3). - Move raw expressions out of
select()intoselectRaw()(§2.4). - Ensure every
delete()has aWHERE; replace intentional table wipes with raw SQL (§2.5). - Null-check / coalesce values from nullable cast columns (§2.6).
- Review non-SELECT
raw()return handling (§2.7). - Replace the old single-arg
with(Closure)form and the oldwithCount()no-arg call (§2.8, §2.9). - Replace direct
addRelation(string)calls withwith()(§2.10). - Remove
binary()column modifiers (§2.11). - Review
catch/message matching around the SQL-empty exception (§2.12). - Check subclasses for name collisions with the
HasEventstrait (§4.3). - Migrate transaction calls from
QueryBuildertoConnection(§5).