Skip to content

Delegate Behavior

The delegate behavior allows a model to delegate methods to one of its relationships. This helps isolate logic in a dedicated model, or simulate class table inheritance.

In schema.xml, use the <behavior> tag to add the delegate behavior to a table. In its <parameter> tags, specify the table that the current table delegates to as the to parameter:

<table name="account">
<column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" />
<column name="login" type="varchar" required="true" />
<column name="password" type="varchar" required="true" />
<behavior name="delegate">
<parameter name="to" value="profile" />
</behavior>
</table>
<table name="profile">
<column name="email" type="varchar" />
<column name="telephone" type="varchar" />
</table>

Rebuild your model, run the table creation SQL again, and you’re ready to go. The delegate profile table is now related to the account table with a one-to-one relationship — the behavior creates a foreign primary key in the profile table. In fact, this is equivalent to having defined the following schema:

<table name="account">
<column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" />
<column name="login" type="varchar" required="true" />
<column name="password" type="varchar" required="true" />
</table>
<table name="profile">
<column name="id" required="true" primaryKey="true" type="integer" />
<column name="email" type="varchar" />
<column name="telephone" type="varchar" />
<foreign-key foreignTable="account" onDelete="cascade">
<reference local="id" foreign="id" />
</foreign-key>
</table>

In addition, the ActiveRecord Account class now provides integrated delegation capabilities. That means it offers to handle the columns of the Profile model directly, while in reality it finds or creates a related Profile object and calls the methods on that delegate:

$account = new Account();
$account->setLogin('francois');
$account->setPassword('S€cr3t');
// fill the profile via delegation
$account->setEmail('francois@example.com');
$account->setTelephone('202-555-9355');
// same as
$profile = new Profile();
$profile->setEmail('francois@example.com');
$profile->setTelephone('202-555-9355');
$account->setProfile($profile);
// save the account and its profile
$account->save();
// retrieve delegated data directly from the main object
echo $account->getEmail(); // francois@example.com

How delegated methods reach the main object

Section titled “How delegated methods reach the main object”

For every public method the delegate’s own generated trait defines, the behavior emits a real, statically-typed forwarding method on the delegating class — not just a __call() entry. Account genuinely has a getEmail(): ?string, whose body resolves the Profile (creating one if none is attached yet) and calls through to it:

/**
* Delegates to Profile::getEmail().
*/
public function getEmail(): ?string
{
if (!$delegate = $this->getProfile()) {
$delegate = new Profile();
$this->setProfile($delegate);
}
return $delegate->getEmail();
}

That has three consequences worth knowing:

  • IDEs and PHPStan see delegated methods, with the delegate’s own parameter and return types, because they are declared methods rather than something resolved at runtime. Collection return types keep their generic argument (@return PropulsionObjectCollection<Comment>) — the native type alone would lose the element type, so the delegate’s own PHPDoc is restated whenever it adds a generic the bare type lacks.
  • A delegated method can be aliased in a use clause, use ProfileGenerated { getEmail as private generatedGetEmail; }, since there is a real method by that name to alias. Nothing could be aliased when the name only existed inside __call().
  • Name collisions resolve by precedence, not by breaking. A method this table already defines wins over any delegate’s, and when a table delegates to more than one target (to="profile, address"), the first delegate to claim a name blocks the second from redeclaring it. That matters more than it sounds: initRelation() exists on every generated trait, so two unrelated delegates always collide on at least one name.

Two shapes get special handling. A void (or never) method is forwarded as a bare statement, since return $delegate->foo(); isn’t legal against a void return type. A method returning static returns $this rather than the delegate — under late static binding static means an instance of the class the method was called on, so handing back the delegate’s own return value would be a TypeError, and returning $this both satisfies the contract and keeps fluent chaining on the object you called.

__call() is still generated, now as the fallback for hand-written custom methods on the delegate — those live on the concrete class, not in the generated trait the forwarders are derived from, so there is no signature to copy:

class Profile extends BaseObject implements Persistent, Poolable, WritableModelInterface
{
use ProfileGenerated;
public function setFakeEmail(): void
{
$n = random_int(0, PHP_INT_MAX);
$fakeEmail = base_convert((string) $n, 10, 36) . '@example.com';
$this->setEmail($fakeEmail);
}
}
$account = new Account();
$account->setFakeEmail(); // delegates to Profile::setFakeEmail()

Delegating using a many-to-one relationship

Section titled “Delegating using a many-to-one relationship”

Instead of adding a one-to-one relationship, the delegate behavior can take advantage of an existing many-to-one relationship. For instance:

<table name="player">
<column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" />
<column name="first_name" type="varchar" />
<column name="last_name" type="varchar" />
</table>
<table name="basketballer">
<column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" />
<column name="points" type="integer" />
<column name="field_goals" type="integer" />
<column name="three_points_field_goals" type="integer" />
<column name="player_id" type="integer" />
<foreign-key foreignTable="player">
<reference local="player_id" foreign="id" />
</foreign-key>
<behavior name="delegate">
<parameter name="to" value="player" />
</behavior>
</table>

In that case, the behavior doesn’t modify the foreign keys — it just proxies methods called on Basketballer to the related Player, or creates one if it doesn’t exist:

$basketballer = new Basketballer();
$basketballer->setPoints(101);
$basketballer->setFieldGoals(47);
$basketballer->setThreePointsFieldGoals(7);
// set player identity via delegation
$basketballer->setFirstName('Michael');
$basketballer->setLastName('Giordano');
// same as
$player = new Player();
$player->setFirstName('Michael');
$player->setLastName('Giordano');
$basketballer->setPlayer($player);
// save basketballer and player
$basketballer->save();
// retrieve delegated data directly from the main object
echo $basketballer->getFirstName(); // Michael

Since several models can delegate to the same player object, a single player can have both basketball and soccer stats.

Delegation allows delegating to several tables. Just separate the delegate table names with commas in the to parameter of the delegate behavior tag:

<table name="account">
<column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" />
<column name="login" type="varchar" required="true" />
<column name="password" type="varchar" required="true" />
<behavior name="delegate">
<parameter name="to" value="profile, preference" />
</behavior>
</table>
<table name="profile">
<column name="email" type="varchar" />
<column name="telephone" type="varchar" />
</table>
<table name="preference">
<column name="preferred_color" type="varchar" />
<column name="max_size" type="integer" />
</table>

Now the Account class has two delegates, addressable seamlessly:

$account = new Account();
$account->setLogin('francois');
$account->setPassword('S€cr3t');
// fill the profile via delegation
$account->setEmail('francois@example.com');
$account->setTelephone('202-555-9355');
// fill the preference via delegation
$account->setPreferredColor('orange');
$account->setMaxSize('200');
// save the account and its profile and its preference
$account->save();

Delegation does not cascade, though. If the profile table itself delegates to a detail table, Detail’s methods are not reachable from an Account object — only one hop is ever made. That is enforced during generation rather than left to chance: while a delegate’s methods are being collected, that delegate’s own delegate behavior is suppressed, so its forwarded second-hop methods never enter the set Account copies. Reach the second hop explicitly — $account->getProfile()->getDetail().

The delegate behavior takes only one parameter, the list of delegate tables:

<table name="account">
<column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" />
<column name="login" type="varchar" required="true" />
<column name="password" type="varchar" required="true" />
<behavior name="delegate">
<parameter name="to" value="profile, preference" />
</behavior>
</table>

The delegate tables must exist, but they don’t need to share a relationship with the main table — in that case, the behavior creates a one-to-one relationship.