Skip to content

ActiveRecord

Viames Marino edited this page Mar 26, 2026 · 7 revisions

Pair framework: 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 Form from 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.

What easyprovider uses most

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.

Mental model

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.

Property names vs column names

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 ActiveRecord helper, it usually expects model property names
  • if the method comes from Query, it usually expects SQL column names

Minimal model declaration

<?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');
    }
}

Constructor and loading modes

The constructor supports four important modes.

1. Empty object

$order = new Order();

// At this point the object is not loaded from DB.
$loaded = $order->isLoaded();

2. Load by primary key

$order = new Order(42);

// This is the classic edit/change flow.
if (!$order->isLoaded()) {
    throw new \RuntimeException('Order not found');
}

3. Load by compound key

$doc = new TenantDocument([
    // Compound-key order must match TABLE_KEY order.
    $tenantId,
    $code,
]);

4. Hydrate from a DB row

$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

Most-used easyprovider flow: controller CRUD

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();

Retrieval and existence helpers

These methods are the most common way to read one object.

find(...)

Returns one object or null.

$sale = Sale::find(15);

// find() is useful when "not found" is a normal branch.
if (!$sale) {
    return;
}

findOrFail(...)

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();

findByAttributes(...)

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:

  • DateTime values are formatted before querying
  • bool values are converted to integers
  • null becomes whereNull(...)

firstWhere(...)

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'
);

exists(...)

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');
}

existsInDb()

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');
}

isLoaded()

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();
}

Lists, options, and query entry points

all()

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

getAllObjects($filters = [], $orderBy = [])

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
  • bool filters are converted to integers
  • null filters become IS 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();

countAllObjects($filters = [])

Counts records with property-based filters.

$contractsCount = Contract::countAllObjects([
    // Property-based count helper.
    'providerId' => Provider::current()->id,
]);

query()

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();

Query shortcut methods

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

When to use all(), getAllObjects(), or query()

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

Custom SQL hydration

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;
}

hydrateFromRow(...)

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);
}

Persistence methods

populateByRequest(...)

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()

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() and afterStore()
$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() returns true when 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

create()

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:

  • createdAt
  • updatedAt
  • createdBy
  • updatedBy

These are auto-populated when the properties exist on the model.

update($properties = null)

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',
]);

updateNotNull()

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 null is a meaningful value that must really be written to DB

delete()

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

Request errors, state, and diagnostics

getErrors() / getLastError() / resetErrors()

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();

hasChanged()

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']);
}

reload()

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();

isDeletable() vs isReferenced()

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

areKeysPopulated() and getId()

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();

Form integration

getForm()

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 -> date or datetime
  • float -> number with step('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.

HTML and view helpers

These methods are heavily used in easyprovider layouts.

html($name, $params = [])

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()');

printHtml($name)

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>

formatDate(...) and formatDateTime(...)

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

Important distinction with relation helpers

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>

Relations and navigation

getParent($parentProperty, $className = null)

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_PROPERTIES can use the shared application cache

getParentProperty($parentProperty, $wantedProperty)

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');

getRelateds($refClass = null)

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();

Dynamic relation helpers via __call(...)

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() and getPrevious()

getLast():

  • returns the latest record for a simple key
  • if the key is not auto-increment but the model has createdAt, it falls back to created_at DESC

getPrevious():

  • returns the previous record only for single auto-increment keys
$lastOrder = Order::getLast();

$current = Order::findOrFail(42);
$previous = $current->getPrevious();

Serialization and raw data extraction

toArray()

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, DateTime properties stay DateTime objects until you format them explicitly

toJson($options = 0)

JSON-encodes toArray().

$profile = Profile::findOrFail($id);

// Pretty-print when the payload is for logs or debug output.
$json = $profile->toJson(JSON_PRETTY_PRINT);

convertToStdClass($wantedProperties = null)

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.

jsonSerialize()

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

getAllProperties()

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();

Bindings, casting, and hooks

_init()

_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

Lifecycle hooks

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');
        }
    }
}

Practical cookbook

1. Easyprovider-style add action

$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();

2. Easyprovider-style edit/detail guard

$profile = new Profile(Post::int('id'));

// Constructor loading + isLoaded() is still common in many controllers.
if (!$profile->isLoaded()) {
    throw new \RuntimeException('Profile not found');
}

3. Provider-scoped list helper

$profiles = Profile::getAllObjects(
    // Property-based filters stay concise in model code.
    ['providerId' => Provider::current()->id],
    ['title' => 'ASC']
);

4. Select options from ActiveRecord + Collection

$options = WorkType::getAllObjects(
    ['providerId' => Provider::current()->id],
    'title'
)
    // After the retrieval, Collection helpers build the final array.
    ->pluck('title', 'id')
    ->toArray();

5. Table rendering with mixed helpers

<td><?php $precontract->printHtml('name'); ?></td>
<td><?php $precontract->printHtml('email'); ?></td>
<td><?php print htmlspecialchars($precontract->getParentProperty('countryId', 'code')); ?></td>

6. Detail view with related models

$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();

7. Raw SQL with computed aliases

$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;

8. PATCH flow that ignores null values

$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();

9. Clone an existing record as a template

$template = Order::findOrFail(42);

// __clone() resets keys and DB-loaded state.
$copy = clone $template;
$copy->status = 'draft';
$copy->store();

10. Small lookup-table cache

$country = Country::getObjectByCachedList('iso2', 'IT');

// Clear the application-level cached list after writes.
Country::unsetCachedList();

Other useful public methods

These methods are less central than the ones above, but they are still useful in real Pair code.

  • getMappedField(string $propertyName): ?string Returns the DB column name for a model property.
  • getMappedProperty(string $fieldName): ?string Returns the model property for a DB column name.
  • getPropertyType(string $name): ?string Returns Pair's effective property type (bool, int, float, DateTime, csv, json, string).
  • getQueryColumns(): string Returns the base select list, including decrypted columns when needed.
  • getEncryptedColumnsQuery(?string $tableAlias = null): string Useful in advanced custom SQL when the model declares ENCRYPTABLES.
  • getObjectByCachedList(string $property, $value): ?self Handy for small lookup tables reused many times in one request.
  • unsetCachedList(): void Invalidates that application-level cached list.
  • getCache(...), setCache(...), unsetCache(...), issetCache(...) Object-level cache helpers used mainly by relation-heavy logic.
  • hydrateFromRow(\stdClass $row): static Small helper for custom loaders.
  • serialize() / unserialize(...) Persist and restore model state through serialized payloads.

Practical notes

  • store() is the default persistence method for most CRUD flows.
  • create() and update() auto-manage createdAt, updatedAt, createdBy, and updatedBy when 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() and getObjectsByQuery() are the right path for join-heavy custom SQL that still needs model hydration.
  • html() and printHtml() are extremely convenient in layouts, but remember that getParentProperty() 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.

Clone this wiki locally