Skip to content

Models

Version 3 runs on Eloquent. Models live under PageBlocks\App\Models\ and are resolved by short name through the model() helper:

php
model('PbResource')->published()->limit(10)->get();

Most of it is ordinary Eloquent. One thing is not, and it is the pattern the whole component rests on.

Fields that are not columns

A constructor lets a manager add a field to a block or a table without a migration. That field has no column — it lives inside a JSON column. AbstractDataModel makes that invisible:

php
$block->subtitle = 'Hello';   // there is no `subtitle` column
$block->save();
echo $block->subtitle;        // Hello

Two hooks do the work:

HookWhat happens
retrievedKeys of the JSON column that are not real columns are unpacked into extra attributes
savingExtra attributes are folded back into the JSON column

Reading goes through __get, so an extra attribute is indistinguishable from a column in templates and in code. You do not write accessors for constructor fields.

Which JSON column

Depends on the model family:

FamilyBase classJSON column
Data — blocks, table rows, resourcesAbstractDataModeldata
Constructor — fields, columnsConstructorModelproperties
Constructor — blocks, tablesConstructorModelpermissions

Never assign the JSON column wholesale

$field->properties = ['foo' => 1] throws away everything the saving hook was going to put back, and the field loses its settings. Set the individual keys instead — $field->foo = 1.

Extras are filtered by the constructor — unless there is none

On save, extra attributes are matched against the constructor's field names, and anything unknown is dropped. That is what keeps data clean.

But a model with no constructor keeps all of its extras:

php
$fields = $model->fields()->get()->toArray();
$data = $fields ? array_filter($extra, /* by field name */) : $extra;

This branch exists because of a real bug: filtering unconditionally wiped data on every save of a hand-written model — pb_companies lost its email, phone and address on each update, because it has no constructor to validate against.

Fillable follows the actual table

Generated data tables carry only a subset of the base columns. getFillable() intersects the base list with the columns the table really has, read once from the schema and cached per table.

Without it an incoming parent_type or context_key from a form would reach the UPDATE and fail with Unknown column.

Soft deletes everywhere

AbstractDataModel and ConstructorModel both extend SoftModel, which uses Eloquent's SoftDeletes. Deleting sets deleted_at; queries exclude such rows automatically. That is what the basket restores from.

Force-deleting a constructor also removes its fields, tabs and columns — they are tied by model_type + model_id, not by a foreign key, so nothing cascades on the database side.

Templates see extra attributes too — because of __isset

Fenom compiles {$obj->prop} into an isset() check. A plain __get would never be reached and every constructor field would render empty. AbstractDataModel therefore implements __isset to report extra attributes as set.

If you write a model of your own with magic properties, remember this — it is the kind of bug that looks like "the value is there in PHP but empty in the template".

Morph classes

Rows are attached to their owner by a pair — model_type + model_id. The two families spell model_type differently:

Familymodel_type
AbstractDataModelfull class name, e.g. PageBlocks\App\Models\PbBlockData
ConstructorModelshort literal, e.g. pbBlock, pbTable

Both spellings exist in live data for historical reasons, so code that matches on model_type usually has to accept more than one. Do not "simplify" BaseModel::getMorphClass() — it holds the constructor together.

© PageBlocks 2019-present