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.

Every data model is versioned ​

AbstractDataModel mixes in HasVersions, so every create and update of a data model writes a snapshot into pb_versions. You do not opt in per model, and a failed version write is logged rather than allowed to break the save.

The switch is the pageblocks_versioning system setting — true in PRO, false in the free edition. Note that it is a default value, not a missing table: the table is there in both editions, so buying PRO does not run into absent schema. See Versions.

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".

Autocomplete: the IDE cannot guess a field from the database ​

$row->price works, but your IDE has never heard of price — the field is declared in the constructor, not in code, and every project has its own. So the annotations are generated rather than written:

bash
php core/components/pageblocks/bin/stubs.php              # stubs for models with no file yet
php core/components/pageblocks/bin/stubs.php PbArticle    # the @property block to paste

Run it from the site root, and again after changing fields in the constructor.

  • A model you wrote keeps its annotations in its own file — that is where an IDE looks first, and a second declaration of the same class elsewhere would make it pick one at random. For these the script prints the block; paste it into the model's docblock, next to the annotations it already has.
  • A constructor table with no model file yet gets a stub in core/App/_ide_helper.php. That file is never loaded and never autoloaded — it exists for the IDE only, and can be deleted and regenerated at will.

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