Models
Models are the heart and soul of dealing with persistent data. LENKRAD has a sophisticated ORM automation to deal with this common topic. This means that when we talk about models, we always talk about databases as well.
The Model class
LENKRAD uses one file to declare and interact with models. This includes:
- Database interactions
- Database migrations
- Object relational mapping
A new instance of this model now has the following static methods available:
get(int|string $id)
Retrieves an instance of the model by primary id.
retrieve(array $condition, array $filter = [])
Returns a collection
retrieveOne(array $condition, array $filter = [])
Retrieves an instance of the model based on condition and/or filter
retrieveOneOrCreate(array $condition)
If a model with the condition exists, it's retrieved, else it's created.
paginate(int $page = 1, int $pageSize = 30)
Returns a Pagination instance
declare()
Returns the structure in a standardized format (mainly to be used for migrations & transactions)
And the following initiated methods:
store()
Executes database transaction(s) to store model changes.
delete(bool $hard = false)
Soft or hard deletes the mapped entity.
toArray()
Converts the model object to an assoc array
rehydrate()
Refills (resets) the instance with database driven values
getTransactionMode()
Returns the TransactionType enum currently active (INSERT | UPDATE)
setTransactionMode(TransactionType $type)
Overrides the internal transaction mode.
__call()
See Magic methods
Defining a Model
In order to create a model new, you simply extend the Model class and give it the properties you need.
namespace App\Models;
use Neoan\Model\Model;
use Neoan\Model\Attributes\IsPrimaryKey;
use Neoan\Model\Attributes\IsUnique;
use Neoan\Model\Attributes\Transform;
use Neoan\Model\Transformers\Hash;
class User extends Model
{
#[IsPrimaryKey]
public int $id;
#[IsUnique]
public string $email;
public string $userName;
#[Transform(Hash::class)]
public string $password;
}
Default & Custom Types
Depending on the database adapter you use (see Quick Start), your PHP types will translate into a default column type. For example, the recommended MySQL adapter will assume a string to be varchar with the length of 255, a PHP integer to be int with a length of 11 and so on. If you want to influence this behavior, you can use attributes to do so. The generic attribute Type allows you to be very specific as well:
...
use Neoan\Model\Transformers\Type;
...
#[Type('LONGTEXT')]
public $post;
...
The following model attributes are shipped with LENKRAD
HasMany(string $modelName, array $matchingRule = [])
Enables automatic loading of one-to-many relationships by attaching a collection
Ignore
Signifies a property that shall be ignored by database transactions.
Initialize(mixed $setter)
Attaches a value or instance to the model at creation. (For example a date-converter)
IsForeignKey(string $modelName, ?string $property)
Enhances relationship lookup performance and enables magic relationships
IsPrimaryKey
REQUIRED! Indicates the index of a model for many operations.
IsUnique
handles UNIQUE constraints
Transform(string $transformerClass)
Generic hook for transforming values bi- or one-directional (own transformers must implement Neoan\Model\Interfaces\Transformation). LENKRAD ships with the transformers Hash and CurrentTimeIn out of the box.
Type(string $type, int $length = null, string $default = null)
As discussed, enables custom column-type-matching
Constants
To escape further default behavior, constants are used. With new Projects, there shouldn't be any necessity for that.
const tableName = 'user_table';
tableName
Overrides the usage of the class name as the table name.
Traits
To simplify data structure, traits offer an easy way to escape duplication. LENKRAD offers the following model traits out of the box:
Setter
Enables the model to deal with private and/or readonly properties. It also exposes the method set(string $propertyName, mixed $value) to the model.
TimeStamps
Shorthand for the common fields createdAt, updatedAt, and deletedAt. In addition to allowing for soft-deletion, this trait handles time format transformations.
Full Example
namespace App\Models;
use Neoan\Model\Model;
use Neoan\Model\Attributes\IsPrimaryKey;
use Neoan\Model\Attributes\IsUnique;
use Neoan\Model\Attributes\Transform;
use Neoan\Model\Transformers\Hash;
use Neoan\Model\Traits\Setter;
use Neoan\Model\Traits\TimeStamps;
class User extends Model
{
#[IsPrimaryKey]
public readonly int $id;
#[IsUnique]
public string $email;
public string $userName;
#[Transform(Hash::class)]
public string $password;
use Setter;
use TimeStamps;
}
Migration
Creating and updating tables according to your model must be done whenever changes to your model declaration have been conducted. The cli tool makes this possible using one command.
Computed values
A model is often more than a mapping to persistent data. Sometimes we want to derive information from that data and have our controllers consume it with more ease. Computed values are meant to help with this.
namespace App\Models;
use Neoan\Model\Model;
use Neoan\Model\Attributes\IsPrimaryKey;
use Neoan\Model\Attributes\IsForeignKey;
use Neoan\Model\Attributes\Computed;
class Person extends Model
{
#[IsPrimaryKey]
public int $id;
#[IsForeignKey(User::class)]
public int $userId;
public string $firstName;
public string $lastName;
#[Computed]
public function fullName(): string
{
return ($this->firstName ?? '') . ' ' . ($this->lastName ?? 'Anonymous');
}
}
Consumption
Instead of having to call the method, you can read the value as property.
Computed values are processed by data normalization and are therefore exposed in controller,
templating (rendering), or JSON response:
HTML
JSON API response
{
"id": 1,
"firstName": "Adam",
"lastName": "Smith",
"fullName": "Adam Smith"
}
Hooks
Interacting with models fires Events and every model is listenable. In some cases relying on "external" logic to facilitate things that should run "whenever I save" might better be addressed directly. To do just that, you can choose to overwrite the following model-methods
afterStore()
Is executed after database transaction & rehydration
afterDeletion()
Is executed after database transaction & before destruction
Remembering our examples above, let's pretend our movies have a rating based on the average of the model Rating, representing a single vote by a user.
namespace App\Models;
use Neoan\Model\Model;
use Neoan\Model\Attributes\IsPrimaryKey;
use Neoan\Model\Attributes\IsForeignKey;
class Rating extends Model
{
#[IsPrimaryKey]
public int $id;
#[IsForeignKey(Movie::class)]
public int $movieId;
public int $rating;
public function afterStore(): void
{
// if you want to fire respective events BEFORE your logic happens, place
// this at the top. If you want to fire event AFTER your logic, at the end.
// If you want to suppress events, don't call the parent method at all.
// parent::afterStore();
$newAverageRating = Calculations::methodReturningAverageRatingByMovieId($this->movieId);
$movie = $this->movie();
$movie->rated = $newAverageRating;
$movie->store();
}
}
Be mindful of the execution chain and possible infinite loops when dealing with hooks. In this example, it's easy to imagine what would happen if the model Movie would also use this hook and so on.
Collections
A collection instance is useful when dealing with multiple different instances of a given model. This is commonly the case when retrieving multiple entries. A collection is iterable and offers useful methods:
each(fn)
Expects a closure or invokable while iterating over the instances.
add(Model $instance)
Manually adds an instance to the collection.
toArray
Returns all entries as one array.
store
Runs store commands for all instances contained in the collection (beware of performance!).
count
Returns an integer with the number of held model instances.
Examples
// this is an example, please don't take this seriously:
// return all users as collection
$userCollection = User::retrieve();
// a collection is iterable:
foreach($userCollection as $userInstance){
...
}
// but you likely want to make use of typing
$userCollection->each(function(User $user){
if(str_ends_with($user->email, '@protonmail.com')) {
$this->sendSecureEmail($user);
}
});
// how many users?
$registeredUsers = $userCollection->count();
// add a new user
if($registeredUsers < $this->marketingGoal) {
for($i = 0; $i < $this->marketingGoal - $registeredUsers; $i++) {
$bot = new User([
'email' => "myspam+{$i}@gmail.com",
'userName' => 'bot-' . $i,
'password' => 'you-wish-800-' . $i
]);
$userCollection->add($bot);
}
}
// since we got ALL users, this could take a while...
$userCollection->store();
Using a model
Retrieval
Conditions
Let's begin by exploring how conditions work. Conditions are passed into a model as assoc arrays
$condition = [
'email' => 'adam@email.com'
];
$user = User::retrieveOne($condition);
With the MySQL adapter, this roughly translates to
SELECT * FROM user WHERE email = "adam@email.com"
.
Roughly, as in reality queries are optimized and executed as prepared statements.
The following examples should help you to read & write conditions:
['email' => '%@email.com']
Retrieve where email ends with "@email"
['id' => '>100']
Retrieve where id is greater than 100
['userName' => '!adam']
Retrieve where userName is not "adam"
['updatedAt' => '!']
Retrieve where updatedAt is not NULL (aka WHERE the record was updated at least once)
['deletedAt' => null] or simply ['^deletedAt']
Retrieve where deletedAt is NULL (aka undeleted entries)
Conditions are chained as AND, so
['email' => '%@email.com', 'userName' => '!adam']
retrieves records that meet both criteria.
Filters
Both shipped adapters currently only provide two filters:
orderBy => [string $property, string $ascOrDesc]
Influences the sorting order of results
limit => [int $offset, int $rowCount]
Paginates results
$filter = [
'orderBy' => ['userName', 'desc'],
'limit' => [0,10]
];
$user = User::retrieve([], $filter);
This would return 10 entries sorted by userName in reverse alphabetical order.
Note: If you are worried about limited complexity, rest assured that direct usage of the Database class can handle any potential bottleneck.
Security
Conditions are matched against the structure and type of a given model and transactions are executed as prepared statements. This annihilates user-input concerns.
// yes, this is save to do
$postedInput = Request::getInputs();
$user = User::retrieveOne($postedInput);
Create
Creating a new model entry is as straight forward as it can be:
// as empty instance
$user = new User();
// write
$user->userName = 'Adam'; // if type is correct, will set
// or directly with values passed as array
$user = new User(['userName' => 'Adam']);
// read
echo $user->userName; // "Adam"
// write
$user->userName = 'Ben'; // overrides old value
try{
$user->store();
} catch(\Exception $e) {
// ups... some properties are neither nullable nor have a default value
// so we have to set email & password
}
$user->password = '123123';
$user->email = 'ben@email.com';
// if a setter is used you can also do:
$user->set('password', '123123')
->set('email', 'ben@email.com');
$user->store();
// after database transaction, we have an id (or other primary key)!
$userId = $user->id;
Update
Updating an entry is no different from creation.
$user = User::retrieveOne(['email' => 'ben@email.com']);
// rename
$user->userName = 'Benjamin';
// save changes to db
$user->store();
Collection properties
If you looked at the attribute-section of the model class, you might have noticed the HasMany-attribute. In a real world scenario, one might have two models:
Movie model
namespace App\Models;
use Neoan\Model\Model;
use Neoan\Model\Collection;
use Neoan\Model\Attributes\IsPrimaryKey;
use Neoan\Model\Attributes\HasMany;
use Neoan\Model\Traits\TimeStamps;
class Movie extends Model
{
#[IsPrimaryKey]
public int $id;
public string $name;
#[HasMany(Rating::class)]
public Collection $ratings;
use TimeStamps;
}
Rating model
namespace App\Models;
use Neoan\Model\Model;
use Neoan\Model\Collection;
use Neoan\Model\Attributes\IsPrimaryKey;
use Neoan\Model\Attributes\IsForeignKey;
use Neoan\Model\Traits\TimeStamps;
use App\Models\Movie;
class Rating extends Model
{
#[IsPrimaryKey]
public int $id;
#[IsForeignKey(Movie::class)]
public int $movieId;
public int $rating;
use TimeStamps;
}
The result when retrieving movies now has ratings automatically attached.
$movie = Movie::get(1);
// the property ratings is already a filled collection
$numberOfRatings = $movie->ratings->count();
Be aware that automatically attaching large numbers of rows can lead to performance issues. Since a model is just a class, you can always optimize by providing own methods:
...
class Movie extends Model
{
...
public ratings(): Collection
{
return Rating::retrieve(['movieId' => $this->id]);
}
}
And then use them accordingly:
$movie = Movie::get(1);
// the property ratings is already a filled collection
$numberOfRatings = $movie->ratings()->count();
Have a look at Magic methods for how one-to-one relationships are automated for this.
Pagination
Pagination is a common task when dealing with lists and/or large numbers of expected results. LENKRAD simplified this process.
$page = 1;
$resultsPerPage = 25;
return User::paginate($page, $resultsPerPage)
// you can use the regular condition array to specify
->where(['^deletedAt])
// newest users first? Let's get the last ids first
->descending('id')
// when you're done specifying, execute
->get();
The response of a pagination call is standardized and returns the structure:
[
'page' => 1, // current page
'total' => 48, // total hits
'pageSize' => 25, // number of results per page
'pages' => 2, // total number of resulting pages
'collection' => `{Collection}` // result as Collection
]
Magic methods
LENKRAD avoids magic methods as we believe in empowering your IDE to do its job without having to write comments like it's 2015. However, models DO have a __call-implementation and attributes can make use of it. Out of the box, the attribute IsForeignKey uses it. Let's revive our movie class and add the property "director" to see it in action:
namespace App\Models;
use Neoan\Model\Model;
use Neoan\Model\Collection;
use Neoan\Model\Attributes\IsPrimaryKey;
use Neoan\Model\Attributes\IsForeignKey;
use Neoan\Model\Attributes\HasMany;
use Neoan\Model\Traits\TimeStamps;
class Movie extends Model
{
#[IsPrimaryKey]
public int $id;
public string $name;
#[HasMany(Rating::class)]
public Collection $ratings;
// let's pretend we have a model "Director"
#[IsForeignKey(Director::class)]
public int $directorId;
use TimeStamps;
// Bonus: let's add a static helper after noticing we tend to
// always get movies by name, but for some reason programmatically
// (In reality one would likely notice that this is rarely necessary)
public static function byName( string $name): ?self
{
return self::retrieveOne(['name' => $name]);
}
}
In action:
$movie = Movie::byName('Avatar');
// Of course we have the directorId available
$directorId = $movie->directorId;
// now let's get the associated director!
if($directorId === $movie->director()->id){
// as you have guessed, we now have the director available
}
Alternatively, you can remain in the parent scope by using the "with"-attachment:
// for this to work, the foreign key for class "Director" must exist.
$movie = Movie::byName('Avatar')->withDirector();
// now director is an object in $movie
$directorName = $movie->director->name;
// Since the binding isn't broken, we can leverage that for collections as well
$allMovies = Movie::find([])->each(fn(Movie $movie) => $movie->withDirector());
As mentioned, your IDE will not suggest the availability of magic methods. Refer to your IDE's guide on how to add magic methods to your suggestions (usually @method)
Before you move on
Many references on this page assume default settings. Your project might differ in behavior, paths etc.