Skip to content

Model Relationships


Overview

Database normalization is a process where data is split into different tables and links are created between those tables, in order to increase flexibility, reduce data redundancy, and improve data integrity. Relationships are defined in the initialize method of each model.

The following types of relationships are available:

one to one

hasOne(
    string|array $fields, 
    string $referenceModel, 
    string|array $referencedFields, 
    array $options = null
)

hasOneThrough(
    string|array $fields, 
    string $intermediateModel, 
    string|array $intermediateFields, 
    string|array $intermediateReferencedFields,
    string $referenceModel, 
    string|array $referencedFields, 
    array $options = null
)

one to many

hasMany(
    string|array $fields, 
    string $referenceModel, 
    string|array $referencedFields, 
    array $options = null
)

many to one

belongsTo(
    string|array $fields, 
    string $referenceModel, 
    string|array $referencedFields, 
    array $options = null
)

many to many

hasManyToMany(
    string|array $fields, 
    string $intermediateModel, 
    string|array $intermediateFields, 
    string|array $intermediateReferencedFields,
    string $referenceModel, 
    string|array $referencedFields, 
    array $options = null
)

Relationships can be unidirectional or bidirectional, and each can be simple (a one-to-one model) or more complex (a combination of models). The model manager manages foreign key constraints for these relationships, the definition of these helps referential integrity as well as fast access of related records to a model. Through the implementation of relations, it is straightforward to access data in related models from the source model in a uniform way.

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public function initialize()
    {
        $this->hasOne(
            'inv_cst_id',
            Customers::class,
            'cst_id',
            [
                'alias'    => 'customers',
                'reusable' => true,
            ]
        );
    }
}

Unidirectional

Unidirectional relations are those that are generated in relation to one another but not vice versa.

Bidirectional

The bidirectional relations build relationships in both models and each model defines the inverse relationship of the other.

Setup

In Phalcon, relationships must be defined in the initialize() method of a model. The methods belongsTo(), hasMany(), hasManyToMany(), hasOne(), and hasOneThrough(), define the relationship between one or more fields from the current model to fields in another model. Each of these methods requires 3 parameters:

  • local fields
  • referenced model
  • referenced fields
Method Description
belongsTo Defines a n-1 relationship
hasMany Defines a 1-n relationship
hasManyToMany Defines a n-n relationship
hasOne Defines a 1-1 relationship
hasOneThrough Defines a 1-1 relationship

The following schema shows 3 tables whose relations will serve us as an example regarding relationships:

create table co_invoices
(
    inv_id          int(10) auto_increment  primary key,
    inv_cst_id      int(10)      null,
    inv_status_flag tinyint(1)   null,
    inv_title       varchar(100) null,
    inv_total       float(10, 2) null,
    inv_created_at  datetime     null
);

create table co_invoices_x_products
(
    ixp_inv_id      int(10),
    inv_prd_id      int(10)
);

create table co_products
(
    prd_id          int(10) auto_increment  primary key,
    prd_title       varchar(100) null,
    prd_price       float(10, 2) null
);
  • The model Invoices has many InvoicesProducts.
  • The model Products has many InvoicesProducts.
  • The model InvoicesProducts belongs to both Invoices and Products models as a many-to-one relation.
  • The model Invoices has a relation many-to-many to Products through InvoicesProducts.

The models with their relations could be implemented as follows:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasManyToMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'reusable' => true,
                'alias'    => 'products',
            ]
        );

        $this->hasMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            [
                'reusable' => true,
                'alias'    => 'invoicesProducts'
            ]
        );
    }
}
<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class InvoicesProducts extends Model
{
    public $ixp_inv_id;
    public $ixp_prd_id;

    public function initialize()
    {
        $this->belongsTo(
            'ixp_inv_id',
            Invoices::class,
            'inv_id',
            [
                'reusable' => true,
                'alias'    => 'invoice'
            ]
        );

        $this->belongsTo(
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'reusable' => true,
                'alias'    => 'product'
            ]
        );
    }
}
<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Products extends Model
{
    public $prd_id;
    public $prd_title;
    public $prd_price;
    public $prd_created_at;

    public function initialize()
    {
        $this->hasMany(
            'prd_id',
            InvoicesProducts::class,
            'ixp_prd_id'
        );

        // Many to many -> Invoices
        $this->hasManyToMany(
            'prd_id',
            InvoicesProducts::class,
            'ixp_prd_id',
            'ixp_inv_id',
            Invoices::class,
            'inv_id',
            [
                'reusable' => true,
                'alias'    => 'invoices',
            ]
        );
    }
}

The first parameter indicates the field of the local model used in the relationship; the second indicates the name of the referenced model, and the third is the field name in the referenced model. You could also use arrays to define multiple fields in the relationship.

Many-to-many relationships require 3 models and define the attributes involved in the relationship:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasManyToMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'reusable' => true,
                'alias'    => 'products',
            ]
        );
    }
}

Parameters

Depending on the needs of our application we might want to store data in one table, that describes different behaviors. For instance, you might want to only have a table called co_customers which has a field cst_status_flag describing the status of the customer (e.g. active, inactive, etc.).

Using relationships, you can get only those Customers that relate to our Invoices that have a certain cst_status_flag. Defining that constraint in the relationship allows you to let the model do all the work.

It also accepts a closure, which is evaluated every time before the related records are accessed. This enables the conditions to be automatically updated between queries.

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasMany(
            'inv_cst_id',
            Customers::class,
            'cst_id',
            [
                'reusable' => true,
                'alias'    => 'customersActive',
                'params'   => [
                    'conditions' => 'cst_status_flag = :status:',
                    'bind'       => [
                        'status' => 1,
                     ]
                ]
            ]
        );

        $container = $this->getDI();

        $this->hasMany(
            'inv_cst_id',
            Customers::class,
            'cst_id',
            [
                'reusable' => true,
                'alias'    => 'customersNearby',
                'params'   => function() use ($container) {
                    return [
                        'conditions' => 'cst_location = :location:',
                        'bind'       => [
                            'location' => $container->getShared('myLocationService')->myLocation,
                         ]
                    ];
                }
            ]
        );
    }
}

Multiple Fields

There are times when relationships need to be defined on a combination of fields and not only one. Consider the following example:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Products extends Model
{
    public $prd_id;
    public $prd_type_flag;
    public $prd_name;
}

and

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Parts extends Model
{
    public $par_id;
    public $par_prd_id;
    public $par_par_id;
    public $par_type_flag;
    public $par_name;
}

In the above, we have a Products model which has prd_id, prd_type_flag, and prd_name fields. The Parts model contains par_id, par_prd_id, par_type_flag, and par_name. The relationship exists based on the product's unique id as well as the type.

Using the relationship options, as seen above, binding one field between the two models will not return the results we need. We can use an array with the necessary fields to define the relationship.

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Products extends Model
{
    public $prd_id;
    public $prd_type_flag;
    public $prd_name;

    public function initialize()
    {
        $this->hasOne(
            [
                'prd_id', 
                'prd_type_flag'
            ],
            Parts::class,
            [
                'par_prd_id', 
                'par_type_flag'
            ],
            [
                'reusable' => true, // cache
                'alias'    => 'parts',
            ]
        );
    }
}

NOTE

The field mappings in the relationship are one for one i.e. the first field of the source model array matches the first field of the target array etc. The field count must be identical in both source and target models.

Reusable Cache Key Override

When a relation is marked 'reusable' => true, the Model Manager memoises the resolved related record so that repeated traversals of the same association inside one request return the cached object. By default, the cache key is derived from object identity, which means two PHP instances representing the same row do not share the entry.

A model can supply a stable key by implementing Phalcon\Contracts\Mvc\Model\Relation\CacheKeyProvider:

<?php

use Phalcon\Contracts\Mvc\Model\Relation\CacheKeyProvider;
use Phalcon\Mvc\Model;

class Customers extends Model implements CacheKeyProvider
{
    public function getUniqueKey(): string
    {
        return 'customer:' . $this->cst_id;
    }
}

The return value of getUniqueKey() replaces the object-identity key when looking up the reusable cache, so different PHP instances of the same logical row will hit the same entry. See Caching for further details.

Accessing

There are several ways that we can access the relationships of a model.

  • Magic __get, __set
  • Magic get*
  • getRelated

__get()

You can use the magic method to access the relationship. Assigning an alias to the relationship simplifies accessing the related data. The name of the property is the same as the one defined in the alias.

<?php

$customer = Customers::findFirst(
    [
        'conditions' => 'cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

foreach ($customer->invoices as $invoice) {
    echo $invoice->inv_title;
}

or for a many-to-many relationship (see models above):

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

foreach ($invoice->invoicesProducts as $product) {
    echo $invoice->product->prd_name;
}

foreach ($invoice->products as $product) {
    echo $invoice->prd_name;
}

Using the magic __get allows you to access the relationship directly but does not offer additional functionality such as filtering or ordering on the relationship.

get*()

You can access the same relationship by using a getter method, starting with get and using the name of the relationship.

<?php

$customer = Customers::findFirst(
    [
        'conditions' => 'cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

foreach ($customer->getInvoices() as $invoice) {
    echo $invoice->inv_title;
}

or for a many-to-many relationship (see models above):

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

foreach ($invoice->getInvoiceProducts() as $product) {
    echo $invoice->product->prd_name;
}

foreach ($invoice->getProducts() as $product) {
    echo $invoice->prd_name;
}

This magic-getter also allows us to perform certain operations when accessing the relationship such as ordering the relationship:

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

$products = $invoice->getProducts(
    [
        'order' => 'prd_name',
    ]
);
foreach ($products as $product) {
    echo $invoice->prd_name;
}

You can also add additional conditionals to the relationship:

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

$products = $invoice->getProducts(
    [
        'prd_created_at = :date:',
        'bind' => [
            'date' => '2019-12-25',
        ],
    ]
);

foreach ($products as $product) {
    echo $invoice->prd_name;
}

To get the same records manually:

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);


$invoicesProducts = InvoicesProducts::find(
    [
        'conditions' => 'ixp_inv_id = :invoiceId:',
        'bind'       => [
            'invoiceId' => $invoice->inv_id,
        ],
    ]
);

$productIds = [];
foreach ($invoicesProducts as $intermediate) {
    $productIds[] = $intermediate->ixp_prd_id;
}

$products = Products::find(
    [
        'conditions' => 'prd_id IN ({array:productIds})',
        'bind'       => [
            'productIds' => $productIds,
        ],
    ]
);

foreach ($products as $product) {
    echo $invoice->prd_name;
}

The prefix get is used to find()/findFirst() related records.

Type Implicit Method Returns
Belongs-To findFirst Model instance of the related record directly
Has-One findFirst Model instance of the related record directly
Has-One-Through findFirst Model instance of the related record directly
Has-Many find Collection of model instances of the referenced model
Has-Many-to-Many (complex query) Collection of model instances of the referenced model (inner join)

You can also use the count prefix to return an integer denoting the count of the related records:

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

echo $invoice->countProducts();

getRelated()

You can access the same relationship by using getRelated() and defining which relationship you want to get.

<?php

$customer = Customers::findFirst(
    [
        'conditions' => 'cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

foreach ($customer->getRelated('invoices') as $invoice) {
    echo $invoice->inv_title;
}

or for a many-to-many relationship (see models above):

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

foreach ($invoice->getRelated('products') as $product) {
    echo $invoice->prd_name;
}

The second parameter of getRelated() is an array that offers additional options to be set such as filtering and ordering.

<?php

$invoice = Invoices::findFirst(
    [
        'conditions' => 'inv_cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

$products = $invoice->getRelated(
    'products',
    [
        'prd_created_at = :date:',
        'bind' => [
            'date' => '2019-12-25',
        ],
    ]
);

foreach ($products as $product) {
    echo $invoice->prd_name;
}

setRelated()

setRelated() is the counterpart of getRelated(). It stores records in the relation cache, so that a later read of that relation returns them without querying the database.

public function setRelated(
    string $alias, 
    mixed $records
): ModelInterface

The alias is the relation name and is not case sensitive. The records can be a model, a resultset, or null for a to-one relation with no match. The model is returned, so calls can be chained.

<?php

use MyApp\Models\Customers;
use MyApp\Models\Invoices;

$customer = Customers::findFirst(
    [
        'conditions' => 'cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ],
    ]
);

$invoices = Invoices::find('inv_cst_id = 1');

$customer->setRelated('invoices', $invoices);

// answered from the cache, no query
$customer->getRelated('invoices');

After the call, isRelationshipLoaded() reports the relation as loaded:

<?php

use MyApp\Models\Customers;

$customer = Customers::findFirst('cst_id = 1');

$customer->isRelationshipLoaded('invoices'); // false

$customer->setRelated('invoices', []);

$customer->isRelationshipLoaded('invoices'); // true

WARNING

setRelated() populates the read cache only. It does not mark the record as having unsaved related records, so a following save() will not persist them.

To save a record together with its related records, assign them as a magic property instead. That path stages the records and save() writes them:

<?php

use MyApp\Models\Customers;
use MyApp\Models\Invoices;

$customer = Customers::findFirst('cst_id = 1');

$invoice = new Invoices();

$invoice->inv_title = 'Invoice for ACME Inc.';
$invoice->inv_total = 100;

$customer->invoices = [$invoice];

$customer->save();

See Save for the full description of that behavior.

setRelated() is the same mechanism Eager Loading uses to attach pre-loaded records to each returned model.

Aliases

Accessing a relationship can be achieved by using the name of the remote table. Due to naming conventions, this might not be that clear and could lead to confusion. As seen above, you can define an alias to the relationship.

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasManyToMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            'ixp_prd_id',
            Products::class,
            'prd_id'
        );
    }
}

With an alias:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasManyToMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'reusable' => true,
                'alias'    => 'products',
            ]
        );
    }
}

If your table structure has self-joins, you will not be able to access those relationships without aliases because you will be using the same model.

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Parts extends Model
{
    public $par_id;
    public $par_prd_id;
    public $par_par_id;
    public $par_type_flag;
    public $par_name;

    public function initialize()
    {
        $this->hasMany(
            'par_id',
            Parts::class,
            'par_par_id',
            [
                'reusable' => true,
                'alias'    => 'children',
            ]
        );

        $this->belongsTo(
            'par_par_id',
            Parts::class,
            'par_id',
            [
                'reusable' => true,
                'alias'    => 'parent',
            ]
        );
    }
}

In the example above, we have a Part that has a relationship with one or more Part objects. Each Part can consist of other parts that construct it. As a result, we end up with a self-join relationship. For a telephone Part we have the following children:

<?php

$phone = Parts::findFirst(....);

echo $phone->getChildren();

// --- Cover
// --- Battery
// --- Charger

and each of those parts has the telephone as a parent:

<?php
$charger = Parts::findFirst(...);

echo $phone->getParent();

// Phone

Caching

Accessing related data can significantly increase the number of queries in your database. You can reduce this load as much as possible, by utilizing the reusable option in your relationship. Setting this option to true will instruct Phalcon to cache the results of the relationship the first time it is accessed, so that subsequent calls to the same relationship can use the cached resultset and not request the data again from the database. This cache is active during the same request.

NOTE

You are encouraged to use the reusable option as often as possible in your relationships

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public function initialize()
    {
        $this->hasOne(
            'inv_cst_id',
            Customers::class,
            'cst_id',
            [
                'alias'    => 'customers',
                'reusable' => true,
            ]
        );
    }
}

Eager Loading

Eager loading pre-loads a relation for every record in a resultset, using one query for the whole set instead of one query per record.

Accessing a relation inside a loop issues a query on each iteration. Reading 500 invoices and their customers costs 501 queries:

<?php

use MyApp\Models\Invoices;

$invoices = Invoices::find('inv_total > 100');

foreach ($invoices as $invoice) {
    echo $invoice->customer->cst_name_last;
}

The reusable option described above does not solve this. It collapses repeated access to the same key, but it never combines different keys into a single query.

Pass the eager parameter to find() to load the relation for the whole resultset at once. The cost becomes two queries, and stays at two no matter how many invoices are returned:

<?php

use MyApp\Models\Invoices;

$invoices = Invoices::find(
    [
        'conditions' => 'inv_total > :total:',
        'bind'       => [
            'total' => 100,
        ],
        'eager'      => ['customer'],
    ]
);

foreach ($invoices as $invoice) {
    echo $invoice->customer->cst_name_last;
}

The records are loaded into the same cache that getRelated() reads, so $invoice->customer, $invoice->getCustomer() and $invoice->getRelated('customer') all return the pre-loaded record without querying.

The value of eager is always an array. A string throws Phalcon\Mvc\Model\Exceptions\InvalidEagerParameter.

Supported Relations

  • belongsTo, hasOne and hasMany cost one query each
  • hasOneThrough and hasManyToMany cost two queries each - one for the intermediate table, one for the referenced records
  • Composite keys are supported for all relation types

Through-relations are loaded without a join, so parent records are never multiplied and the result is identical to the lazy path.

A to-one relation with no matching record resolves to null. A to-many relation with no matching records resolves to an empty resultset, so it can be iterated without a guard.

Nested Relations

Relations of relations are loaded with a dot-delimited path:

<?php

use MyApp\Models\Invoices;

$invoices = Invoices::find(
    [
        'eager' => ['customer.country'],
    ]
);

foreach ($invoices as $invoice) {
    echo $invoice->customer->country->cnt_name;
}

A path implies each of its prefixes, and prefixes are merged. Both of the following cost the same two queries - one for customers, one for countries:

<?php

'eager' => ['customer.country'];
'eager' => ['customer', 'customer.country'];

The number of queries follows the number of distinct relations named, not the number of paths supplied. Loading ['customer.country', 'customer.address'] costs three queries: customers are fetched once and shared by both branches.

Paths are limited to five segments. A longer path throws Phalcon\Mvc\Model\Exceptions\InvalidEagerPath, as does an empty segment such as customer..country.

Relation Options

An element can be written as path => options to narrow what the relation loads. The accepted options are columns, conditions, bind, bindTypes and order.

<?php

use MyApp\Models\Customers;

$customers = Customers::find(
    [
        'eager' => [
            'invoices' => [
                'conditions' => 'inv_status_flag = :status:',
                'bind'       => [
                    'status' => 1,
                ],
                'order'      => 'inv_created_at DESC',
            ],
        ],
    ]
);

Conditions declared on the relation itself - the params option of hasMany(), belongsTo() and the rest - are always applied, whether or not the relation is loaded eagerly. When both are present the two are combined with AND; the relation's own conditions are not replaced.

limit and offset are rejected with Phalcon\Mvc\Model\Exceptions\UnsupportedEagerOption. A limit applied to a single query for the whole set would return that many records in total rather than that many per parent, which is not what the option would appear to mean.

Selecting Columns

Restricting a relation to a subset of columns returns Phalcon\Mvc\Model\Row objects for that relation, exactly as columns does on find():

<?php

use MyApp\Models\Invoices;

$invoices = Invoices::find(
    [
        'eager' => [
            'customer' => [
                'columns' => 'cst_id, cst_name_last',
            ],
        ],
    ]
);

foreach ($invoices as $invoice) {
    // $invoice is a model; $invoice->customer is a Row
    echo $invoice->customer->cst_name_last;
}

A Row carries only the selected columns. It is not a model, so it has no save(), no snapshots and none of the methods defined on the related model.

The relation's referenced field must appear in the column list, because the returned records are matched back to their parents by that field. Omitting it throws Phalcon\Mvc\Model\Exceptions\MissingEagerKeyColumn. The same applies to the local field when the parent query itself uses columns.

Hydration

Eager loading requires the default hydration mode, Resultset::HYDRATE_RECORDS. Arrays and standard objects have no relation cache to populate, so combining eager with HYDRATE_ARRAYS or HYDRATE_OBJECTS throws Phalcon\Mvc\Model\Exceptions\UnsupportedEagerHydration.

Criteria

The same parameter is available on the criteria returned by query():

<?php

use MyApp\Models\Invoices;

$invoices = Invoices::query()
    ->eager(['customer'])
    ->where('inv_total > 100')
    ->execute();

Phalcon\Mvc\Model\Criteria::eager() records the paths and execute() forwards them to find(), so the behavior is identical.

eager() is defined on Phalcon\Mvc\Model\Criteria and not on Phalcon\Mvc\Model\CriteriaInterface, because adding a method to the interface would break every existing implementation of it. Phalcon\Mvc\Model::query() declares CriteriaInterface as its return type, so a static analyzer reports the call as undefined even though it resolves at runtime. Call eager() first, as above, and annotate the variable if your analysis requires it:

<?php

use MyApp\Models\Invoices;
use Phalcon\Mvc\Model\Criteria;

/** @var Criteria $criteria */
$criteria = Invoices::query();

$invoices = $criteria
    ->eager(['customer'])
    ->where('inv_total > 100')
    ->execute();

Errors

An unknown relation alias is reported before any query runs, with Phalcon\Mvc\Model\Exceptions\UnknownEagerRelation naming the model and the alias. Every other failure listed above is also raised as an exception rather than silently loading nothing, so a mistake in the specification is visible immediately.

Autocompletion

Most IDEs and editors with auto-completion capabilities can not detect the correct types when using magic getters (both methods and properties). To address this issue, you can use the class docblock that specifies what magic actions are available, helping the IDE to produce a better auto-completion:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

/**
 * Invoices model
 *
 * @property Simple|Products[] $products
 * @method   Simple|Products[] getProducts($parameters = null)
 * @method   integer           countProducts()
 */
class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasManyToMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'reusable' => true,
                'alias'    => 'products',
            ]
        );
    }
}

Conditionals

You can also create relationships based on conditionals. When querying based on the relationship the condition will be automatically appended to the query:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasManyToMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'reusable' => true,
                'alias'    => 'products',
            ]
        );
    }
}

class Companies extends Model
{
    public function initialize()
    {
        $this->hasMany(
            'id',
            Invoices::class,
            'inv_id',
            [
                'alias' => 'Invoices',
            ]
        );

        $this->hasMany(
            'id',
            Invoices::class,
            'inv_id',
            [
                'alias'  => 'InvoicesPaid',
                'params' => [
                    'conditions' => "inv_status = 'paid'",
                ],
            ]
        );

        $this->hasMany(
            'id',
            Invoices::class,
            'inv_id',
            [
                'alias'  => 'InvoicesUnpaid',
                'params' => [
                    'conditions' => "inv_status <> :status:",
                    'bind'       => [
                        'status' => 'unpaid',
                    ],
                ],
            ]
        );
    }
}

Additionally, you can use the parameters of getInvoices() or getRelated() on the model, to further filter or order your relationship:

<?php

$company = Companies::findFirst(
    [
        'conditions' => 'id = :id:',
        'bind'       => [
            'id' => 1,
        ],
    ]
);
*
$unpaidInvoices = $company->InvoicesUnpaid;
$unpaidInvoices = $company->getInvoicesUnpaid();
$unpaidInvoices = $company->getRelated('InvoicesUnpaid');
$unpaidInvoices = $company->getRelated(
    'Invoices', 
    [
        'conditions' => "inv_status = 'paid'",
    ]
);

$unpaidInvoices = $company->getRelated(
    'Invoices', 
    [
        'conditions' => "inv_status = 'paid'",
        'order'      => 'inv_created_date ASC',
    ]
);

Virtual Foreign Keys

By default, relationships do not have any constraints attached to them, to check related data when adding, updating, or deleting records. You can however attach validations to your relationships, to ensure the integrity of data. This can be done with the last parameter of the relationship-related method.

The cross table InvoicesProducts can be slightly changed to demonstrate this functionality:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class InvoicesProducts extends Model
{
    public $ixp_inv_id;
    public $ixp_prd_id;

    public function initialize()
    {
        $this->belongsTo(
            'ixp_inv_id',
            Invoices::class,
            'inv_id',
            [
                'alias'      => 'invoice',
                'foreignKey' => true,
                'reusable'   => true,
            ]
        );

        $this->belongsTo(
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'alias'      => 'product',
                'foreignKey' => [
                    'message' => 'The prd_id does not exist ' .
                                 'in the Products model',
                ],
                'reusable'   => true,
            ]
        );
    }
}

If you alter a belongsTo() relationship to act as foreign key, it will validate that the values inserted/updated on those fields have reference valid ids in the respective models. Similarly, if a hasMany()/hasOne() is changed to define the foreignKey, it will validate that records can or cannot if the record has related data.

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Products extends Model
{
    public $prd_id;
    public $prd_title;
    public $prd_price;
    public $prd_created_at;

    public function initialize()
    {
        $this->hasMany(
            'prd_id',
            Products::class,
            'ixp_prd_id',
            [
                'foreignKey' => [
                    'message' => 'The product cannot be deleted ' . 
                                 'because there are invoices ' .
                                 'attached to it',
                ],
            ]
        );
    }
}

A virtual foreign key can be set up to allow null values as follows:

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class InvoicesProducts extends Model
{
    public $ixp_inv_id;
    public $ixp_prd_id;

    public function initialize()
    {
        $this->belongsTo(
            'ixp_inv_id',
            Invoices::class,
            'inv_id',
            [
                'alias'      => 'invoice',
                'foreignKey' => true,
                'reusable'   => true,
            ]
        );

        $this->belongsTo(
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'alias'      => 'product',
                'foreignKey' => [
                    'allowNulls' => true,
                    'message'    => 'The prd_id does not exist ' .
                                    'in the Products model',
                ],
                'reusable'   => true,
            ]
        );
    }
}

Cascade/Restrict

Relationships that act as virtual foreign keys by default restrict the creation/update/deletion of records to maintain the integrity of data. You can define these constraints that mimic the RDBMS functionality for CASCADE and RESTRICT by using the action option in foreignKey. The Phalcon\Mvc\Model\Relation underlying object offers two constants:

  • Relation::ACTION_CASCADE
  • Relation::ACTION_RESTRICT
<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;
use Phalcon\Mvc\Model\Relation;

class Products extends Model
{
    public $prd_id;
    public $prd_title;
    public $prd_price;
    public $prd_created_at;

    public function initialize()
    {
        $this->hasMany(
            'prd_id',
            Products::class,
            'ixp_prd_id',
            [
                'foreignKey' => [
                    'action' => Relation::ACTION_CASCADE,
                ],
            ]
        );
    }
}

The code above allows you to delete all the related records if the primary record is deleted (cascade delete).

Operations

You can perform operations using relationships if a resultset returns complete objects.

Save

Magic properties can be used to store a record and its related properties:

<?php

$artist = new Artists();

$artist->name    = 'Shinichi Osawa';
$artist->country = 'Japan';

$album = new Albums();

$album->name   = 'The One';
$album->artist = $artist;
$album->year   = 2008;

$album->save();

Saving a record and its related records in a has-many relation:

<?php

$customer = Customers::findFirst(
    [
        'conditions' => 'cst_id = :customerId:',
        'bind'       => [
            'customerId' => 1,
        ]
    ]
);

$invoice1 = new Invoices();
$invoice1-> inv_status_flag = 0;
$invoice1-> inv_title       = 'Invoice for ACME Inc. #1';
$invoice1-> inv_total       = 100;
$invoice1-> inv_created_at  = time();

$invoice2 = new Invoices();
$invoice2-> inv_status_flag = 0;
$invoice2-> inv_title       = 'Invoice for ACME Inc. #2';
$invoice2-> inv_total       = 200;
$invoice2-> inv_created_at  = time();


$customer->invoices = [
    $invoice1,
    $invoice2
];


$customer->save();

The code above gets a customer from our database. Two invoices are created and assigned to the invoices relationship of the customer as an array. The customer record is then saved, which also saves the two invoices in the database and links them to the customer.

Although the syntax above is very handy, it is not always ideal to use it, especially when updating related records. Phalcon does not know which records need to be added or removed using an update, and as a result, it will perform a replacement. In update situations, it is better to control the data yourself vs. leaving it to the framework to do that.

Saving data with the above syntax will implicitly create a transaction and commit it if all goes well. Messages generated during the save process of the whole transaction will be passed back to the user for more information.

WARNING

Adding related entities by overloading the following methods/events is not possible:

  • Phalcon\Mvc\Model::beforeSave()
  • Phalcon\Mvc\Model::beforeCreate()
  • Phalcon\Mvc\Model::beforeUpdate()

You need to overload Phalcon\Mvc\Model::save() for this to work from within a model.

Synchronizing Many-to-Many Records

Assigning an array to a many-to-many relationship and calling save() is additive by default. Records in the array are inserted or kept. Records that were linked before but are absent from the array stay linked. Synchronization changes this: the assigned array becomes the complete set of related records. Links missing from the array are deleted, new links are created, and existing links are kept.

  • Synchronization applies to hasManyToMany() relationships only.
  • It operates on the intermediate (pivot) table and behaves identically on MySQL, PostgreSQL, and SQLite.
  • The default additive behavior is unchanged unless you opt in.

Enable it on the relationship definition with the sync option. Every save of that relationship then synchronizes.

<?php

namespace MyApp\Models;

use Phalcon\Mvc\Model;

class Invoices extends Model
{
    public $inv_id;
    public $inv_cst_id;
    public $inv_status_flag;
    public $inv_title;
    public $inv_total;
    public $inv_created_at;

    public function initialize()
    {
        $this->hasManyToMany(
            'inv_id',
            InvoicesProducts::class,
            'ixp_inv_id',
            'ixp_prd_id',
            Products::class,
            'prd_id',
            [
                'alias' => 'products',
                'sync'  => true,
            ]
        );
    }
}

With the option enabled, the assigned array is authoritative on every save:

<?php

$invoice = Invoices::findFirst(1);

// Link invoice 1 to product 1 and product 2
$invoice->products = [$product1, $product2];
$invoice->save();

// product2 is dropped from the array
$invoice->products = [$product1, $product3];
$invoice->save();

// invoice 1 is now linked to product 1 and product 3 only.
// The link to product2 is deleted. product2 itself is not deleted.

Assigning an empty array removes every link for that relationship:

<?php

$invoice = Invoices::findFirst(1);

$invoice->products = [];
$invoice->save();

// All intermediate rows for invoice 1 are removed.

To synchronize without changing the relationship definition, use Phalcon\Mvc\Model::setSync(). It enables or disables synchronization for the next save() only, and the flag is cleared once the save completes. The method returns the model instance, so it chains before save().

setSync() accepts:

  • no argument or "*" - every many-to-many relationship on the model
  • a single alias string - that one relationship
  • an array of alias strings - those relationships
  • an optional second argument (bool, default true) - pass false to disable synchronization instead of enabling it

A per-save call takes precedence over the relationship's sync option for that save.

<?php

$invoice = Invoices::findFirst(1);

// Synchronize only the "products" relationship on this save
$invoice->products = [$product1];
$invoice->setSync('products')->save();

// Synchronize every many-to-many relationship on this save
$invoice->setSync()->save();

// Synchronize a specific set of relationships on this save
$invoice->setSync(['products', 'categories'])->save();

The second argument disables synchronization for a single save. This keeps a save additive even when the relationship is defined with 'sync' => true:

<?php

$invoice = Invoices::findFirst(1);

// "*" targets every relationship; false keeps this save additive
$invoice->products = [$product1];
$invoice->setSync('*', false)->save();

// Disable synchronization for specific relationships on this save
$invoice->setSync(['products', 'categories'], false)->save();

Synchronization runs inside the same implicit transaction as the rest of the save. If a delete fails, the transaction is rolled back and the messages are available through Phalcon\Mvc\Model::getMessages().

Update

Instead of doing this:

<?php

$invoices = $customer->getInvoices();

foreach ($invoices as $invoice) {
    $invoice->inv_total      = 100;
    $invoice->inv_updated_at = time();

    if (false === $invoice->update()) {
        $messages = $invoice->getMessages();

        foreach ($messages as $message) {
            echo $message;
        }

        break;
    }
}

you can do this:

<?php

$customer->getInvoices()->update(
    [
        'inv_total'      => 100,
        'inv_updated_at' => time(),
    ]
);

update also accepts an anonymous function to filter what records must be updated:

<?php

$data = [
    'inv_total'      => 100,
    'inv_updated_at' => time(),
];

$customer->getInvoices()->update(
    $data,
    function ($invoice) {
        return ($invoice->inv_cst_id !== 1);
    }
);

Delete

Instead of doing this:

<?php

$invoices = $customer->getInvoices();

foreach ($invoices as $invoice) {
    if (false === $invoice->delete()) {
        $messages = $invoice->getMessages();

        foreach ($messages as $message) {
            echo $message;
        }

        break;
    }
}

you can do this:

<?php

$customer->getInvoices()->delete();

delete() also accepts an anonymous function to filter what records must be deleted:

WARNING

delete() only works safely with hasMany() relationships. The deletion callback runs before the actual deletion of the parent model.

This makes it safe for:

  • Deleting child models that hold a foreign key to the parent (e.g., hasMany)

But unsafe for:

  • Deleting related models that the parent depends on via a foreign key (e.g., belongsTo or hasOne where FK is in parent)
<?php

$customer->getInvoices()->delete(
    function ($invoice) {
        return ($invoice->inv_total >= 0);
    }
);

Messages

You can append messages from another model.

<?php

$invoices = $customer->getInvoices();

foreach ($invoices as $invoice) {
    if ( false === $invoice->save() ) {
        $customer->appendMessagesFrom($invoice);
    }
}
$messages = $customer->getMessages();
foreach ($messages as $message) {
    echo $message;
    $metaData = $message->getMetadata();
    if ( true === isset($metaData['model']) ) {
        echo $metaData['model'];
    }
}

For better error reporting you can retrieve the name of the Model and Reference Model from the Message MetaData:

<?php

$invoices = $customer->getInvoices();
if ( false === $customer->save() ) {
    $messages = $customer->getMessages();
    foreach ($messages as $message) {
        echo $message;
        $metaData = $message->getMetadata();
        if ( true === isset($metaData['model']) ) {
            echo $metaData['model'];
        }
        if ( true === isset($metaData['referenceModel']) ) {
            echo $metaData['referenceModel'];
        }
    }
}