-
Notifications
You must be signed in to change notification settings - Fork 2
ActiveRecord
Pair\Orm\ActiveRecord is Pair's model base class for table-mapped entities.
In real Pair applications such as easyprovider it is not just the persistence layer. It is also the default entry point for:
- loading one record or many records
- populating objects from the request
- inserting, updating and deleting records
- traversing relations through foreign keys
- rendering safe HTML directly in layouts
- generating a first-pass
Formfrom model metadata - hydrating model objects from custom SQL queries
If you need to understand how most Pair business models work in practice, this is one of the most important classes in the framework.
Looking at common patterns in /Users/viames/Sites/easyprovider, the methods that appear most often around ActiveRecord are these:
populateByRequest()store()findOrFail()getAllObjects()all()-
printHtml()/html() getParentProperty()getRelateds()exists()isLoaded()isDeletable()toArray()getForm()
This page goes much deeper on exactly those methods, while still covering the rest of the public API that you will use regularly.
An ActiveRecord object is both data and behavior. It knows how to:
- map class properties to table columns
- cast values consistently when reading from and writing to the database
- persist itself
- track changed properties
- expose model-aware shortcuts to
Query - format itself for HTML and JSON output
- navigate relations through foreign keys
When your code is centered on one table-backed entity with domain logic nearby, ActiveRecord is the right default.
Important nuance: Pair supports compound keys, but simple keys remain the most predictable and ergonomic path.
This distinction is critical and causes many mistakes when forgotten.
Property-oriented methods:
getAllObjects(['providerId' => 3])countAllObjects(['enabled' => true])findByAttributes((object)['groupId' => 4])update(['status', 'title'])getParent('billingId')getParentProperty('countryId', 'code')
Column-oriented methods:
query()->where('provider_id', 3)latest('created_at')orderBy('name', 'asc')whereBetween('created_at', [...])
Rule of thumb:
- if the method is a classic
ActiveRecordhelper, it usually expects model property names - if the method comes from
Query, it usually expects SQL column names
<?php
namespace App\Orm;
use Pair\Orm\ActiveRecord;
class Order extends ActiveRecord {
const TABLE_NAME = 'orders';
const TABLE_KEY = ['id'];
protected int $id;
protected int $customerId;
protected string $status;
protected float $total;
protected bool $paid;
protected array $tags;
protected mixed $meta;
protected ?\DateTime $createdAt;
protected ?\DateTime $updatedAt;
protected function _init(): void
{
// Bindings define how the object casts DB values and request values.
$this->bindAsInteger('id', 'customerId');
$this->bindAsFloat('total');
$this->bindAsBoolean('paid');
$this->bindAsCsv('tags');
$this->bindAsJson('meta');
$this->bindAsDatetime('createdAt', 'updatedAt');
}
}The constructor supports four important modes.
$order = new Order();
// At this point the object is not loaded from DB.
$loaded = $order->isLoaded();$order = new Order(42);
// This is the classic edit/change flow.
if (!$order->isLoaded()) {
throw new \RuntimeException('Order not found');
}$doc = new TenantDocument([
// Compound-key order must match TABLE_KEY order.
$tenantId,
$code,
]);$row = (object) [
// This shape usually comes from Database::load(...).
'id' => 42,
'status' => 'pending',
];
// This mode is mainly used internally or in custom hydration helpers.
$order = new Order($row);Practical notes:
-
getId()returns a scalar for simple keys and an indexed array for compound keys -
areKeysPopulated()checks whether all key properties have a value -
isLoaded()tells you whether the object was successfully loaded from DB -
existsInDb()performs a fresh existence check using the current key values
This is one of the most common Pair patterns and appears repeatedly in easyprovider.
$profile = new Profile();
// Copy request fields whose names match bound model properties.
$profile->populateByRequest();
// Context-sensitive fields are usually assigned explicitly, not trusted from POST.
$profile->providerId = Provider::current()->id;
// store() chooses create() or update() based on keys and DB existence.
$profile->store();Update flow:
$profile = new Profile(Post::int('id'));
// This guard is common when the code chooses constructor loading over findOrFail().
if (!$profile->isLoaded()) {
throw new \RuntimeException('Profile not found');
}
// Refresh the object from current request data.
$profile->populateByRequest();
$profile->store();These methods are the most common way to read one object.
Returns one object or null.
$sale = Sale::find(15);
// find() is useful when "not found" is a normal branch.
if (!$sale) {
return;
}Returns one object or throws a PairException with RECORD_NOT_FOUND.
This is extremely common in detail/edit views.
$sale = Sale::findOrFail((int) Router::get(0));
// Once here, the object definitely exists.
$code = $sale->getCode();Finds one object by property/value pairs.
Important constraint:
- it accepts a
stdClass, not an array - property names must be model property names
$helpPage = HelpPage::findByAttributes((object) [
// Property names, not column names.
'groupId' => $groupId,
]);It also handles common conversions for you:
-
DateTimevalues are formatted before querying -
boolvalues are converted to integers -
nullbecomeswhereNull(...)
Useful when you want the first result of a fluent query without calling get()->first().
$firstPending = Order::firstWhere(
// Query-style methods use DB column names.
'status',
'=',
'pending'
);Fast existence check by primary or compound key.
This is common in guard clauses before linking or deleting records.
if (!Profile::exists($profileId)) {
throw new \RuntimeException('Profile does not exist');
}
if (!Alias::exists($aliasId)) {
throw new \RuntimeException('Alias does not exist');
}Checks whether the current object still exists in the database.
Use it when the object may have been loaded earlier and another process could have removed it since then.
$invoice = Invoice::findOrFail($id);
// Re-check existence before a destructive or expensive follow-up step.
if (!$invoice->existsInDb()) {
throw new \RuntimeException('Invoice no longer exists');
}This is the companion method for constructor-based loading.
$profileAmount = new ProfileAmount($id);
// This pattern is frequent in codebases that prefer constructor loading.
if (!$profileAmount->isLoaded()) {
$profileAmount = new ProfileAmount();
}Loads the entire table as a Collection of model objects.
This is used a lot for small lookup tables and select options.
$categories = Category::all()
// Collection methods take over after ActiveRecord retrieval.
->sortBy('title');Use all() when:
- the table is small
- you want every record
- you do not need filters in SQL
This is one of the most common high-level retrieval helpers in easyprovider.
Key characteristics:
- filters use model property names
- ordering uses model property names
-
boolfilters are converted to integers -
nullfilters becomeIS NULL - invalid property names throw early
$billings = Billing::getAllObjects(
[
// Filters use property names.
'providerId' => (int) Provider::current()->id,
],
[
// Ordering also uses property names.
'periodStart' => 'DESC',
]
);Another common pattern is building form options:
$workTypeOptions = WorkType::getAllObjects(
['providerId' => Provider::current()->id],
'title'
)
// Collection methods are often chained immediately after retrieval.
->pluck('title', 'id')
->toArray();Counts records with property-based filters.
$contractsCount = Contract::countAllObjects([
// Property-based count helper.
'providerId' => Provider::current()->id,
]);Returns a Query builder already configured to hydrate rows as the current model class.
Use it when you need more SQL flexibility than getAllObjects().
$recentPaid = Order::query()
// Query methods use DB column names.
->where('status', '=', 'paid')
->whereDate('created_at', '>=', '2026-01-01')
->latest('created_at')
->limit(50)
->get();The following shortcuts all delegate to query():
where(...)whereBetween(...)whereDate(...)whereDay(...)whereIn(...)whereMonth(...)whereNotIn(...)whereNotNull(...)whereNull(...)whereTime(...)whereYear(...)latest(...)oldest(...)orderBy(...)
Example:
$rows = Order::whereIn('status', ['pending', 'paid'])
->whereBetween('created_at', ['2026-02-01', '2026-02-28'])
->orderBy('id', 'desc')
->get();Important distinction:
-
getAllObjects(['providerId' => 5])uses model properties -
Order::where('provider_id', 5)uses SQL columns
Use all() when:
- you want every row
- the table is small
- SQL filtering is unnecessary
Use getAllObjects() when:
- filters are simple equality checks on model properties
- order clauses are simple
- you want property-name safety
Use query() when:
- you need
whereIn,whereBetween,paginate, nested conditions, raw joins, or more SQL control
In real Pair apps, not every list comes from query() or getAllObjects(). Some modules use explicit SQL and still want model hydration.
The two key methods are:
getObjectByQuery(string $query, array $params = [])getObjectsByQuery(string $query, array $params = [])
These methods do something very useful:
- bound model columns hydrate normal mapped properties
- extra selected aliases become dynamic camelCase properties
- objects are marked as loaded from DB
This is ideal for join-heavy list screens.
Example inspired by easyprovider-style list queries:
$sql = '
SELECT wo.*, wt.`title` AS `work_type_title`, a.`name` AS `affiliate_name`
FROM `work_orders` wo
INNER JOIN `work_types` wt ON wt.`id` = wo.`work_type_id`
INNER JOIN `affiliates` a ON a.`id` = wo.`affiliate_id`
';
$rows = WorkOrder::getObjectsByQuery($sql);
// Aliases become dynamic camelCase properties on each hydrated object.
$first = $rows->first();
$title = $first->workTypeTitle;
$affiliateName = $first->affiliateName;Single-object variant:
$sql = '
SELECT p.*, c.`name` AS `city_name`
FROM `precontracts` p
LEFT JOIN `cities` c ON c.`id` = p.`city_id`
WHERE p.`id` = ?
';
$precontract = Precontract::getObjectByQuery($sql, [$id]);
if ($precontract) {
// city_name becomes dynamic property cityName.
$city = $precontract->cityName;
}When you already have a stdClass row and only need to mark it as loaded:
$row = Database::load($sql, [$id], Database::OBJECT);
if ($row) {
// This is a tiny wrapper around constructor hydration + loaded flag.
$object = WorkOrder::hydrateFromRow($row);
}This is one of the most used methods in controller actions.
Behavior:
- reads request fields whose names match bound model properties
- if you pass specific property names, only those are populated
- booleans are always considered, even when the input is absent
Typical add/change flow:
$extra = new Extra();
// Copy request data into matching model properties.
$extra->populateByRequest();
// Provider-scoped data is usually assigned explicitly after request population.
$extra->providerId = Provider::current()->id;Restricted population:
$order = Order::findOrFail($id);
// Only these properties are refreshed from the request.
$order->populateByRequest('status', 'total', 'paid');Important practical note:
- because boolean properties are always processed, unchecked checkbox-like inputs do not preserve the old object value automatically
store() is the default persistence method in most Pair CRUD controllers.
Behavior:
- if keys are populated and the record already exists, it calls
update() - otherwise it calls
create() - it executes
beforeStore()andafterStore()
$profile = new Profile();
$profile->populateByRequest();
// store() chooses create() here because the object is new.
$profile->store();
$profile->title = 'Updated title';
// store() chooses update() here because the record already exists.
$profile->store();Important implementation detail:
- in the current source,
store()returnstruewhen the write completes - many legacy controllers still use
if (!$object->store())style checks, but in practice the meaningful failure paths usually come from exceptions or custom errors added by hooks/business methods
Use create() when you explicitly want an insert and do not want store() to decide for you.
$order = new Order();
$order->customerId = 12;
$order->status = 'pending';
$order->total = 99.50;
// Force an INSERT path.
$order->create();Automatic audit fields:
createdAtupdatedAtcreatedByupdatedBy
These are auto-populated when the properties exist on the model.
Updates either:
- all bound properties, when called without arguments
- only the listed model properties, when called with a property list
$order = Order::findOrFail(42);
$order->status = 'confirmed';
// Update only the listed properties.
$order->update(['status']);Property names, not column names:
$order->update([
// These are model properties.
'status',
'total',
]);Useful for PATCH-like flows when null should mean “leave untouched” instead of “write null”.
$order = Order::findOrFail($id);
// Populate only a partial payload.
$order->populateByRequest('status', 'total', 'paid', 'meta');
// Persist only properties that are currently non-null.
$order->updateNotNull();Use this carefully:
- it is ideal when the object is only partially populated
- it is not ideal when
nullis a meaningful value that must really be written to DB
Deletes the record identified by the current keys.
$profile = Profile::findOrFail($id);
if (!$profile->isDeletable()) {
throw new \RuntimeException('Profile cannot be deleted');
}
$profile->delete();Important nuance:
- after deletion, the object unsets most of its properties, clears errors, and is no longer marked as loaded from DB
These methods are useful when your model or hooks add explicit errors.
$profile = new Profile();
$profile->populateByRequest();
$profile->store();
// Read any model-level errors added during the flow.
$errors = $profile->getErrors();
$lastError = $profile->getLastError();Compares current object state with the current DB record.
$order = Order::findOrFail(42);
$order->status = 'cancelled';
// This triggers a fresh comparison against the DB row.
if ($order->hasChanged()) {
$order->update(['status']);
}Reloads the current object from DB using the current keys and discards most in-memory changes.
$order = Order::findOrFail(42);
$order->status = 'draft';
// Throw away in-memory edits and reload the DB version.
$order->reload();These two methods answer different questions.
isDeletable():
- checks inverse foreign keys with
DELETE_RULE = RESTRICT - answers “can I delete this without violating restrictive FK rules?”
isReferenced():
- checks whether any referencing rows exist at all
- answers “does anything point to this record?”
Example:
$profile = Profile::findOrFail($id);
if ($profile->isReferenced()) {
// A relation exists somewhere.
}
if ($profile->isDeletable()) {
// Restrictive FK rules do not block deletion.
}These are small helpers but very useful in import, clone, and upsert flows.
$order = new Order();
// With an empty object, keys are not populated yet.
$canUpdate = $order->areKeysPopulated();
$id = $order->getId();getForm() auto-generates a Form based on:
- bound property types
- DB column types
- nullability and emptiability
- current object values
Current mapping behavior:
- key properties -> hidden controls
-
bool-> checkbox -
DateTime->dateordatetime -
float-> number withstep('0.01') -
int-> number -
csv-> multiple select -
json-> textarea -
enum-> select -
set-> multiple select - text-like DB types -> textarea
- other strings -> text with
maxLength(...)when available - non-nullable and non-emptiable fields ->
required()
Basic example:
$extra = Extra::findOrFail($id);
// getForm() creates controls and preloads the current object values.
$form = $extra->getForm();In practice, many modules use getForm() as a base and then customize it.
$extra = Extra::findOrFail($id);
$form = $extra->getForm();
// Apply a shared UI class to all generated controls.
$form->classForControls('form-control');
// Replace or extend specific generated controls for module-specific UX.
$form->control('billingId')
->label('Reference billing')
->class('default-select2');Another very common pattern is building a manual Form and then using values($object) there, but that part belongs mainly to Form. The key point here is that ActiveRecord::getForm() already knows how to derive a sensible first version of the form from the model.
These methods are heavily used in easyprovider layouts.
Returns an HTML-safe string representation of:
- a property
- a method result
- a dynamically loaded property
Formatting behavior depends on type:
-
bool-> check/times icon HTML -
DateTime-> localized date or datetime -
csv-> comma-separated escaped text -
json-><pre>...</pre> - strings -> escaped text with
nl2br(...) - arrays -> comma-separated escaped text
Property example:
$precontract = Precontract::findOrFail($id);
// html() returns a formatted escaped string.
$nameHtml = $precontract->html('name');Method example:
$sale = Sale::findOrFail($id);
// You can pass method names with or without parentheses.
$stateHtml = $sale->html('readableState()');Echo helper around html(...).
This is one of the most common layout helpers in easyprovider.
<td><?php $precontract->printHtml('name'); ?></td>
<td><?php $precontract->printHtml('email'); ?></td>
<td><?php $sale->printHtml('readableState()'); ?></td>These methods are useful when you need a date string outside html(...).
$order = Order::findOrFail(42);
// Use framework-localized formatting by default.
$created = $order->formatDate('createdAt');
$updated = $order->formatDateTime('updatedAt');
// Or pass an explicit DateTime format string.
$iso = $order->formatDateTime('updatedAt', 'Y-m-d H:i:s');Practical note:
-
html('createdAt')usually covers the layout use case -
formatDate(...)/formatDateTime(...)are better when you need the string for other logic or templating
printHtml() and html() already escape/format output.
getParentProperty(...) returns the raw property value from the parent object, so you should escape it yourself when printing.
<td><?php $precontract->printHtml('name'); ?></td>
<td><?php print htmlspecialchars($precontract->getParentProperty('countryId', 'code')); ?></td>Returns the parent object behind a foreign key property.
$payment = Payment::findOrFail($id);
// billingId is a property on Payment that points to a Billing object.
$billing = $payment->getParent('billingId', Billing::class);Caching behavior:
- normal parent lookups are cached in the object cache
- properties listed in
SHARED_CACHE_PROPERTIEScan use the shared application cache
Shortcut for “load parent and return one property”.
This is extremely common in table layouts.
$precontract = Precontract::findOrFail($id);
// Read one field from the related profile without writing explicit relation code.
$title = $precontract->getParentProperty('profileId', 'title');Returns a Collection of related child objects.
Without arguments:
- it tries to discover all useful inverse foreign keys
With a class name:
- it filters to that related model only
Example:
$sale = Sale::findOrFail($id);
// Load only related solar plants.
$projects = $sale->getRelateds(SolarPlant::class);
$firstProject = $projects->first();This is a direct match for patterns that appear in easyprovider detail views:
$sale = Sale::findOrFail((int) Router::get(0));
// This pattern is common in detail screens.
$project = $sale->getRelateds('SolarPlant')->first();If relation discovery succeeds, ActiveRecord can resolve calls such as:
getCustomer()getOrders()getBilling()
Example:
$order = Order::findOrFail(42);
// Dynamic parent relation helper.
$customer = $order->getCustomer();
// Dynamic child relation helper.
$payments = $order->getPayments();getLast():
- returns the latest record for a simple key
- if the key is not auto-increment but the model has
createdAt, it falls back tocreated_at DESC
getPrevious():
- returns the previous record only for single auto-increment keys
$lastOrder = Order::getLast();
$current = Order::findOrFail(42);
$previous = $current->getPrevious();Returns all bound properties as an array.
$profile = Profile::findOrFail($id);
// Useful for API payloads or debug dumps.
$payload = $profile->toArray();Practical note:
-
toArray()returns the current typed values, not the HTML-formatted ones - for example,
DateTimeproperties stayDateTimeobjects until you format them explicitly
JSON-encodes toArray().
$profile = Profile::findOrFail($id);
// Pretty-print when the payload is for logs or debug output.
$json = $profile->toJson(JSON_PRETTY_PRINT);Useful when you want a plain stdClass, optionally with only a subset of properties.
$profile = Profile::findOrFail($id);
$publicPayload = $profile->convertToStdClass([
// Export only the fields you really want.
'id',
'title',
'enabled',
]);Like toArray(), this method returns raw typed values rather than view-formatted strings.
Makes the object compatible with json_encode($object).
Practical note:
-
toArray()is usually clearer when you want explicit control over the output -
jsonSerialize()is still useful for generic JSON responses
Returns bound properties only, with current typed values.
This is useful for debug, diff logic, and model-level tooling.
$order = Order::findOrFail(42);
// Bound properties only, no ActiveRecord internals.
$properties = $order->getAllProperties();_init() is where you define property bindings:
bindAsBoolean(...)bindAsCsv(...)bindAsDatetime(...)bindAsFloat(...)bindAsInteger(...)bindAsJson(...)
These bindings affect:
- constructor hydration
- request population through
populateByRequest() - write preparation before
create()/update() - HTML formatting through
html(...) - automatic control type selection in
getForm()
Example:
protected function _init(): void
{
// Integer properties are cast on read and write.
$this->bindAsInteger('id', 'providerId');
// Booleans affect getForm(), html(), and request population behavior.
$this->bindAsBoolean('enabled');
// DateTime bindings activate typed date handling end to end.
$this->bindAsDatetime('createdAt', 'updatedAt');
}Practical note:
- keep
_init()deterministic and lightweight; it is the right place for bindings and small setup, not for business logic
These hooks are the main extension points:
-
beforePopulate(...)/afterPopulate() -
beforePrepareData()/afterPrepareData(...) -
beforeStore()/afterStore() -
beforeCreate()/afterCreate() -
beforeUpdate()/afterUpdate() -
beforeDelete()/afterDelete()
Example:
class Order extends ActiveRecord {
protected function beforeCreate(): void
{
// Set a sensible default before the INSERT is prepared.
if (!$this->status) {
$this->status = 'pending';
}
}
protected function beforeUpdate(): void
{
// React only when the tracked property really changed.
if ($this->hasPropertyUpdated('status') && $this->status === 'paid') {
$this->paid = true;
}
}
protected function beforeDelete(): void
{
// Stop destructive flows if FK rules or domain logic say no.
if (!$this->isDeletable()) {
throw new \RuntimeException('Order cannot be deleted');
}
}
}$extra = new Extra();
// Populate only properties that belong to the model.
$extra->populateByRequest();
// Context values should still be assigned explicitly.
$extra->providerId = Provider::current()->id;
// store() handles insert/update selection.
$extra->store();$profile = new Profile(Post::int('id'));
// Constructor loading + isLoaded() is still common in many controllers.
if (!$profile->isLoaded()) {
throw new \RuntimeException('Profile not found');
}$profiles = Profile::getAllObjects(
// Property-based filters stay concise in model code.
['providerId' => Provider::current()->id],
['title' => 'ASC']
);$options = WorkType::getAllObjects(
['providerId' => Provider::current()->id],
'title'
)
// After the retrieval, Collection helpers build the final array.
->pluck('title', 'id')
->toArray();<td><?php $precontract->printHtml('name'); ?></td>
<td><?php $precontract->printHtml('email'); ?></td>
<td><?php print htmlspecialchars($precontract->getParentProperty('countryId', 'code')); ?></td>$sale = Sale::findOrFail((int) Router::get(0));
// Child relation lookup by class name.
$project = $sale->getRelateds('SolarPlant')->first();
// Parent relation lookup through dynamic __call().
$affiliate = $sale->getAffiliate();$sql = '
SELECT i.*, p.`title` AS `profile_title`
FROM `invoice_items` i
INNER JOIN `profiles` p ON p.`id` = i.`profile_id`
WHERE i.`invoice_id` = ?
';
$items = InvoiceItem::getObjectsByQuery($sql, [$invoiceId]);
// profile_title becomes profileTitle on hydrated objects.
$title = $items->first()->profileTitle;$ticket = Ticket::findOrFail($id);
// Read only the properties exposed by this endpoint.
$ticket->populateByRequest('status', 'priority', 'assignedUserId');
// Keep null values out of the UPDATE payload.
$ticket->updateNotNull();$template = Order::findOrFail(42);
// __clone() resets keys and DB-loaded state.
$copy = clone $template;
$copy->status = 'draft';
$copy->store();$country = Country::getObjectByCachedList('iso2', 'IT');
// Clear the application-level cached list after writes.
Country::unsetCachedList();These methods are less central than the ones above, but they are still useful in real Pair code.
-
getMappedField(string $propertyName): ?stringReturns the DB column name for a model property. -
getMappedProperty(string $fieldName): ?stringReturns the model property for a DB column name. -
getPropertyType(string $name): ?stringReturns Pair's effective property type (bool,int,float,DateTime,csv,json,string). -
getQueryColumns(): stringReturns the base select list, including decrypted columns when needed. -
getEncryptedColumnsQuery(?string $tableAlias = null): stringUseful in advanced custom SQL when the model declaresENCRYPTABLES. -
getObjectByCachedList(string $property, $value): ?selfHandy for small lookup tables reused many times in one request. -
unsetCachedList(): voidInvalidates that application-level cached list. -
getCache(...),setCache(...),unsetCache(...),issetCache(...)Object-level cache helpers used mainly by relation-heavy logic. -
hydrateFromRow(\stdClass $row): staticSmall helper for custom loaders. -
serialize()/unserialize(...)Persist and restore model state through serialized payloads.
-
store()is the default persistence method for most CRUD flows. -
create()andupdate()auto-managecreatedAt,updatedAt,createdBy, andupdatedBywhen those properties exist. -
getAllObjects()is often the best “Pair-native” read helper when you want property-name safety. -
query()is the right escalation path when you need expressive SQL builder features. -
getObjectByQuery()andgetObjectsByQuery()are the right path for join-heavy custom SQL that still needs model hydration. -
html()andprintHtml()are extremely convenient in layouts, but remember thatgetParentProperty()returns raw values and should usually be escaped explicitly. -
getForm()returns a Form, but the final layout still decides how to render labels, wrappers, buttons, and tokens.
See also: Query, Collection, Database, Form, FormControl, Model.