Models And Eager Loading
Phalcon Kit models build on Phalcon\Mvc\Model and add generated model layers, relationship-aware assignment, model behaviors, and batch eager loading.
Official Phalcon references:
- Models: https://docs.phalcon.io/latest/db-models/
- Relationships: https://docs.phalcon.io/latest/db-models-relationships/
- Behaviors: https://docs.phalcon.io/latest/db-models-behaviors/
- Model validation: https://docs.phalcon.io/latest/db-models-validation/
Choose the loading strategy from intent:
| Need | Use |
|---|---|
| One relation on one already-loaded model | Normal relation access |
| A known graph for a list or detail operation | findWith() / findFirstWith() |
| Native Phalcon criteria composition | Native eager-loading parameters |
| A stable public nested representation | Eager loading plus a transformer |
Avoid loading relationships inside a loop. Decide the graph before executing the root query.
Generated And Concrete Layers
Generated abstract models carry schema knowledge:
- properties and comments
- getters and setters
- column maps
- default relationships
- default validations
- generated interfaces
- enum classes where supported by the database
Concrete models carry application behavior:
<?php
namespace App\Models;
final class Project extends Abstracts\ProjectAbstract
{
public function isOpen(): bool
{
return !$this->isDeleted() && $this->getStatus() === 'open';
}
}
When the schema changes, regenerate the abstract layer and review concrete models for new domain rules.
Relationship Payloads
Generated relationship aliases are used by REST save payloads and eager loading. Typical alias shapes are:
UserEntityfor a single related model.UserListfor a one-to-many or many-to-many list.UserNodefor join/node-table records.
Controllers can allow relation writes through nested initializeSaveFields() configuration:
$this->setSaveFields([
'label',
'usernode' => [
'userId',
'type',
'deleted',
],
]);
Keep relation payloads explicit. Do not expose every nested field just because a relationship exists.
Strict Relationship Assignment
Relationship assignment is permissive by default for backward compatibility. assignRelated() receives the full model payload before Phalcon assigns scalar columns, so unknown scalar keys must still pass through native model assignment.
Enable strict relationship assignment on models or resource flows where the payload has already been normalized and relation aliases are expected to be exact:
$project->setStrictRelatedAssignment(true);
$project->assign([
'label' => 'Portal',
'UserNode' => [
['userId' => 10, 'type' => 'owner'],
],
], [
'label',
'UserNode' => ['userId', 'type'],
]);
When strict mode is enabled, Phalcon Kit throws a scoped exception for relationship-specific mistakes:
- a real relation alias is blocked by the assignment whitelist
- an unknown complex payload looks like a relation but is not a mapped model column
- a known relation receives an unsupported value or list item
Strict mode also follows nested relation assignment. If a parent relation payload creates or updates a related Phalcon Kit model, that child receives the same strict setting before its own nested assign() call runs.
Strict relationship assignment does not replace column validation, model validation, or REST save-field policies. It is a guard for nested relation payloads, not a general "reject every unknown scalar field" mode.
Relationship Save Options
Direct hasOne and hasMany saves keep legacy behavior by default: submitted children can be created or updated, missing children can still be deleted when keepMissingRelated is false, and soft-deleted direct children are not restored automatically.
Applications that need stricter direct-child ownership can set relationship defaults in bootstrap config under model.relationship:
'model' => [
'relationship' => [
'enforceDirectOwnership' => true,
'allowUnownedDirectRelationAdoption' => false,
'autoRestoreDirectRelations' => false,
],
],
The same defaults can be controlled with environment variables:
MODEL_RELATIONSHIP_ENFORCE_DIRECT_OWNERSHIPMODEL_RELATIONSHIP_ALLOW_UNOWNED_DIRECT_RELATION_ADOPTIONMODEL_RELATIONSHIP_AUTO_RESTORE_DIRECT_RELATIONS
enforceDirectOwnership rejects direct child records that already point to a different parent before the save process rewrites their foreign key. When that guard is enabled, allowUnownedDirectRelationAdoption controls whether existing children with empty relationship keys may be attached to the current parent.
autoRestoreDirectRelations only restores soft-deleted direct children that already belong to the current parent. Many-to-many through relations keep their existing intermediate-node restore behavior.
Eager Loading
Use eager loading when a response or workflow needs related data. This avoids lazy-loading loops and keeps relation graphs visible at the query boundary.
Avoid this pattern in list endpoints:
$projects = Project::find(['limit' => 25]);
foreach ($projects as $project) {
foreach ($project->getUserNode() as $userNode) {
$user = $userNode->getUserEntity();
}
}
Each relation access can trigger more database work. Load the graph once:
Phalcon 5.18 provides native eager loading for standard relationship graphs:
$projects = Project::find([
'conditions' => 'deleted <> 1',
'eager' => [
'UserNode.UserEntity',
'CategoryList',
],
]);
Native eager loading stores relations through Model::setRelated(). Phalcon Kit mirrors that native cache into its read-only loaded-relation cache, so property access, getRelated(), relatedToArray(), and isRelationshipLoaded() see the same values.
Use Phalcon Kit's eager-loading API when relation-level closures, its controller initializeWith() convention, or its established array return shape is required:
Model-level examples:
$projects = Project::findWith([
'UserNode.UserEntity',
'CategoryList',
], [
'conditions' => 'deleted <> 1',
]);
$project = Project::findFirstWith([
'UserNode.UserEntity',
], [
'conditions' => 'id = :id:',
'bind' => ['id' => $id],
]);
Controller-level examples:
public function initializeWith(): void
{
$this->setWith([
'UserNode.UserEntity',
'CategoryList',
]);
}
Use relation-level query builders when a relation needs extra constraints, ordering, or limits. Keep expensive relation graphs out of list requests unless the UI really needs them. Choose one eager-loading surface per query; do not send the same graph through both native eager parameters and findWith().
List vs Detail Graphs
Use smaller graphs for list screens and richer graphs for detail screens:
final class ProjectReadService
{
public function listOpenProjects(): array
{
return Project::findWith([
'UserNode.UserEntity',
], [
'conditions' => 'status = :status: AND deleted <> 1',
'bind' => ['status' => 'active'],
'limit' => 50,
'order' => 'id DESC',
]);
}
public function getProjectDetail(int $id): ?Project
{
return Project::findFirstWith([
'UserNode.UserEntity',
'CategoryList',
'ExclusionReasonList',
], [
'conditions' => 'id = :id: AND deleted <> 1',
'bind' => ['id' => $id],
]);
}
}
The important part is not the service class; it is the explicit relation graph at the query boundary.
Custom Relationship Override
If the scaffolder cannot infer the business alias you want, add it in the concrete model after the generated default relationships:
<?php
namespace App\Models;
final class Project extends Abstracts\ProjectAbstract
{
public function initialize(): void
{
parent::initialize();
$this->hasMany(
'id',
ProjectUser::class,
'projectId',
['alias' => 'ActiveUserNode']
);
}
}
Keep the generated relationship intact when existing controllers or transformers depend on it. Add the new alias for the new use case.
Model Behaviors
Phalcon Kit model traits and behaviors cover common persistence rules:
- UUIDs and UUIDv7 identifiers.
- Soft delete and restore fields.
- Created, updated, deleted, and restored blameable fields.
- Slug generation.
- Position/order helpers.
- Snapshot and cache support.
- Security checks against identity roles.
- Replication helpers.
Use generated defaults for schema-derived behavior and concrete models for business-specific behavior.
Snapshot Changed Fields
Use getSnapshotChangedFields() when audit logs, domain comparisons, replication decisions, or API response metadata need to know which persisted snapshot values differ from the model's current raw attributes.
The helper complements Phalcon's native getChangedFields() instead of replacing it. Native dirty tracking still controls persistence behavior. getSnapshotChangedFields() returns mapped model field names, accepts snapshots keyed by either database columns or mapped fields, reads current values through readAttribute() rather than domain getters, and falls back to native getChangedFields() only when no snapshot data exists.
$changedFields = $record->getSnapshotChangedFields([
'updatedAt',
'updatedBy',
'updatedAs',
]);
The ignore list accepts either mapped model fields such as updatedAt or database columns such as updated_at. Use it for lifecycle and bookkeeping fields that should not appear in business-facing diffs. Nullable fields follow Phalcon Kit's existing SQL "NULL" string convention, so nullable "NULL" snapshot values compare as null without mutating the snapshot.
Do not use snapshot changed fields as the sole authorization context for sensitive flows such as password reset or privileged account changes. Those flows should pass explicit intent and authorization context through the service or controller layer.
Model Cache Invalidation
The model cache behavior currently uses a coarse invalidation strategy. Create, delete, restore, and reorder events clear the shared modelsCache service because they change record visibility or ordering. Save and update events clear the shared cache when either condition is true:
- the model has no snapshot data, which covers new records and other writes where Phalcon cannot compare an old row snapshot
- the model has snapshot data and Phalcon reports changed or updated fields
Unchanged snapshot-aware saves and updates do not clear the cache. Session and audit models are excluded from the default flush behavior during model initialization so high-volume infrastructure writes do not repeatedly clear application model query caches.
Do not depend on targeted model cache keys yet. A future granular invalidation contract still needs explicit cache-key naming, model whitelist rules, relation invalidation rules, and optional pre-warming semantics before the framework can replace the shared-cache clear safely.
Future Granular Cache Policy
Any future targeted invalidation should be opt-in and policy-driven. The minimum safe contract should define:
- cache-key ownership: whether keys are owned by the model class, repository, controller query, or application service
- key format: a stable namespace, model class/source identity, query signature, identity/permission scope, locale/workspace scope, pagination scope, and a version segment so old keys can be abandoned safely
- whitelist rules: only explicitly opted-in model classes or cache groups can use targeted deletion; all other model writes keep using the coarse shared cache clear
- reverse indexes: cached query keys must be discoverable by model class, relation alias, and affected primary keys before the framework can delete only selected entries
- relation invalidation: parent and child relation caches need explicit rules for belongs-to, has-one, has-many, and through relations
- mutation events: create, update, delete, restore, reorder, and lifecycle tasks must map to the same invalidation contract
- pre-warming: any automatic cache refill must be an application callback or queue job, not an implicit model-event side effect
The migration path should add observation and key registration first, then allow specific models to opt into targeted invalidation. The framework should keep the coarse clear as the fallback whenever a policy is missing, ambiguous, or unable to find the affected cache keys.
Practical Rules
- Treat the database schema as the source of truth for generated model shape.
- Keep custom relationships in concrete models when the scaffolder cannot infer them safely.
- Prefer
findWith()andfindFirstWith()for known relation graphs. - Expect through relations to attach each target model once per parent, even when repeated intermediate rows point to the same target key.
- Use transformers for heavy nested API resources.
- Keep model methods focused on domain behavior, not controller formatting.
Continue with REST APIs to expose relationship graphs safely, Developer Cookbook for focused eager-loading examples, and Database And Scaffolding when aliases or generated metadata do not match the schema.