How to Write a Behavior
Behaviors are a good way to reuse code across models without inheritance — horizontal reuse instead of vertical. This tutorial walks through porting hand-written model code into a real behavior, using a simplified version of the bundled aggregate_column behavior as the running example: keeping a total_nb_votes column on a PollQuestion object up to date every time a related PollAnswer is saved, edited, or deleted.
Read Behaviors first for the basics — what behaviors are, how to enable one, and the full list of hook methods available. This page focuses on building one from the ground up.
Bootstrapping a behavior
Section titled “Bootstrapping a behavior”A behavior is a class that alters the generated classes for a table in your model. It extends Propulsion\Generator\Model\Behavior and implements one or more “hook” methods. Here’s the skeleton to start with for an aggregate_column-style behavior:
<?php
class AggregateColumnBehavior extends \Propulsion\Generator\Model\Behavior{ // default parameter values protected array $parameters = [ 'name' => null, ];}Save this class as AggregateColumnBehavior.php. Propulsion resolves third-party behaviors via Composer, so tell it where to find your class through your package’s composer.json:
- Add a
propulsion.behavior.<name>.classentry, or - Expose an
extra.name/extra.classpair, the same way bundled behaviors register a short name instead of requiring their fully-qualified class name in the schema.
{ "name": "your-name/aggregate-column-behavior", "extra": { "name": "aggregate_column", "class": "\\YourVendor\\PropulsionBehaviors\\AggregateColumn\\AggregateColumnBehavior" }}Then require that package from your project’s composer.json. If you don’t want to publish a package at all, you can skip registration entirely and reference the behavior by its fully-qualified class name directly in the schema — see Using third-party behaviors.
Test the behavior by adding it to a table in your model — for instance, a poll_question table:
<database name="poll" defaultIdMethod="native"> <table name="poll_question" phpName="PollQuestion"> <column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" /> <column name="body" type="varchar" size="100" /> <behavior name="aggregate_column"> <parameter name="name" value="total_nb_votes" /> </behavior> </table></database>Rebuild your model (bin/propulsion model:build) and check the generated PollQuestionTableMap class under the Map/ subdirectory of your build output. It should carry a getBehaviors() method proving the behavior was applied:
<?phpclass PollQuestionTableMap extends TableMap{ // ...
public function getBehaviors(): array { return [ 'aggregate_column' => ['name' => 'total_nb_votes'], ]; }}Adding a column
Section titled “Adding a column”The behavior works, but it doesn’t do anything yet. Make it useful by having it add a column — implement modifyTable():
<?php
class AggregateColumnBehavior extends \Propulsion\Generator\Model\Behavior{ // ...
public function modifyTable(): void { $table = $this->getTable(); $columnName = $this->getParameter('name'); if (!$columnName) { throw new \InvalidArgumentException(sprintf( "You must define a 'name' parameter for the 'aggregate_column' behavior in the '%s' table", $table->getName(), )); }
// add the aggregate column if not already present if (!$table->hasColumn($columnName)) { $table->addColumn([ 'name' => $columnName, 'type' => 'integer', ]); } }}This shows that a behavior has access to the <parameter> elements defined for it in schema.xml through getParameter(), and to the Table object attached to it via getTable(). A Table can check whether a column exists and add a new one. Table is one of many build-time model classes used to describe the object model while generating code, alongside Column, ForeignKey, Index, and others, all found under generator/Lib/Model/ in the Propulsion source.
Rebuild the model and the SQL, and the new column shows up: the generated PollQuestionGenerated trait gets getTotalNbVotes()/setTotalNbVotes() methods, and the generated table-creation SQL includes the new total_nb_votes column:
DROP TABLE IF EXISTS poll_question;CREATE TABLE poll_question( id INTEGER NOT NULL, body VARCHAR(100), total_nb_votes INTEGER, PRIMARY KEY (id));Adding a method to the Active Record class
Section titled “Adding a method to the Active Record class”The previous version relied on an external method updating total_nb_votes by hand. A behavior can add such a method itself by implementing objectMethods():
<?php
use Propulsion\Generator\Builder\OM\ObjectBuilder;
class AggregateColumnBehavior extends \Propulsion\Generator\Model\Behavior{ // ...
public function objectMethods(ObjectBuilder $builder): string { return $this->addUpdateAggregateColumn($builder); }
protected function addUpdateAggregateColumn(ObjectBuilder $builder): string { $sql = sprintf( 'SELECT %s FROM %s WHERE %s = ?', $this->getParameter('expression'), $this->getParameter('foreign_table'), $this->getParameter('foreign_column'), ); $table = $this->getTable(); $aggregateColumn = $table->getColumn($this->getParameter('name')); $columnPhpName = $aggregateColumn->getPhpName(); $localColumn = $table->getColumn($this->getParameter('local_column')); $peerClassname = $builder->getStubPeerBuilder()->getClassname();
return <<<PHP
/** * Updates the aggregate column {$aggregateColumn->getName()} * * @param ?PropulsionPDO \$con A connection object; resolved from the datasource when null */public function update{$columnPhpName}(?PropulsionPDO \$con = null): void{ if (\$con === null) { \$con = Propulsion::getConnection({$peerClassname}::DATABASE_NAME, Propulsion::CONNECTION_WRITE); } \$stmt = \$con->prepare('{$sql}'); \$stmt->execute([\$this->get{$localColumn->getPhpName()}()]); \$this->set{$columnPhpName}(\$stmt->fetchColumn()); \$this->save(\$con);}PHP; }}The Active Record class builder appends the string returned from objectMethods() to the generated class body. Don’t worry about indentation — the builder classes indent whatever a behavior returns. A good rule of thumb is one behavior method per generated method, for readability.
Two details in that signature are conventions worth copying. PropulsionPDO and Propulsion are written unqualified because every generated object file already imports them — the builders resolve a fixed set of runtime short names to their real FQCNs in Propulsion\Connection\, Propulsion\Query\, and so on, so behavior-emitted code can use the short names too. And the connection parameter is nullable with a fallback, exactly as Propulsion\Connection\PropulsionPDO appears in every other generated method that takes one. Requiring a connection here would only push the resolution onto whoever calls the method — and the callers are themselves generated hooks and application code that may not have one to hand — so a behavior that emits a method taking a connection resolves it from the datasource itself when none is passed. Use Propulsion::CONNECTION_WRITE for anything that writes; Propulsion::getReadConnection() is the read-side shorthand.
The schema needs the extra parameters this method relies on:
<database name="poll" defaultIdMethod="native"> <table name="poll_question" phpName="PollQuestion"> <column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" /> <column name="body" type="varchar" size="100" /> <behavior name="aggregate_column"> <parameter name="name" value="total_nb_votes" /> <parameter name="expression" value="count(nb_votes)" /> <parameter name="foreign_table" value="poll_answer" /> <parameter name="foreign_column" value="question_id" /> <parameter name="local_column" value="id" /> </behavior> </table> <table name="poll_answer" phpName="PollAnswer"> <column name="id" required="true" primaryKey="true" autoIncrement="true" type="integer" /> <column name="question_id" required="true" type="integer" /> <column name="body" type="varchar" size="100" /> <column name="nb_votes" type="integer" /> <foreign-key foreignTable="poll_question" onDelete="cascade"> <reference local="question_id" foreign="id" /> </foreign-key> </table></database>Rebuild the model, and the generated PollQuestionGenerated trait — the trait the PollQuestion stub class uses, see Active Record reference — now includes the new updateTotalNbVotes() method:
<?php
use Propulsion\Connection\PropulsionPDO;use Propulsion\Propulsion;
trait PollQuestionGenerated{ // ...
/** * Updates the aggregate column total_nb_votes * * @param ?PropulsionPDO $con A connection object; resolved from the datasource when null */ public function updateTotalNbVotes(?PropulsionPDO $con = null): void { if ($con === null) { $con = Propulsion::getConnection(PollQuestionPeer::DATABASE_NAME, Propulsion::CONNECTION_WRITE); } $stmt = $con->prepare('SELECT count(nb_votes) FROM poll_answer WHERE question_id = ?'); $stmt->execute([$this->getId()]); $this->setTotalNbVotes($stmt->fetchColumn()); $this->save($con); }}Because the generated code lands in a trait that is flattened into PollQuestion, $this inside a behavior-emitted method is the stub class itself — so a method a behavior adds can call the model’s own hand-written methods, and the stub can override a behavior’s method by declaring one with the same name.
Behaviors offer a similar hook to add methods to query classes (queryMethods()), and to add attributes with objectAttributes()/queryAttributes(). See Behaviors: Writing a behavior for the full list of hooks — pre/post save and delete hooks, table-map hooks, and hooks for adding entirely new generated classes.
Using a template for generated code
Section titled “Using a template for generated code”Building method bodies as raw interpolated strings, like addUpdateAggregateColumn() above, gets hard to read fast. Propulsion behaviors can use a simple templating system instead — an external PHP file rendered with a fixed set of variables.
Refactor addUpdateAggregateColumn() to render a template:
<?php
class AggregateColumnBehavior extends \Propulsion\Generator\Model\Behavior{ // ...
protected function addUpdateAggregateColumn(ObjectBuilder $builder): string { $sql = sprintf( 'SELECT %s FROM %s WHERE %s = ?', $this->getParameter('expression'), $this->getParameter('foreign_table'), $this->getParameter('foreign_column'), ); $table = $this->getTable(); $aggregateColumn = $table->getColumn($this->getParameter('name'));
return $this->renderTemplate('objectUpdateAggregate', [ 'aggregateColumn' => $aggregateColumn, 'columnPhpName' => $aggregateColumn->getPhpName(), 'localColumn' => $table->getColumn($this->getParameter('local_column')), 'peerClassname' => $builder->getStubPeerBuilder()->getClassname(), 'sql' => $sql, ]); }}The method now returns a rendered template rather than a hand-built string. Propulsion templates are plain PHP files executed in a sandbox, with access only to the variables passed as the second argument to renderTemplate().
Create a templates/ directory next to the AggregateColumnBehavior class file, and add objectUpdateAggregate.php:
/** * Updates the aggregate column <?php echo $aggregateColumn->getName() ?> * * @param ?PropulsionPDO $con A connection object; resolved from the datasource when null */public function update<?php echo $columnPhpName ?>(?PropulsionPDO $con = null): void{ if ($con === null) { $con = Propulsion::getConnection(<?php echo $peerClassname ?>::DATABASE_NAME, Propulsion::CONNECTION_WRITE); } $stmt = $con->prepare('<?php echo $sql ?>'); $stmt->execute([$this->get<?php echo $localColumn->getPhpName() ?>()]); $this->set<?php echo $columnPhpName ?>($stmt->fetchColumn()); $this->save($con);}No need to escape dollar signs — this separation is much cleaner for larger behaviors, and it’s exactly the pattern the bundled aggregate_column behavior itself uses under generator/Lib/Behavior/AggregateColumn/templates/ in the Propulsion source.
Adding another behavior from a behavior
Section titled “Adding another behavior from a behavior”This is where it gets trickier. The updateTotalNbVotes() calls in a real implementation need to run from the postSave()/postDelete() hooks of PollAnswer, not PollQuestion — but the behavior above is registered on poll_question. How can it modify code generated for a different table?
It can’t directly. To modify classes built for poll_answer, a behavior has to be registered on the poll_answer table. But a behavior is a first-class part of the build-time model, just like a column or a foreign key — so the trick is to have AggregateColumnBehavior::modifyTable() add a second behavior to the foreign table, one whose job is implementing the postSave()/postDelete() hooks on PollAnswer. This is exactly what the real bundled aggregate_column behavior does — it pairs an AggregateColumnBehavior on the table owning the aggregate column with an internal AggregateColumnRelationBehavior it registers on the foreign table automatically. Read generator/Lib/Behavior/AggregateColumn/AggregateColumnBehavior.php in the Propulsion source for the full implementation once you’re comfortable with the pieces above.