Divergence uses a typical ActiveRecord pattern for its models.
| Type | Field | Description |
|---|---|---|
integer |
ID |
The primary key. |
enum |
Class |
Fully qualified PHP namespaced class. |
timestamp |
Created |
Time when the object is created in the database. |
integer |
CreatorID |
Reserved for use with your authentication system. |
Divergence\Models\Model automatically also pulls in Divergence\Models\Getters, so you do not have to do it yourself.
| Trait | Description |
|---|---|
Divergence\Models\Getters |
Suite of methods to pull records from the database. |
Divergence\Models\Relations |
Lets you build relationships between models. |
Divergence\Models\Versioning |
Automatically tracks history of models. |
Classes that use ActiveRecord may optionally use traits to enable relationship features and versioning features respectively.
When using array mapping, ActiveRecord merges public static $fields and public static $relationships at runtime, giving priority to the child.
A child class may choose to unset a relationship or field simply by setting the config to null. A child class may also use a different type for the same database field name.
Overrides must use the key for the field configuration.
If you are using the older array-mapping style, you will usually define the common class configurables shown in this section. With attribute-based mapping, the framework can infer more of the model structure directly from your class.
public static $rootClass = __CLASS__;
public static $defaultClass = __CLASS__;
public static $subClasses = [__CLASS__];In the event that you have subclasses you can define them here. By default just use the above configuration. You'll want to override $rootClass and $defaultClass if necessary for yourself.
public static $tableName = 'table';
public static $singularNoun = 'table';
public static $pluralNoun = 'tables';Table name is for the database table.
Singular noun and plural noun are mostly used by RecordsRequestHandler to load the right template in HTML mode, so think of those as resource and template names.
Divergence supports field mapping using PHP attributes as well as the older static-array style.
For example:
#[Column(type: "integer", primary:true, autoincrement:true, unsigned:true)]
private $ID;For field mapping by array, ActiveRecord also merges each static::$fields for every class from child to parent. Any defined $fields are usable as $Model->$fieldName.
public static $fields = [
'Tag',
'Slug',
];By default, if you just have a string that will be treated as the name of the field for the model. In practice that means a PHP string and a short text column at the schema layer if the framework has to auto-create the table.
protected $title;If you try to use a model and the database responds with a table-not-found error, the framework can attempt to build the SQL, create the table, and rerun the original operation.
You can disable this behavior by setting:
public static $autoCreateTables = false;Note the current property name is autoCreateTables.
Here's an example of a minimum model:
<?php
namespace yourApp\Models;
use Divergence\Models\Mapping\Column;
class Tag extends \Divergence\Models\Model
{
// support subclassing
public static $rootClass = __CLASS__;
public static $defaultClass = __CLASS__;
public static $subClasses = [__CLASS__];
// ActiveRecord configuration
public static $tableName = 'tags';
public static $singularNoun = 'tag';
public static $pluralNoun = 'tags';
#[Column(type: 'string', required: true, notnull: true)]
private $Tag;
#[Column(type: 'string', blankisnull: true, notnull: false)]
private $Slug;
}We get these fields from \Divergence\Models\Model as defaults:
#[Column(type: "integer", primary:true, autoincrement:true, unsigned:true)]
private $ID;
#[Column(type: "enum", notnull:true, values:[])]
private $Class;
#[Column(type: "timestamp", default:'CURRENT_TIMESTAMP')]
private $Created;
#[Column(type: "integer", notnull:false)]
private $CreatorID;Divergence ActiveRecord is simple and makes use of normal PHP object patterns whenever possible.
Example without defaults:
$Tag = new Tag();
echo $Tag->Name; // prints null
$Tag->Name = 'Divergence';
echo $Tag->Name; // prints DivergenceExample with record instantiation via constructor:
$Tag = new Tag([
'Name' => 'Divergence',
]);
echo $Tag->Name; // prints DivergenceExample with record instantiation via create():
$Tag = Tag::create([
'Name' => 'Divergence',
]);
echo $Tag->Name; // prints DivergenceExample with record instantiation via create() and save directly to the database:
$Tag = Tag::create([
'Name' => 'Divergence',
], true);
echo $Tag->Name; // prints Divergence
echo $Tag->ID; // prints ID assigned by the database auto incrementAnother save example:
$Tag = new Tag();
$Tag->Name = 'Divergence';
echo $Tag->ID; // prints null
$Tag->save();
echo $Tag->ID; // prints ID assigned by the database auto increment$Tag = Tag::getByID(1);
echo $Tag->ID; // prints 1
$Tag->Name = 'Divergence';
$Tag->save();Get by field:
$Tag = Tag::getByField('ID', 1);
echo $Tag->ID; // prints 1
$Tag->Name = 'Divergence';
$Tag->save();$Tag = Tag::getByID(1);
echo $Tag->ID; // prints 1
$Tag->destroy(); // record still exists in the variableor statically:
Tag::delete(1); // returns true if affected rows > 0The public model API still looks like classic Divergence:
getByIDgetByFieldgetByHandlegetByWheregetByQuerygetAllgetAllByFieldgetAllByWheregetAllByQuerygetUniqueHandle
But the current implementation is more modular than older docs implied.
Today:
Divergence\Models\Gettersis a thin forwarding trait- static getter calls route into
Divergence\Models\Factory Factoryregisters dedicated getter classes such asGetByID,GetByField,GetAllByWhere, andGetUniqueHandleFactoryalso coordinates model metadata, instantiation, connection resolution, and storage caching
That means the external API is stable, but the query/runtime plumbing behind it has been decomposed into smaller pieces.
Your model must be defined with use Versioning in its definition.
<?php
namespace Divergence\Tests\MockSite\Models;
use Divergence\Models\Model;
use Divergence\Models\Versioning;
class Tag extends Model
{
use Versioning;
}You must provide these settings to use versioning.
public static $historyTable = 'test_history';
public static $createRevisionOnDestroy = true;
public static $createRevisionOnSave = true;If you did not create your tables yet, a versioned model can have its history table automatically created by the same missing-table path the main model uses.
#[Column(type: "integer", unsigned:true, notnull:false)]
private $RevisionID;| Method | Purpose |
|---|---|
getRevisionsByID |
Returns an array of versions of a model by ID and $options. |
getRevisions |
Returns an array of versions of a model by $options. |
| Relationship | Type | Purpose |
|---|---|---|
History |
history |
Pulls old versions of this model |
'History' => [
'type' => 'history',
'order' => ['RevisionID' => 'DESC'],
],$Model = Tag::getByID(1);
$Model->History; // array of revisions where ID == 1 ordered by RevisionIDYour model must be defined with use Relations in its definition.
<?php
namespace Divergence\Tests\MockSite\Models;
use Divergence\Models\Model;
use Divergence\Models\Relations;
class Tag extends Model
{
use Relations;
}For array mapping you must provide relationship configurations in the static variable $relationships.
public static $relationships = [
/*
'RelationshipName' => [
... config ...
]
*/
];Otherwise you can define it with attributes like so:
#[Relation(
type:'one-one',
class:Tag::class,
local: 'ThreadID',
foreign: 'ID',
)]
protected $Tag;- Relationships should not have the same name.
- The second will override the first.
- Children classes can override parent classes by setting the class configuration to
null. - Relationship configs will be stacked with priority given to the child class.
- Relationships are callable by their key name from
$this->$relationshipKey, but model field names take priority.
Internally, the relationship resolver supports:
one-oneone-manymany-manycontext-parentcontext-childrenhistory
Use one-one when the current record points to exactly one related record.
#[Relation(
type:'one-one',
class:User::class,
local: 'AuthorID',
foreign: 'ID',
)]
protected $Author;Defaults:
- omitting
typebehaves likeone-one - omitting
localdefaults it to<RelationshipName>ID - omitting
foreigndefaults it toID
Use one-many when the current record owns a collection of related records.
#[Relation(
type:'one-many',
class:Thread::class,
local: 'ID',
foreign: 'CategoryID'
)]
protected $Threads;You can also add conditions and order.
Use many-many when two models are connected through a join model or join table.
#[Relation(
type:'many-many',
class:Tag::class,
linkClass:PostTags::class,
linkLocal: 'PostID',
linkForeign: 'TagID',
local: 'ID',
foreign: 'ID'
)]
protected $Tags;Use context-parent when a record stores a polymorphic parent reference through ContextClass and ContextID.
#[Relation(
type:'context-parent',
local: 'ContextID',
classField: 'ContextClass'
)]
protected $Context;Use context-children for the inverse of context-parent.
#[Relation(
type:'context-children',
class:Media::class,
local: 'ID',
contextClass: Post::class
)]
protected $Media;Use history with versioned models to expose prior revisions.
'History' => [
'type' => 'history',
'order' => ['RevisionID' => 'DESC'],
],#[Relation(
type:'one-one',
class:Tag::class,
local: 'ThreadID',
foreign: 'ID',
)]
protected $Tag;
#[Relation(
type:'one-one',
class:Post::class,
local: 'PostID',
foreign: 'ID',
)]
protected $Post;Feel free to create multiple relationship configurations with different conditions and orders.
#[Relation(
type:'one-many',
class:Thread::class,
local: 'ID',
foreign: 'CategoryID'
)]
protected $Threads;
#[Relation(
type:'one-many',
class:Thread::class,
local: 'ID',
foreign: 'CategoryID',
conditions: [
'Created > DATE_SUB(NOW(), INTERVAL 1 HOUR)',
],
order: ['Title' => 'ASC']
)]
protected $RecentThreads;| Field Type | Typical Use |
|---|---|
int |
Integer values |
integer |
Integer values |
uint |
Unsigned integer values |
string |
Short text |
clob |
Long text |
float |
Approximate decimal values |
decimal |
Exact fixed-point values |
enum |
Controlled one-of-many values |
boolean |
True/false flags |
password |
Hashed secret material |
timestamp |
Date + time values |
date |
Date-only values |
serialized |
Serialized structured data |
set |
Multi-value controlled sets |
list |
Ordered delimited lists |
binary |
Raw binary blobs such as session IP storage |
This section explains what each field type means in practice, how the framework stores it, and what you should expect when reading and writing values.
Whole-number numeric field.
Also a whole-number field. In practice int and integer are the same family here.
Unsigned integer. Should never be negative.
Short text, usually the right fit for names, titles, slugs, and handles.
Long-form text for bodies, descriptions, and content.
Approximate decimal values. Fine for measurements where small rounding drift is acceptable.
Exact fixed-point decimal values. Use for money or values where precision matters.
Restricts a field to one of a predefined set of values.
True/false flag field.
Intended for hashed secrets rather than plaintext input.
Time values with time-of-day precision.
Calendar dates without time-of-day precision.
Stores structured PHP data serialized into text.
Stores multiple values from a controlled list.
Stores an ordered delimited list of values.
Stores raw binary data. The session model uses this for LastIP.
The mock test app ships a Canary model that exists specifically as an example of field mapping coverage.
<?php
namespace App\Models;
use Divergence\Models\Versioning;
use Divergence\Models\Mapping\Column;
class Canary extends \Divergence\Models\Model
{
use Versioning;
public static $tableName = 'canaries';
public static $historyTable = 'canaries_history';
public static $createRevisionOnDestroy = true;
public static $createRevisionOnSave = true;
#[Column(type: 'int', default:7)]
protected $ContextID;
#[Column(type: 'enum', values: [Tag::class], default: Tag::class)]
protected $ContextClass;
#[Column(type: 'clob', notnull:true)]
protected $DNA;
#[Column(type: 'string', required: true, notnull:true)]
protected $Name;
#[Column(type: 'string', blankisnull: true, notnull:false)]
protected $Handle;
#[Column(type: 'boolean', default: true)]
protected $isAlive;
#[Column(type: 'password')]
protected $DNAHash;
#[Column(type: 'timestamp', notnull: false)]
protected $StatusCheckedLast;
#[Column(type: 'serialized')]
protected $SerializedData;
#[Column(type: 'set', values: ["red", "blue", "green"])]
protected $Colors;
#[Column(type: 'list', delimiter: '|')]
protected $EyeColors;
#[Column(type: 'float')]
protected $Height;
#[Column(type: 'int', notnull: false)]
protected $LongestFlightTime;
#[Column(type: 'uint')]
protected $HighestRecordedAltitude;
#[Column(type: 'integer', notnull: true)]
protected $ObservationCount;
#[Column(type: 'date')]
protected $DateOfBirth;
#[Column(type: 'decimal', notnull: false, precision: 5, scale: 2)]
protected $Weight;
}Validation is available to you through a static config in your model. The config is an array of validator configs. Whenever possible Divergence validators use built in PHP validation helpers.
Validators are evaluated in the order in which they appear. save() calls validate() before persistence.
if (!$this->validate($deep)) {
throw new Exception('Cannot save invalid record');
}Set validators in your model:
public static $validators = [
[
'field' => 'Name',
'required' => true,
'errorMessage' => 'Name is required.',
],
];[
'field' => 'Name',
'minlength' => 2,
'required' => true,
'errorMessage' => 'Name is required.',
][
'field' => 'Name',
'maxlength' => 5,
'required' => true,
'errorMessage' => 'Name is too big. Max 5 characters.',
][
'field' => 'ID',
'required' => true,
'validator' => 'number',
'max' => PHP_INT_MAX,
'min' => 1,
'errorMessage' => 'ID must be between 1 and PHP_INT_MAX ('.PHP_INT_MAX.')',
][
'field' => 'Float',
'required' => true,
'validator' => 'number',
'max' => 0.759,
'min' => 0.128,
'errorMessage' => 'Float must be between 0.128 and 0.759',
]Email validation:
[
'field' => 'Email',
'required' => true,
'validator' => 'email',
]Custom validation:
[
'field' => 'Email',
'required' => true,
'validator' => [
Validate::class,
'email',
],
]Every ActiveRecord save will call $class::$beforeSave and $class::$afterSave if they are set to PHP callables.
If you set ActiveRecord::$beforeSave you can hook into every save for every model on the entire site.
Both $beforeSave and $afterSave get passed an instance of the object being saved as the only parameter.
Events are not overridden by child classes. An event will fire for every parent of a child class.
if (is_callable($class::$beforeSave)) {
if (!empty($class::$beforeSave)) {
if (!in_array($class::$beforeSave, static::$_classBeforeSave)) {
static::$_classBeforeSave[] = $class::$beforeSave;
}
}
}if (is_callable($class::$afterSave)) {
if (!empty($class::$afterSave)) {
if (!in_array($class::$afterSave, static::$_classAfterSave)) {
static::$_classAfterSave[] = $class::$afterSave;
}
}
}Also note that the current save flow routes through handler classes:
beforeSaveHandlerafterSaveHandlersaveHandlerdestroyHandlerdeleteHandler
So the framework's event and persistence lifecycle is more modular than older docs implied, even though the conceptual hooks are the same.
Here are a few examples of how to use ActiveRecord but still do custom things with your model.
This is a case where you'll want to extend getValue($field).
public function getValue($field)
{
switch ($field) {
case 'HeightCM':
return static::inchesToCM($this->Height);
case 'calculateTax':
return $this->calculateTaxTotal();
default:
return parent::getValue($field);
}
}
public static function inchesToCM($value)
{
return $value * 2.54;
}
public function calculateTaxTotal()
{
$taxTotal = 0;
if ($state = $this->getStateTaxRate()) {
$taxTotal += ($state * $this->Price);
}
if ($local = $this->getLocalTaxRate()) {
$taxTotal += ($local * $this->Price);
}
return $taxTotal;
}In this example we let the table names come right from the class. We also make sure our query only gives us the one model we actually want to instantiate from the data.
if (App::$App->is_loggedin()) {
$where = "`Status` IN ('Draft','Published')";
} else {
$where = "`Status` IN ('Published')";
}
$BlogPosts = BlogPost::getAllByQuery(
"SELECT `bp`.* FROM `%s` `bp`
INNER JOIN %s as `t` ON `t`.`BlogPostID`=`bp`.`ID`
WHERE `t`.`TagID`='%s' AND $where",
[
BlogPost::$tableName,
PostTags::$tableName,
$Tag->ID,
]
);public function getValue($field)
{
switch ($field) {
case 'getAllByTag':
return static::getAllByTag($_REQUEST['tag']);
default:
return parent::getValue($field);
}
}
public static function getAllByTag($slug)
{
if ($Tag = Tag::getByField('Slug', $slug)) {
if (App::$App->is_loggedin()) {
$where = "`Status` IN ('Draft','Published')";
} else {
$where = "`Status` IN ('Published')";
}
return static::getAllByQuery(
"SELECT `bp`.* FROM `%s` `bp`
INNER JOIN %s as `t` ON `t`.`BlogPostID`=`bp`.`ID`
WHERE `t`.`TagID`='%s' AND $where",
[
static::$tableName,
PostTags::$tableName,
$Tag->ID,
]
);
}
}