A library for a simple model structure.
- Installation
- Notes on examples
- Configuration
- Creating a model
- Interacting with a model
- Attribute validation
- Data transfer objects
- Classes of note
- Contracts of note
It's recommended that you install Schema as a project dependency via Composer:
composer require stellarwp/modelsWe actually recommend that this library gets included in your project using Strauss.
Luckily, adding Strauss to your
composer.jsonis only slightly more complicated than adding a typical dependency, so checkout our strauss docs.
Since the recommendation is to use Strauss to prefix this library's namespaces, all examples will be using the Boomshakalaka namespace prefix.
This library requires some configuration before its classes can be used. The configuration is done via the Config class.
useBoomshakalaka\StellarWP\Models\Config;
add_action( 'plugins_loaded', function() {
Config::setHookPrefix( 'boom-shakalaka' );
} );Models are classes that hold data and provide some helper methods for interacting with that data.
This is an example of a model that just holds properties. Properties can be defined in one or both of the following ways:
namespaceBoomshakalaka\Whatever;
useBoomshakalaka\StellarWP\Models\Model;
class Breakfast_Model extends Model {
/** * @inheritDoc */protectedstatic$properties = [
'id' => 'int',
'name' => ['string', 'Default Name'], // With default value'price' => 'float',
'num_eggs' => 'int',
'has_bacon' => 'bool',
];
}namespaceBoomshakalaka\Whatever;
useBoomshakalaka\StellarWP\Models\Model;
useBoomshakalaka\StellarWP\Models\ModelPropertyDefinition;
class Breakfast_Model extends Model {
/** * @inheritDoc */protectedstaticfunctionproperties(): array {
return [
'id' => ModelPropertyDefinition::create()
->type('int')
->required()
'name' => ModelPropertyDefinition::create()
->type('string')
->default('Default Name')
->nullable(),
'price' => ModelPropertyDefinition::create()
->type('float')
->requiredOnSave(),
];
}
}type(string ...$types)- Set one or more types (int, string, bool, float, array, or class names)default($value)- Set a default value (can be a closure)nullable()- Allow null valuesrequired()- Property must be provided during constructionrequiredOnSave()- Property must be set before savingreadonly()- Property can only be set during construction, cannot be modified afterwardcastWith(callable $callback)- Custom casting function for the property value
This is a model that includes persistence operations (create, find, save, delete). Ideally, the actual persistence operations should be deferred to and handled by
a repository class, but the model should provide a simple interface for interacting with the repository. We get a persistable
model by implementing the Contracts\ModelPersistable contract.
namespaceBoomshakalaka\Whatever;
useBoomshakalaka\StellarWP\Models\Contracts;
useBoomshakalaka\StellarWP\Models\Model;
useBoomshakalaka\StellarWP\Models\ModelQueryBuilder;
class Breakfast_Model extends Model implementsContracts\ModelPersistable {
/** * @inheritDoc */protectedstatic$properties = [
'id' => 'int',
'name' => 'string',
'price' => 'float',
'num_eggs' => 'int',
'has_bacon' => 'bool',
];
/** * @inheritDoc */publicstaticfunctioncreate( array$attributes ) : Model {
$obj = newstatic( $attributes );
return App::get( Repository::class )->insert( $obj );
}
/** * @inheritDoc */publicstaticfunctionfind( $id ) : Model {
return App::get( Repository::class )->get_by_id( $id );
}
/** * @inheritDoc */publicfunctionsave() : Model {
return App::get( Repository::class )->update( $this );
}
/** * @inheritDoc */publicfunctiondelete() : bool {
return App::get( Repository::class )->delete( $this );
}
/** * @inheritDoc */publicstaticfunctionquery() : ModelQueryBuilder {
return App::get( Repository::class )->prepareQuery();
}
}Models track changes to their properties and provide methods to manage those changes:
$breakfast = newBreakfast_Model([
'name' => 'Original Name',
'price' => 5.99,
]);
// Check if a property is dirty (changed)$breakfast->setAttribute('name', 'New Name');
if ($breakfast->isDirty('name')) {
echo'Name has changed!';
}
// Get all dirty values$dirtyValues = $breakfast->getDirty(); // ['name' => 'New Name']// Commit changes (makes current values the "original")$breakfast->commitChanges();
// or use the alias:$breakfast->syncOriginal();
// Revert a specific property change$breakfast->setAttribute('price', 7.99);
$breakfast->revertChange('price'); // price is back to 5.99// Revert all changes$breakfast->setAttribute('name', 'Another Name');
$breakfast->setAttribute('price', 8.99);
$breakfast->revertChanges(); // All properties back to original// Get original value$originalName = $breakfast->getOriginal('name');
$allOriginal = $breakfast->getOriginal(); // Get all original valuesThe isSet() method checks if a property has been set. This is different from PHP's isset() because it considers null values and default values as "set":
$breakfast = newBreakfast_Model();
// Properties with defaults are considered setif ($breakfast->isSet('name')) { // true if 'name' has a default valueecho'Name is set';
}
// Properties without defaults are not set until assignedif (!$breakfast->isSet('price')) { // false - no default and not assignedecho'Price is not set';
}
// Setting a property to null still counts as set$breakfast->setAttribute('price', null);
if ($breakfast->isSet('price')) { // true - explicitly set to nullecho'Price is set (even though it\'s null)';
}
// PHP's isset() behaves differently with nullif (!isset($breakfast->price)) { // false - isset() returns false for nullecho'PHP isset() returns false for null values';
}Key differences from PHP's isset():
isSet()returnstruefor properties with default valuesisSet()returnstruefor properties explicitly set tonullisSet()returnsfalseonly for properties that have no default and haven't been assigned
Models can be created from database query results using the fromData() method:
// From an object or array$data = DB::get_row("SELECT * FROM breakfasts WHERE id = 1");
$breakfast = Breakfast_Model::fromData($data);
// With different build modes$breakfast = Breakfast_Model::fromData($data, Breakfast_Model::BUILD_MODE_STRICT);
$breakfast = Breakfast_Model::fromData($data, Breakfast_Model::BUILD_MODE_IGNORE_MISSING);
$breakfast = Breakfast_Model::fromData($data, Breakfast_Model::BUILD_MODE_IGNORE_EXTRA);Build modes:
BUILD_MODE_STRICT: Throws exceptions for missing or extra propertiesBUILD_MODE_IGNORE_MISSING: Ignores properties missing from the dataBUILD_MODE_IGNORE_EXTRA: Ignores extra properties in the data (default)
Properties marked as readonly() can only be set during construction and cannot be modified afterward:
useBoomshakalaka\StellarWP\Models\Model;
useBoomshakalaka\StellarWP\Models\ModelPropertyDefinition;
class User_Model extends Model {
protectedstaticfunctionproperties(): array {
return [
'id' => ModelPropertyDefinition::create()
->type('int')
->readonly(), // Can only be set during construction'email' => ModelPropertyDefinition::create()
->type('string'),
];
}
}
// Set readonly property during construction$user = newUser_Model(['id' => 1, 'email' => 'user@example.com']);
// This works fine$user->setAttribute('email', 'newemail@example.com');
// This throws ReadOnlyPropertyException$user->setAttribute('id', 2); // Error: Cannot modify readonly property "id"// This also throws ReadOnlyPropertyException
unset($user->id); // Error: Cannot unset readonly property "id"Readonly properties are useful for:
- Primary keys that shouldn't change after creation
- Timestamps that are set once
- Any immutable identifiers or values
Models can perform custom initialization after construction by overriding the afterConstruct() method:
class Breakfast_Model extends Model {
protectedfunctionafterConstruct() {
// Perform custom initializationif ($this->has_bacon && $this->num_eggs > 2) {
$this->setAttribute('name', $this->name . ' (Hearty!)');
}
}
}Models can define relationships to other models, similar to how properties are defined. Relationships support lazy loading and caching.
Relationships can be defined using either shorthand syntax or the fluent ModelRelationshipDefinition API:
namespaceBoomshakalaka\Whatever;
useBoomshakalaka\StellarWP\Models\Model;
useBoomshakalaka\StellarWP\Models\ValueObjects\Relationship;
class Product_Model extends Model {
/** * @inheritDoc */protectedstatic$relationships = [
'category' => Relationship::BELONGS_TO,
'reviews' => Relationship::HAS_MANY,
'tags' => Relationship::MANY_TO_MANY,
];
/** * Define how to load the category relationship. */protectedfunctioncategory() {
return Category_Model::query()->where('id', $this->category_id);
}
/** * Define how to load the reviews relationship. */protectedfunctionreviews() {
return Review_Model::query()->where('product_id', $this->id);
}
/** * Define how to load the tags relationship. */protectedfunctiontags() {
return Tag_Model::query()
->select('tags.*')
->join('product_tags', 'product_tags.tag_id', 'tags.id')
->where('product_tags.product_id', $this->id);
}
}namespaceBoomshakalaka\Whatever;
useBoomshakalaka\StellarWP\Models\Model;
useBoomshakalaka\StellarWP\Models\ModelRelationshipDefinition;
class Product_Model extends Model {
/** * @inheritDoc */protectedstaticfunctionrelationships(): array {
return [
'category' => (newModelRelationshipDefinition('category'))
->belongsTo(),
'reviews' => (newModelRelationshipDefinition('reviews'))
->hasMany(),
'tags' => (newModelRelationshipDefinition('tags'))
->manyToMany()
->disableCaching(), // Don't cache this relationship
];
}
// Define relationship loaders as above...
}Five relationship types are available:
Relationship::HAS_ONE- Model has one related modelRelationship::HAS_MANY- Model has many related modelsRelationship::BELONGS_TO- Model belongs to another modelRelationship::BELONGS_TO_MANY- Model belongs to many related modelsRelationship::MANY_TO_MANY- Many-to-many relationship
Relationships are loaded lazily when accessed as properties:
$product = Product_Model::find(1);
// First access loads from database and caches result$category = $product->category;
// Subsequent accesses use cached value (if caching enabled)$category = $product->category; // No additional query// Access multiple relationship$reviews = $product->reviews; // Returns array of Review_Model instancesBy default, relationships are cached after the first load. You can control caching behavior:
class Product_Model extends Model {
protectedstaticfunctionrelationships(): array {
return [
// Cached (default)'category' => (newModelRelationshipDefinition('category'))
->belongsTo(),
// Not cached - always loads fresh'stock' => (newModelRelationshipDefinition('stock'))
->hasOne()
->disableCaching(),
];
}
}Models provide methods to manage relationship caching:
$product = Product_Model::find(1);
// Manually set a cached relationship value$product->setCachedRelationship('category', $newCategory);
// Clear a specific relationship cache$product->purgeRelationship('category');
$category = $product->category; // Reloads from database// Clear all relationship caches$product->purgeRelationshipCache();Override the fetchRelationship() method to customize how relationships are loaded:
class Product_Model extends Model {
/** * Custom relationship loading logic. */protectedfunctionfetchRelationship(string$key) {
// Add custom logic before loadingif ($key === 'category' && !$this->category_id) {
returnnull;
}
// Default loading behaviorreturnparent::fetchRelationship($key);
}
}Sometimes it would be helpful to validate attributes that are set in the model. To do that, you can create validate_*()
methods that will execute any time an attribute is set.
Here's an example:
namespaceBoomshakalaka\Whatever;
useBoomshakalaka\StellarWP\Models\Model;
class Breakfast_Model extends Model {
/** * @inheritDoc */protectedstatic$properties = [
'id' => 'int',
'name' => 'string',
'price' => 'float',
'num_eggs' => 'int',
'has_bacon' => 'bool',
];
/** * Validate the name. * * @param string $value * * @return bool */publicfunctionvalidate_name( $value ): bool {
if ( ! preg_match( '/eggs/i', $value ) ) {
thrownew \Exception( 'Breakfasts must have "eggs" in the name!' );
}
returntrue;
}
}Data Transfer Objects (DTOs) are classes that help with the translation of database query results (or other sources of data)
into models. DTOs are not required for using this library, but they are recommended. Using these objects helps you be more
deliberate with your query usage and allows your models and repositories well with the ModelQueryBuilder.
Here's an example of a DTO for breakfasts:
namespaceBoomshakalaka\Whatever;
useBoomshakalaka\Whatever\StellarWP\Models\DataTransferObject;
useBoomshakalaka\Whatever\Breakfast_Model;
class Breakfast_DTO extends DataTransferObject {
/** * Breakfast ID. * * @var int */publicint$id;
/** * Breakfast name. * * @var string */publicstring$name;
/** * Breakfast price. * * @var float */publicfloat$price;
/** * Number of eggs in the breakfast. * * @var int */publicint$num_eggs;
/** * Whether or not the breakfast has bacon. * * @var bool */publicbool$has_bacon;
/** * Builds a new DTO from an object. * * @since TBD * * @param object $object The object to build the DTO from. * * @return Breakfast_DTO The DTO instance. */publicstaticfunctionfromObject( $object ): self {
$self = newself();
$self->id = $object->id;
$self->name = $object->name;
$self->price = $object->price;
$self->num_eggs = $object->num_eggs;
$self->has_bacon = (bool) $object->has_bacon;
return$self;
}
/** * Builds a model instance from the DTO. * * @since TBD * * @return Breakfast_Model The model instance. */publicfunctiontoModel(): Breakfast_Model {
$attributes = get_object_vars( $this );
returnnewBreakfast_Model( $attributes );
}
}Repositories are classes that fetch from and interact with the database. Ideally, repositories would be used to
query the database in different ways and return corresponding models. With this library, we provide
Deletable, Insertable, and Updatable contracts that can be used to indicate what operations a repository provides.
You may be wondering why there isn't a Findable or Readable contract (or similar). That's because the fetching needs
of a repository varies with the usecase. However, in the Repository abstract class, there is an abstract prepareQuery()
method. This method should return a ModelQueryBuilder instance that can be used to fetch data from the database.
namespaceBoomshakalaka\Whatever;
useBoomshakalaka\StellarWP\Models\Contracts\Model;
useBoomshakalaka\StellarWP\Models\ModelQueryBuilder;
useBoomshakalaka\StellarWP\Repositories\Repository;
useBoomshakalaka\StellarWP\Repositories\Contracts;
useBoomshakalaka\Whatever\Breakfast_Model;
useBoomshakalaka\Whatever\BreakfastasTable;
class Breakfast_Repository extends Repository implementsContracts\Deletable, Contracts\Insertable, Contracts\Updatable {
/** * {@inheritDoc} */publicfunctiondelete( Model$model ): bool {
return (bool) DB::delete( Table::table_name(), [ 'id' => $model->id ], [ '%d' ] );
}
/** * {@inheritDoc} */publicfunctioninsert( Model$model ): Breakfast_Model {
DB::insert( Table::table_name(), [
'name' => $model->name,
'price' => $model->price,
'num_eggs' => $model->num_eggs,
'has_bacon' => (int) $model->has_bacon,
], [
'%s',
'%s',
'%d',
'%d',
] );
$model->id = DB::last_insert_id();
return$model;
}
/** * {@inheritDoc} */functionprepareQuery(): ModelQueryBuilder {
$builder = newModelQueryBuilder( Breakfast_Model::class );
return$builder->from( Table::table_name( false ) );
}
/** * {@inheritDoc} */publicfunctionupdate( Model$model ): Model {
DB::update( Table::table_name(), [
'name' => $model->name,
'price' => $model->price,
'num_eggs' => $model->num_eggs,
'has_bacon' => (int) $model->has_bacon,
], [ 'id' => $model->id ], [
'%s',
'%s',
'%d',
'%d',
], [ '%d' ] );
return$model;
}
/** * Finds a Breakfast by its ID. * * @since TBD * * @param int $id The ID of the Breakfast to find. * * @return Breakfast_Model|null The Breakfast model instance, or null if not found. */publicfunctionfind_by_id( int$id ): ?Breakfast_Model {
return$this->prepareQuery()->where( 'id', $id )->get();
}
}$breakfast = App::get( Breakfast_Repository::class )->find_by_id( 1 );
// Or, we can fetch via the model, which defers to the repository.$breakfast = Breakfast_Model::find( 1 );$breakfast = newBreakfast_Model( [
'name' => 'Bacon and Eggs',
'price' => 5.99,
'num_eggs' => 2,
'has_bacon' => true,
] );
$breakfast->save();$breakfast = Breakfast_Model::find( 1 );
$breakfast->setAttribute( 'price', 6.99 );
$breakfast->save();$breakfast = Breakfast_Model::find( 1 );
$breakfast->delete();$breakfast = Breakfast_Model::find( 1 );
unset($breakfast->price); // Unsets the price propertyThis is an abstract class to extend for your models.
This class extends the stellarwp/dbQueryBuilder class so that it returns
model instances rather than arrays or stdClass instances.
This is an abstract class to extend for your DTOs.
This is an abstract class to extend for your repositories.
Provides definitions of methods for persistence operations in a model (create, find, save, delete, query).
Provides method signatures for delete methods in a repository.
Provides method signatures for insert methods in a repository.
Provides method signatures for update methods in a repository.