CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

Beyond MVC: Data, Context, and Interaction in CakePHP

This article is part of the CakeDC Advent Calendar 2024 (December 15th 2024)

Introduction to the Creator of DCI

The Data-Context-Interaction (DCI) architectural pattern was introduced by Trygve Reenskaug, a Norwegian computer scientist and software engineer. Reenskaug is well known for his contributions to object-oriented programming and design. He is also famous for the development of the Model-View-Controller (MVC) design pattern, which has become a foundational concept in software architecture, especially in web development.

The Emergence of DCI

Reenskaug introduced the DCI pattern as a way to address some of the limitations he observed in traditional object-oriented programming. The DCI pattern aims to separate the concerns of data (the model), the context in which that data is used (the interaction), and the interactions themselves (the roles that objects play in specific scenarios). This separation allows for more maintainable, understandable, and flexible code, making it easier to adapt to changing business requirements.

Classic Implementation

The classic example used to introduce the DCI pattern is the money transfer scenario. This example show how DCI separates the roles of data, context, and interaction, allowing for a clearer understanding of how objects interact in a system. By modeling the transfer of funds between accounts, we can see how the roles of TransferSource and TransferDestination are defined, encapsulating the behaviors associated with withdrawing and depositing money. This separation enhances code maintainability and readability, making it easier to adapt to changing business requirements.

classDiagram
    class TransferSource {
        +BigDecimal balance
        +updateBalance(newBalance: BigDecimal): Unit
        +withdraw(amount: BigDecimal): Unit
        +canWithdraw(amount: BigDecimal): Boolean
    }

    class TransferDestination {
        +BigDecimal balance
        +updateBalance(newBalance: BigDecimal): Unit
        +deposit(amount: BigDecimal): Unit
    }

    class Account {
        +String id
        +BigDecimal balance
    }

    class MoneyTransfer {
        +Account source
        +Account destination
        +BigDecimal amount
        +execute(): Unit
    }

    Account ..|> TransferSource : implements
    Account ..|> TransferDestination : implements
    MoneyTransfer --> TransferSource : uses
    MoneyTransfer --> TransferDestination : uses

In the money transfer example, we typically have two accounts: a source account from which funds are withdrawn and a destination account where the funds are deposited. The DCI pattern allows us to define the behaviors associated with these roles separately from the data structure of the accounts themselves. This means that the logic for transferring money can be encapsulated in a context, such as a MoneyTransfer class, which orchestrates the interaction between the source and destination accounts. By doing so, we achieve a more modular and flexible design that can easily accommodate future changes or additional features, such as transaction logging or validation rules.

sequenceDiagram
    participant M as Main
    participant S as Source Account
    participant D as Destination Account
    participant MT as MoneyTransfer

    M->>S: new Account("1", 1000) with TransferSource
    M->>D: new Account("2", 500) with TransferDestination
    M->>MT: new MoneyTransfer(source, destination, 100)
    M->>MT: execute()
    MT->>S: canWithdraw(100)
    alt Source can withdraw
        S-->>MT: true
        MT->>S: withdraw(100)
        S->>S: updateBalance(900)
        MT->>D: deposit(100)
        D->>D: updateBalance(600)
    else Source cannot withdraw
        S-->>MT: false
        MT->>M: throw Exception("Source cannot withdraw")
    end

First, I want to show the classic implementation in Scala. By Trygve the language is well suited for this pattern, as traits implementation allow to define the roles and the context in a very clean way and mixins traits into the objects allow explicitely define the roles of the each object.

trait TransferSource {
  def balance: BigDecimal
  def updateBalance(newBalance: BigDecimal): Unit

  def withdraw(amount: BigDecimal): Unit = {
    require(amount > 0, "Amount must be positive")
    require(balance >= amount, "Insufficient funds")

    updateBalance(balance - amount)
  }

  def canWithdraw(amount: BigDecimal): Boolean =
    amount > 0 && balance >= amount
}

trait TransferDestination {
  def balance: BigDecimal
  def updateBalance(newBalance: BigDecimal): Unit

  def deposit(amount: BigDecimal): Unit = {
    require(amount > 0, "Amount must be positive")
    updateBalance(balance + amount)
  }
}

case class Account(id: String, var balance: BigDecimal)

class MoneyTransfer(
    source: Account with TransferSource,
    destination: Account with TransferDestination,
    amount: BigDecimal
) {
  def execute(): Unit = {
    require(source.canWithdraw(amount), "Source cannot withdraw")

    source.withdraw(amount)
    destination.deposit(amount)
  }
}

object Main extends App {
  val source = new Account("1", 1000) with TransferSource
  val dest = new Account("2", 500) with TransferDestination

  val transfer = new MoneyTransfer(source, dest, 100)
  transfer.execute()
}

Basic PHP Implementation

Some languages don't have the same level of flexibility and expressiveness as Scala. Most obvious approach is class wrapper definition for actor roles. I see both pros and cons of this approach. The pros are that it's very easy to understand and implement. The cons are that it's not very flexible and it's not very easy to extend and require additional boilerplate code.

Here is the sequence diagram of the implementation:

sequenceDiagram
    participant MT as MoneyTransfer
    participant S as MoneySource
    participant D as MoneyDestination
    participant Source as Source Account
    participant Destination as Destination Account

    MT->>S: bind(Source)
    S->>Source: validatePlayer(Source)
    alt Player is valid
        S-->>MT: Player bound successfully
    else Player is invalid
        S-->>MT: throw Exception("Player does not meet role requirements")
    end

    MT->>D: bind(Destination)
    D->>Destination: validatePlayer(Destination)
    alt Player is valid
        D-->>MT: Player bound successfully
    else Player is invalid
        D-->>MT: throw Exception("Player does not meet role requirements")
    end

    MT->>S: withdraw(amount)
    S->>Source: getBalance()
    Source-->>S: balance
    alt Insufficient funds
        S-->>MT: throw Exception("Insufficient funds")
    else Sufficient funds
        S->>Source: setBalance(newBalance)
        S-->>MT: Withdrawal successful
    end

    MT->>D: deposit(amount)
    D->>Destination: getBalance()
    Destination-->>D: currentBalance
    D->>Destination: setBalance(newBalance)
    D-->>MT: Deposit successful

    MT->>S: unbind()
    MT->>D: unbind()
  1. First, let's create the Data part (domain objects):
// /src/Model/Entity/Account.php
namespace App\Model\Entity;

use Cake\ORM\Entity;

class Account extends Entity
{
    protected $_accessible = [
        'balance' => true,
        'name' => true
    ];

    protected float $balance;

    public function getBalance(): float
    {
        return $this->get('balance');
    }

    public function setBalance(float $amount): void
    {
        $this->set('balance', $amount);
    }
}
  1. Create Role management classes:
// /src/Context/Contracts/RoleInterface.php
namespace App\Context\Contracts;

interface RoleInterface
{
    public function bind($player): void;
    public function unbind(): void;
    public function getPlayer();
}
// /src/Context/Roles/AbstractRole.php
namespace App\Context\Roles;

use App\Context\Contracts\RoleInterface;

abstract class AbstractRole implements RoleInterface
{
    protected $player;

    public function bind($player): void
    {
        if (!$this->validatePlayer($player)) {
            throw new \InvalidArgumentException('Player does not meet role requirements');
        }
        $this->player = $player;
    }

    public function unbind(): void
    {
        $this->player = null;
    }

    public function getPlayer()
    {
        return $this->player;
    }

    abstract protected function validatePlayer($player): bool;
}
  1. Create roles that define transfer behaviors:
// /src/Context/Roles/MoneySource.php
namespace App\Context\Roles;

use App\Model\Entity\Account;

class MoneySource extends AbstractRole
{
    protected function validatePlayer($player): bool
    {
        return $player instanceof Account
            && method_exists($player, 'getBalance')
            && method_exists($player, 'setBalance');
    }

    public function withdraw(float $amount): void
    {
        $balance = $this->player->getBalance();
        if ($balance < $amount) {
            throw new \Exception('Insufficient funds');
        }
        $this->player->setBalance($balance - $amount);
    }
}
// /src/Context/Roles/MoneyDestination.php
namespace App\Context\Roles;

use App\Model\Entity\Account;

class MoneyDestination extends AbstractRole
{
    protected function validatePlayer($player): bool
    {
        return $player instanceof Account
            && method_exists($player, 'getBalance')
            && method_exists($player, 'setBalance');
    }

    public function deposit(float $amount): void
    {
        $currentBalance = $this->player->getBalance();
        $this->player->setBalance($currentBalance + $amount);
    }
}
  1. Create the context that orchestrates the transfer:
// /src/Context/MoneyTransfer.php

namespace App\Context;

use App\Model\Entity\Account;
use App\Context\Roles\MoneySource;
use App\Context\Roles\MoneyDestination;

class MoneyTransfer
{
    private MoneySource $sourceRole;
    private MoneyDestination $destinationRole;
    private float $amount;

    public function __construct(Account $source, Account $destination, float $amount)
    {
        $this->sourceRole = new MoneySource();
        $this->sourceRole->bind($source);

        $this->destinationRole = new MoneyDestination();
        $this->destinationRole->bind($destination);

        $this->amount = $amount;
    }

    public function execute(): void
    {
        try {
            $this->sourceRole->withdraw($this->amount);
            $this->destinationRole->deposit($this->amount);
        } finally {
            $this->sourceRole->unbind();
            $this->destinationRole->unbind();
        }
    }

    public function __destruct()
    {
        $this->sourceRole->unbind();
        $this->destinationRole->unbind();
    }
}
  1. Implements controller logic
// /src/Controller/AccountsController.php

namespace App\Controller;

use App\Context\MoneyTransfer;

class AccountsController extends AppController
{

    public $Accounts;

    public function initialize(): void
    {
        parent::initialize();
        $this->Accounts = $this->fetchTable('Accounts');

    }

    public function transfer()
    {
        if ($this->request->is(['post'])) {
            $sourceAccount = $this->Accounts->get($this->request->getData('source_id'));
            $destinationAccount = $this->Accounts->get($this->request->getData('destination_id'));
            $amount = (float)$this->request->getData('amount');
            try {
                $context = new MoneyTransfer($sourceAccount, $destinationAccount, $amount);
                $context->execute();

                $this->Accounts->saveMany([
                    $sourceAccount,
                    $destinationAccount
                ]);

                $this->Flash->success('Transfer completed successfully');
            } catch (\Exception $e) {
                $this->Flash->error($e->getMessage());
            }
            return $this->redirect(['action' => 'transfer']);
        }

        $this->set('accounts', $this->Accounts->find('list', valueField: ['name'])->all());
    }
}

Synthesizing DCI Pattern with CakePHP's Architectural Philosophy

One can look at the roles like a behaviors for table records. We can't use table behaviors directly, because it completely breaks the conception of methods separation based on the roles. In case of table behaviors we can't define methods for different roles for same instance as all class objects will have access to all roles methods.

So we're going to implement the behaviors like roles on the entity level.

  1. RoleBehavior layer that mimics CakePHP's behavior system but for entities:
classDiagram
    class RoleBehavior {
        #EntityInterface _entity
        #array _config
        #array _defaultConfig
        +__construct(entity: EntityInterface, config: array)
        +initialize(config: array): void
        +getConfig(key: string|null, default: mixed): mixed
        hasProperty(property: string): bool
        getProperty(property: string): mixed
        setProperty(property: string, value: mixed): void
        +implementedMethods(): array
        +implementedEvents(): array
    }

    class ObjectRegistry {
        #_resolveClassName(class: string): string
        #_create(class: string, alias: string, config: array): object
        #_resolveKey(name: string): string
        +clear(): void
    }

    class RoleRegistry {
        -EntityInterface _entity
        +__construct(entity: EntityInterface)
        #_resolveClassName(class: string): string
        #_create(class: string, alias: string, config: array): RoleBehavior
        #_resolveKey(name: string): string
        +clear(): void
        #_throwMissingClassError(class: string, plugin: string|null): void
    }

    class RoleAwareEntity {
        -RoleRegistry|null _roles
        -array _roleMethods
        #_getRoleRegistry(): RoleRegistry
        +addRole(role: string, config: array): void
        +removeRole(role: string): void
        +hasRole(role: string): bool
        #getRole(role: string): RoleBehavior
        +__call(method: string, arguments: array)
        +hasMethod(method: string): bool
    }

    ObjectRegistry <|-- RoleRegistry
    RoleAwareEntity o-- RoleRegistry
    RoleRegistry o-- RoleBehavior
    RoleAwareEntity ..> RoleBehavior
// /src/Model/Role/RoleBehavior.php
namespace App\Model\Role;

use Cake\Datasource\EntityInterface;
use Cake\Event\EventDispatcherInterface;
use Cake\Event\EventDispatcherTrait;

abstract class RoleBehavior implements EventDispatcherInterface
{
    use EventDispatcherTrait;

    protected EntityInterface $_entity;
    protected array $_config;

    protected $_defaultConfig = [];

    public function __construct(EntityInterface $entity, array $config = [])
    {
        $this->_entity = $entity;
        $this->_config = array_merge($this->_defaultConfig, $config);
        $this->initialize($config);
    }

    /**
     * Initialize hook - like CakePHP behaviors
     */
    public function initialize(array $config): void
    {
    }

    /**
     * Get behavior config
     */
    public function getConfig(?string $key = null, $default = null): mixed
    {
        if ($key === null) {
            return $this->_config;
        }
        return $this->_config[$key] ?? $default;
    }

    /**
     * Check if entity has specific property/method
     */
    protected function hasProperty(string $property): bool
    {
        return $this->_entity->has($property);
    }

    /**
     * Get entity property
     */
    protected function getProperty(string $property): mixed
    {
        return $this->_entity->get($property);
    }

    /**
     * Set entity property
     */
    protected function setProperty(string $property, mixed $value): void
    {
        $this->_entity->set($property, $value);
    }

    /**
     * Get implemented methods - similar to CakePHP behaviors
     */
    public function implementedMethods(): array
    {
        return [];
    }

    /**
     * Get implemented events
     */
    public function implementedEvents(): array
    {
        return [];
    }
}
  1. Now we can create a RoleRegistry to manage roles for entities:
// /src/Model/Role/RoleRegistry.php
namespace App\Model\Role;

use Cake\Core\ObjectRegistry;
use Cake\Datasource\EntityInterface;
use InvalidArgumentException;

class RoleRegistry extends ObjectRegistry
{
    private EntityInterface $_entity;

    public function __construct(EntityInterface $entity)
    {
        $this->_entity = $entity;
    }

    /**
     * Should return a string identifier for the object being loaded.
     *
     * @param string $class The class name to register.
     * @return string
     */
    protected function _resolveClassName(string $class): string
    {
        if (class_exists($class)) {
            return $class;
        }

        $className = 'App\\Model\\Role\\' . $class . 'Role';
        if (!class_exists($className)) {
            throw new InvalidArgumentException("Role class for '{$class}' not found");
        }

        return $className;
    }

    /**
     * Create an instance of a role.
     *
     * @param string $class The class to create.
     * @param string $alias The alias of the role.
     * @param array $config The config array for the role.
     * @return \App\Model\Role\RoleBehavior
     */
    protected function _create($class, string $alias, array $config): RoleBehavior
    {
        return new $class($this->_entity, $config);
    }

    /**
     * Get the key used to store roles in the registry.
     *
     * @param string $name The role name to get a key for.
     * @return string
     */
    protected function _resolveKey(string $name): string
    {
        return strtolower($name);
    }

    /**
     * Clear all roles from the registry.
     *
     * @return void
     */
    public function clear(): void
    {
        $this->reset();
    }

    /**
     * @inheritDoc
     */
    protected function _throwMissingClassError(string $class, ?string $plugin): void
    {
        throw new InvalidArgumentException("Role class for '{$class}' not found");
    }
}
  1. And add role support to Entity:
// /src/Model/Entity/RoleAwareEntity.php
namespace App\Model\Entity;

use App\Model\Role\RoleBehavior;
use App\Model\Role\RoleRegistry;
use Cake\ORM\Entity;
use BadMethodCallException;

class RoleAwareEntity extends Entity
{
    private ?RoleRegistry $_roles = null;
    private array $_roleMethods = [];

    protected function _getRoleRegistry(): RoleRegistry
    {
        if ($this->_roles === null) {
            $this->_roles = new RoleRegistry($this);
        }
        return $this->_roles;
    }

    public function addRole(string $role, array $config = []): void
    {
        $roleInstance = $this->_getRoleRegistry()->load($role, $config);

        foreach ($roleInstance->implementedMethods() as $method => $callable) {
            $this->_roleMethods[$method] = $role;
        }
    }

    public function removeRole(string $role): void
    {
        $this->_roleMethods = array_filter(
            $this->_roleMethods,
            fn($roleType) => $roleType !== $role
        );

        $this->_getRoleRegistry()->unload($role);
    }

    public function hasRole(string $role): bool
    {
        return $this->_getRoleRegistry()->has($role);
    }

    protected function getRole(string $role): RoleBehavior
    {
        return $this->_getRoleRegistry()->load($role);
    }

    public function __call(string $method, array $arguments)
    {
        if (isset($this->_roleMethods[$method])) {
            $role = $this->getRole($this->_roleMethods[$method]);
            return $role->$method(...$arguments);
        }

        throw new BadMethodCallException(sprintf(
            'Method %s::%s does not exist',
            static::class,
            $method
        ));
    }

    public function hasMethod(string $method): bool
    {
        return isset($this->_roleMethods[$method]);
    }
}
  1. Now our Account entity can use roles:

    // /src/Model/Entity/ComplexAccount.php
    namespace App\Model\Entity;
    
    /**
     * @method void withdraw(float $amount)
     * @method bool canWithdraw(float $amount)
     * @method void deposit(float $amount)
     * @method bool canDeposit(float $amount)
     * @method void logOperation(string $operation, array $data)
     * @method void notify(string $type, array $data)
     */
    class ComplexAccount extends RoleAwareEntity
    {
        protected array $_accessible = [
            'balance' => true,
            'account_type' => true,
            'status' => true,
            'is_frozen' => true,
            'created' => true,
            'modified' => true
        ];
    }
  2. Let's rewrite the money transfer example using our new role layer system:

classDiagram
    class AuditableBehavior {
        #Table _auditLogsTable
        +initialize(config: array): void
        +logOperation(table: Table, foreignKey: int, operation: string, data: array)
    }

    class RoleBehavior {
        #EntityInterface _entity
        #array _config
        #array _defaultConfig
        +initialize(config: array)
        +getConfig(key: string|null): mixed
        #hasProperty(property: string): bool
        #getProperty(property: string): mixed
        #setProperty(property: string, value: mixed)
    }

    class AuditableRole {
        +implementedMethods(): array
        +logOperation(operation: string, data: array): void
    }

    class TransferSourceRole {
        #ComplexAccount _entity
        #_defaultConfig: array
        +implementedMethods(): array
        +withdraw(amount: float): void
        +canWithdraw(amount: float): bool
    }

    class TransferDestinationRole {
        #ComplexAccount _entity
        #_defaultConfig: array
        +implementedMethods(): array
        +deposit(amount: float): void
        +canDeposit(amount: float): bool
    }

    class MoneyTransferContext {
        -ComplexAccount source
        -ComplexAccount destination
        -float amount
        -ComplexAccountsTable ComplexAccounts
        +__construct(ComplexAccountsTable, source, destination, amount, config)
        -attachRoles(config: array): void
        +execute(): void
        -detachRoles(): void
    }

    class ComplexAccountsController {
        +ComplexAccounts
        +initialize(): void
        +transfer()
    }

    RoleBehavior <|-- AuditableRole
    RoleBehavior <|-- TransferSourceRole
    RoleBehavior <|-- TransferDestinationRole

    MoneyTransferContext --> TransferSourceRole : uses
    MoneyTransferContext --> TransferDestinationRole : uses
    MoneyTransferContext --> AuditableRole : uses
    ComplexAccountsController --> MoneyTransferContext : creates
    AuditableRole ..> AuditableBehavior : uses

    note for TransferSourceRole "Handles withdrawal operations\nand balance validation"
    note for TransferDestinationRole "Handles deposit operations\nand deposit limits"
    note for AuditableRole "Provides audit logging\ncapabilities"
    note for MoneyTransferContext "Orchestrates money transfer\nwith role management"

TransferSourceRole

// /src/Model/Role/TransferSourceRole.php
namespace App\Model\Role;

use App\Model\Entity\ComplexAccount;
use Cake\Datasource\EntityInterface;

class TransferSourceRole extends RoleBehavior
{

    /**
     * @var ComplexAccount
     */
    protected EntityInterface $_entity;

    protected $_defaultConfig = [
        'field' => 'balance',
        'minimumBalance' => 0
    ];

    public function implementedMethods(): array
    {
        return [
            'withdraw' => 'withdraw',
            'canWithdraw' => 'canWithdraw'
        ];
    }

    public function withdraw(float $amount): void
    {
        if (!$this->canWithdraw($amount)) {
            throw new \InvalidArgumentException('Cannot withdraw: insufficient funds or invalid amount');
        }

        $balanceField = $this->getConfig('field');
        $currentBalance = $this->getProperty($balanceField);

        $this->_entity->logOperation('pre_withdrawal', [
            'amount' => $amount,
            'current_balance' => $currentBalance
        ]);

        $this->setProperty($balanceField, $currentBalance - $amount);

        $this->_entity->logOperation('post_withdrawal', [
            'amount' => $amount,
            'new_balance' => $this->getProperty($balanceField)
        ]);
    }

    public function canWithdraw(float $amount): bool
    {
        if ($amount <= 0) {
            return false;
        }

        $balanceField = $this->getConfig('field');
        $minimumBalance = $this->getConfig('minimumBalance');

        return $this->getProperty($balanceField) - $amount >= $minimumBalance &&
               $this->getProperty('status') === 'active' &&
               !$this->getProperty('is_frozen');
    }
}

TransferDestinationRole

// /src/Model/Role/TransferDestinationRole.php
namespace App\Model\Role;

use Cake\Datasource\EntityInterface;

class TransferDestinationRole extends RoleBehavior
{
    /**
     * @var ComplexAccount
     */
    protected EntityInterface $_entity;

    protected $_defaultConfig = [
        'field' => 'balance',
        'maxDeposit' => null
    ];

    public function implementedMethods(): array
    {
        return [
            'deposit' => 'deposit',
            'canDeposit' => 'canDeposit'
        ];
    }

    public function deposit(float $amount): void
    {
        if (!$this->canDeposit($amount)) {
            throw new \InvalidArgumentException('Cannot deposit: invalid amount or limit exceeded');
        }

        $balanceField = $this->getConfig('field');
        $currentBalance = $this->getProperty($balanceField);

        $this->_entity->logOperation('pre_deposit', [
            'amount' => $amount,
            'current_balance' => $currentBalance
        ]);

        $this->setProperty($balanceField, $currentBalance + $amount);

        $this->_entity->logOperation('post_deposit', [
            'amount' => $amount,
            'new_balance' => $this->getProperty($balanceField)
        ]);
    }

    public function canDeposit(float $amount): bool
    {
        if ($amount <= 0) {
            return false;
        }

        $maxDeposit = $this->getConfig('maxDeposit');
        return ($maxDeposit === null || $amount <= $maxDeposit) &&
               $this->getProperty('status') === 'active' &&
               !$this->getProperty('is_frozen');
    }
}
  1. Lets implement audit functionality to show more complex role usage.

AuditableRole

// /src/Model/Role/AuditableRole.php
namespace App\Model\Role;

use Cake\ORM\TableRegistry;

class AuditableRole extends RoleBehavior
{
    public function implementedMethods(): array
    {
        return [
            'logOperation' => 'logOperation'
        ];
    }

    public function logOperation(string $operation, array $data): void
    {
        $table = TableRegistry::getTableLocator()->get($this->_entity->getSource());
        $table->logOperation($table, $this->_entity->id, $operation, $data);
    }
}

AuditableBehavior

// /src/Model/Behavior/AuditableBehavior.php
namespace App\Model\Behavior;

use Cake\ORM\Behavior;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;

class AuditableBehavior extends Behavior
{
    protected array $_defaultConfig = [
        'implementedMethods' => [
            'logOperation' => 'logOperation',
        ],
    ];

    protected Table $_auditLogsTable;

    public function initialize(array $config): void
    {
        parent::initialize($config);
        $this->_auditLogsTable = TableRegistry::getTableLocator()->get('AuditLogs');
    }

    public function logOperation(Table $table, int $foreignKey, string $operation, array $data = [])
    {
        $log = $this->_auditLogsTable->newEntity([
            'model' => $table->getAlias(),
            'foreign_key' => $foreignKey,
            'operation' => $operation,
            'data' => json_encode($data),
            'created' => new \DateTime()
        ]);

        return $this->_auditLogsTable->save($log);
    }
}
  1. Lets take a look on improved context implementation.
// /src/Context/MoneyTransfer/MoneyTransferContext.php
namespace App\Context\MoneyTransfer;

use App\Model\Entity\ComplexAccount;
use App\Model\Table\ComplexAccountsTable;

class MoneyTransferContext
{
    private readonly ComplexAccount $source;
    private readonly ComplexAccount $destination;
    private readonly float $amount;
    private readonly ComplexAccountsTable $ComplexAccounts;

    public function __construct(
        ComplexAccountsTable $ComplexAccounts,
        ComplexAccount $source,
        ComplexAccount $destination,
        float $amount,
        array $config = []
    ) {
        $this->source = $source;
        $this->destination = $destination;
        $this->amount = $amount;
        $this->ComplexAccounts = $ComplexAccounts;
        $this->attachRoles($config);
    }

    private function attachRoles(array $config): void
    {
        $this->source->addRole('Auditable');
        $this->source->addRole('TransferSource', $config['source'] ?? []);

        $this->destination->addRole('Auditable');
        $this->destination->addRole('TransferDestination', $config['destination'] ?? []);
    }

    public function execute(): void
    {
        try {
            $this->ComplexAccounts->getConnection()->transactional(function() {
                if (!$this->source->canWithdraw($this->amount)) {
                    throw new \InvalidArgumentException('Source cannot withdraw this amount');
                }

                if (!$this->destination->canDeposit($this->amount)) {
                    throw new \InvalidArgumentException('Destination cannot accept this deposit');
                }

                $this->source->withdraw($this->amount);
                $this->destination->deposit($this->amount);

                // This code will not able to work! Methods not attached not available, and logic errors does not possible to perform in context.
                // $this->source->deposit($this->amount);
                // $this->destination->withdraw($this->amount);

                $this->ComplexAccounts->saveMany([
                    $this->source,
                    $this->destination
                ]);
            });
        } finally {
            $this->detachRoles();
        }
    }

    private function detachRoles(): void
    {
        $this->source->removeRole('TransferSource');
        $this->source->removeRole('Auditable');

        $this->destination->removeRole('TransferDestination');
        $this->destination->removeRole('Auditable');
    }
}
  1. And finally lets implements controller logic.
// /src/Controller/ComplexAccountsController.php
namespace App\Controller;

use App\Context\MoneyTransfer\MoneyTransferContext as MoneyTransfer;

class ComplexAccountsController extends AppController
{

    public $ComplexAccounts;

    public function initialize(): void
    {
        parent::initialize();
        $this->ComplexAccounts = $this->fetchTable('ComplexAccounts');

    }

    public function transfer()
    {
        if ($this->request->is(['post'])) {
            try {
                $source = $this->ComplexAccounts->get($this->request->getData('source_id'));
                $destination = $this->ComplexAccounts->get($this->request->getData('destination_id'));
                $amount = (float)$this->request->getData('amount');

                $transfer = new MoneyTransfer($this->ComplexAccounts, $source, $destination, $amount);

                $transfer->execute();

                $this->Flash->success('Transfer completed successfully');

            } catch (\InvalidArgumentException $e) {
                $this->Flash->error($e->getMessage());
            }
            $this->redirect(['action' => 'transfer']);
        }

        $this->set('complexAccounts', $this->ComplexAccounts->find('list', valueField: ['account_type', 'id'])->all());
    }
}

The money transfer flow is shown in the following diagram:

sequenceDiagram
    participant CC as ComplexAccountsController
    participant MT as MoneyTransferContext
    participant SA as Source Account
    participant DA as Destination Account
    participant TSR as TransferSourceRole
    participant TDR as TransferDestinationRole
    participant AR as AuditableRole
    participant AB as AuditableBehavior
    participant DB as Database

    CC->>MT: new MoneyTransfer(accounts, source, destination, amount)
    activate MT

    MT->>SA: addRole('Auditable')
    MT->>SA: addRole('TransferSource')
    MT->>DA: addRole('Auditable')
    MT->>DA: addRole('TransferDestination')

    CC->>MT: execute()

    MT->>SA: canWithdraw(amount)
    SA->>TSR: canWithdraw(amount)
    TSR->>SA: getProperty('balance')
    TSR->>SA: getProperty('status')
    TSR->>SA: getProperty('is_frozen')
    TSR-->>MT: true/false

    alt Can Withdraw
        MT->>DA: canDeposit(amount)
        DA->>TDR: canDeposit(amount)
        TDR->>DA: getProperty('balance')
        TDR->>DA: getProperty('status')
        TDR->>DA: getProperty('is_frozen')
        TDR-->>MT: true/false

        alt Can Deposit
            MT->>SA: withdraw(amount)
            SA->>TSR: withdraw(amount)
            TSR->>SA: logOperation('pre_withdrawal')
            SA->>AR: logOperation('pre_withdrawal')
            AR->>AB: logOperation(table, id, operation, data)
            AB->>DB: save audit log

            TSR->>SA: setProperty(balance, newBalance)

            TSR->>SA: logOperation('post_withdrawal')
            SA->>AR: logOperation('post_withdrawal')
            AR->>AB: logOperation(table, id, operation, data)
            AB->>DB: save audit log

            MT->>DA: deposit(amount)
            DA->>TDR: deposit(amount)
            TDR->>DA: logOperation('pre_deposit')
            DA->>AR: logOperation('pre_deposit')
            AR->>AB: logOperation(table, id, operation, data)
            AB->>DB: save audit log

            TDR->>DA: setProperty(balance, newBalance)

            TDR->>DA: logOperation('post_deposit')
            DA->>AR: logOperation('post_deposit')
            AR->>AB: logOperation(table, id, operation, data)
            AB->>DB: save audit log

            MT->>DB: saveMany([source, destination])
        else Cannot Deposit
            MT-->>CC: throw InvalidArgumentException
        end
    else Cannot Withdraw
        MT-->>CC: throw InvalidArgumentException
    end

    MT->>SA: removeRole('TransferSource')
    MT->>SA: removeRole('Auditable')
    MT->>DA: removeRole('TransferDestination')
    MT->>DA: removeRole('Auditable')
    deactivate MT

    alt Success
        CC->>CC: Flash.success('Transfer completed')
    else Error
        CC->>CC: Flash.error(error.message)
    end

    CC->>CC: redirect(['action' => 'transfer'])

Conclusion

DCI pattern helps us write safer code by controlling what objects can do at any given time. Like in our money transfer example, we make sure the source account can only take money out and the destination account can only receive money. This prevents mistakes and makes the code more secure.

Context is a great way to keep code organized and focused. It serves as an excellent implementation of the Single Responsibility Principle. Each context, like our MoneyTransferContext, does just one thing and does it well. This makes the code easier to understand and test because each piece has a clear job to do.

Even though PHP isn't as flexible as some other programming languages (for example, we can't change object behavior on the fly), we found good ways to make DCI work. Our RoleBehavior and RoleRegistry classes give us a solid way to manage different roles for our objects. CakePHP turns out to be a great framework for using the DCI pattern. We were able to build on CakePHP's existing features, like its behavior system, to create our role-based approach.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-dci and available for testing.

This article is part of the CakeDC Advent Calendar 2024 (December 15th 2024)

Latest articles

CakeDC Search Filter Plugin

This article is part of the CakeDC Advent Calendar 2024 (December 21th 2024) The CakeDC Search Filter plugin is a powerful tool for CakePHP applications that provides advanced search functionality with a modern, user-friendly interface. It combines backend flexibility with a Vue.js-powered frontend to create dynamic search filters. Key features include:

  • Dynamic filter generation based on database schema
  • Multiple filter types for different data types
  • Customizable search conditions
  • Interactive Vue.js frontend
  • AJAX-powered autocomplete functionality
  • Seamless integration with CakePHP's ORM

Setup

  1. Install the plugin using Composer: composer require cakedc/search-filter
  2. Load the plugin in your application's src/Application.php: $this->addPlugin('CakeDC/SearchFilter');
  3. Add the search element to your view inside search form: <?= $this->element('CakeDC/SearchFilter.Search/v_search'); ?>
  4. Initialize the Vue.js application: <script> window._search.createMyApp(window._search.rootElemId) </script>

Filters

Filters are the user interface elements that allow users to interact with the search. The plugin provides several built-in filter types for different data scenarios:
  1. BooleanFilter: For Yes/No selections $booleanFilter = (new BooleanFilter()) ->setCriterion(new BoolCriterion('is_active')) ->setLabel('Active Status') ->setOptions([1 => 'Active', 0 => 'Inactive']);
  2. DateFilter: For date-based filtering $dateFilter = (new DateFilter()) ->setCriterion(new DateCriterion('created_date')) ->setLabel('Creation Date') ->setDateFormat('YYYY-MM-DD');
  3. StringFilter: For text-based searches $stringFilter = (new StringFilter()) ->setCriterion(new StringCriterion('title')) ->setLabel('Title');
  4. NumericFilter: For number-based filtering $numericFilter = (new NumericFilter()) ->setCriterion(new NumericCriterion('price')) ->setLabel('Price') ->setProperty('step', '0.01');
  5. LookupFilter: For autocomplete-based filtering $lookupFilter = (new LookupFilter()) ->setCriterion(new LookupCriterion('user_id', $usersTable, new StringCriterion('name'))) ->setLabel('User') ->setLookupFields(['name', 'email']) ->setAutocompleteRoute(['controller' => 'Users', 'action' => 'autocomplete']);
  6. MultipleFilter: For selecting multiple values $multipleFilter = (new MultipleFilter()) ->setCriterion(new InCriterion('category_id', $categoriesTable, new StringCriterion('name'))) ->setLabel('Categories') ->setProperty('placeholder', 'Select multiple options');
  7. SelectFilter: For dropdown selections $selectFilter = (new SelectFilter()) ->setCriterion($manager->criterion()->numeric('status_id')) ->setLabel('Status') ->setOptions($this->Statuses->find('list')->toArray()) ->setEmpty('All Statuses');

Criteria Purpose and Usage

Criteria are the building blocks that define how filters operate on your data. They handle the actual query building and data filtering. Key criterion types include:
  1. AndCriterion: Combines multiple criteria with AND logic
  2. BoolCriterion: Handles boolean comparisons
  3. StringCriterion: Handles string matching
  4. DateCriterion: Manages date comparisons
  5. DateTimeCriterion: Manages datetime comparisons
  6. InCriterion: Handles in comparisons
  7. LookupCriterion: Handles lookup comparisons
  8. NumericCriterion: Handles numeric comparisons
  9. OrCriterion: Combines multiple criteria with OR logic
Example of combining criteria: $complexCriterion = new OrCriterion([ new StringCriterion('title'), new StringCriterion('content') ]);

Filters Usage

Let's walk through a complete example of setting up filters in a controller. This implementation demonstrates how to integrate search filters with our htmx application from previous articles. Figure 1-1

Controller Setup

First, we need to initialize the PlumSearch filter component in our controller: public function initialize(): void { parent::initialize(); $this->loadComponent('PlumSearch.Filter'); }

Implementing Search Filters

Here's a complete example of setting up filters in the controller's list method: // /src/Controller/PostsController.php protected function list() { $query = $this->Posts->find(); $manager = new Manager($this->request); $collection = $manager->newCollection(); $collection->add('search', $manager->filters() ->new('string') ->setConditions(new \stdClass()) ->setLabel('Search...') ); $collection->add('name', $manager->filters() ->new('string') ->setLabel('Name') ->setCriterion( new OrCriterion([ $manager->buildCriterion('title', 'string', $this->Posts), $manager->buildCriterion('body', 'string', $this->Posts), ]) ) ); $collection->add('created', $manager->filters() ->new('datetime') ->setLabel('Created') ->setCriterion($manager->buildCriterion('created', 'datetime', $this->Posts)) ); $viewFields = $collection->getViewConfig(); if (!empty($this->getRequest()->getQuery()) && !empty($this->getRequest()->getQuery('f'))) { $search = $manager->formatSearchData(); $this->set('values', $search); $this->Posts->addFilter('search', [ 'className' => 'Multiple', 'fields' => [ 'title', 'body', ] ]); $this->Posts->addFilter('multiple', [ 'className' => 'CakeDC/SearchFilter.Criteria', 'criteria' => $collection->getCriteria(), ]); $filters = $manager->formatFinders($search); $query = $query->find('filters', params: $filters); } $this->set('viewFields', $viewFields); $posts = $this->paginate($this->Filter->prg($query), ['limit' => 12]); $this->set(compact('posts')); }

Table Configuration

Enable the filterable behavior in your table class: // /src/Model/Table/PostsTable.php public function initialize(array $config): void { // ... $this->addBehavior('PlumSearch.Filterable'); }

View Implementation

In your view template, add the necessary assets and initialize the search filter: <!-- templates/Posts/index.php --> <?= $this->Html->css('CakeDC/SearchFilter.inline'); ?> <?= $this->Html->script('CakeDC/SearchFilter.vue3.js'); ?> <?= $this->Html->script('CakeDC/SearchFilter.main.js', ['type' => 'module']); ?> <?= $this->element('CakeDC/SearchFilter.Search/v_templates'); ?> <div id="search"> <?= $this->Form->create(null, [ 'id' => 'search-form', 'type' => 'get', 'hx-get' => $this->Url->build(['controller' => 'Posts', 'action' => 'index']), 'hx-target' => "#posts", ]); ?> <div id="ext-search"></div> <?= $this->Form->button('Search', ['type' => 'submit', 'class' => 'btn btn-primary']); ?> <?= $this->Form->end(); ?> </div> <script> window._search = window._search || {}; window._search.fields = <?= json_encode($viewFields) ?>; var values = null; <?php if (!empty($values)): ?> window._search.values = <?= json_encode($values) ?>; <?php else: ?> window._search.values = {}; <?php endif; ?> </script>

JavaScript Integration

Finally, add the necessary JavaScript to handle the search filter initialization and htmx interactions: <!-- /templates/Posts/index.php --> <script> function setupTable(reload) { if (reload) { setTimeout(function () { window._search.app.unmount() window._search.createMyApp(window._search.rootElemId) }, 20); } } document.addEventListener('DOMContentLoaded', function() { window._search.createMyApp(window._search.rootElemId) setupTable(false); htmx.on('htmx:afterRequest', (evt) => { setupTable(true); }) }); </script> The combination of CakePHP's search filter plugin with htmx provides a modern, responsive search experience with minimal JavaScript code.

Frontend Vue App Widgets

The plugin provides several Vue.js widgets for different filter types:
  • SearchInput: For basic text input
  • SearchInputNumericRange: For basic text input
  • SearchSelect, Select2, SearchSelectMultiple: For dropdown selections
  • SearchInputDate, SearchInputDateRange: For date picking
  • SearchInputDateTime, SearchInputDateTimeRange: For datetime picking
  • SearchLookupInput: For autocomplete functionality
  • SearchMultiple: For multiple selections
  • SearchSelectMultiple: For multiple selections
These widgets are automatically selected based on the filter type you define in your controller.

Custom Filters and Custom Widgets

The CakeDC Search Filter plugin can be extended with custom filters and widgets. Let's walk through creating a custom range filter that allows users to search between two numeric values.

Custom Filter Class

First, create a custom filter class that extends the AbstractFilter: // /src/Controller/Filter/RangeFilter.php <?php declare(strict_types=1); namespace App\Controller\Filter; use Cake\Controller\Controller; use CakeDC\SearchFilter\Filter\AbstractFilter; class RangeFilter extends AbstractFilter { protected array $properties = [ 'type' => 'range', ]; protected object|array|null $conditions = [ self::COND_BETWEEN => 'Between', ]; }

Custom Criterion Implementation

Create a criterion class to handle the range filtering logic: // /src/Model/Filter/Criterion/RangeCriterion.php <?php declare(strict_types=1); namespace App\Model\Filter\Criterion; use Cake\Database\Expression\QueryExpression; use Cake\ORM\Query; use CakeDC\SearchFilter\Filter\AbstractFilter; use CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion; class RangeCriterion extends BaseCriterion { protected $field; public function __construct($field) { $this->field = $field; } public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query { $filter = $this->buildFilter($condition, $values, $criteria, $options); if (!empty($filter)) { return $query->where($filter); } return $query; } public function buildFilter(string $condition, array $values, array $criteria, array $options = []): ?callable { return function (QueryExpression $exp) use ($values) { if (!empty($values['from']) && !empty($values['to'])) { return $exp->between($this->field, $values['from'], $values['to']); } return $exp; }; } public function isApplicable($value, string $condition): bool { return !empty($value['from']) || !empty($value['to']); } }

Controller Integration

Update your controller to use the custom range filter: // /src/Controller/PostsController.php protected function list() { // ... $manager = new Manager($this->request); $manager->filters()->load('range', ['className' => RangeFilter::class]); $collection = $manager->newCollection(); $collection->add('id', $manager->filters() ->new('range') ->setLabel('Id Range') ->setCriterion($manager->buildCriterion('id', 'integer', $this->Posts)) ); // ... }

Custom Vue.js Widget

Create a custom Vue.js component for the range input. It consists of two parts, widget template and widget component: <!-- /templates/Posts/index.php --> <script type="text/x-template" id="search-input-range-template"> <span class="range-wrapper d-flex"> <input type="number" class="form-control value value-from" :name="'v[' + index + '][from][]'" v-model="fromValue" @input="updateValue" :placeholder="field.fromPlaceholder || 'From'" /> <span class="range-separator d-flex align-items-center">&nbsp;&mdash;&nbsp;</span> <input type="number" class="form-control value value-to" :name="'v[' + index + '][to][]'" v-model="toValue" @input="updateValue" :placeholder="field.toPlaceholder || 'To'" /> </span> </script> <script> const RangeInput = { template: "#search-input-range-template", props: ['index', 'value', 'field'], data() { return { fromValue: '', toValue: '', }; }, methods: { updateValue() { this.$emit('change-value', { index: this.index, value: { from: this.fromValue, to: this.toValue } }); } }, mounted() { if (this.value) { this.fromValue = this.value.from || ''; this.toValue = this.value.to || ''; } }, watch: { value(newValue) { if (newValue) { this.fromValue = newValue.from || ''; this.toValue = newValue.to || ''; } else { this.fromValue = ''; this.toValue = ''; } } } }; <script>

Component Registration

Register the custom widget in the Vue.js app. Implement the register function to register the custom widget and the setupTable function to setup the table after a htmx request. // /templates/Posts/index.php function register(app, registrator) { app.component('RangeInput', RangeInput); registrator('range', function(cond, type) { return 'RangeInput';}); } function setupTable(reload) { if (reload) { setTimeout(function () { window._search.app.unmount() window._search.createMyApp(window._search.rootElemId, register) }, 20); } } document.addEventListener('DOMContentLoaded', function() { window._search.createMyApp(window._search.rootElemId, register) setupTable(false); htmx.on('htmx:afterRequest', (evt) => { setupTable(true); }) }); </script> This implementation creates a custom range filter that allows users to search for records within a specified numeric range. The filter consists of three main components:
  1. A custom filter class (RangeFilter) that defines the filter type and conditions
  2. A custom criterion class (RangeCriterion) that implements the filtering logic
  3. A Vue.js component (RangeInput) that provides the user interface for entering range values
  4. A registration function to register the custom widget and the setupTable function to setup the table after a htmx request.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-htmx/tree/4.0.0 and available for testing.

Conclusion

The CakeDC Search Filter plugin provides a robust solution for implementing advanced search functionality in CakePHP applications. Its combination of flexible backend filtering and modern frontend components makes it an excellent choice for any CakePHP project. The plugin's extensibility allows for customization to meet specific project needs, while its built-in features cover most common search scenarios out of the box. Whether you need simple text searches or complex multi-criteria filtering, the Search Filter plugin offers the tools to build user-friendly search interfaces. This article is part of the CakeDC Advent Calendar 2024 (December 21th 2024)

5 CakePHP security tips

This article is part of the CakeDC Advent Calendar 2024 (December 20th 2024) We all know the importance of security in our sites, so here we have 5 quick tips that can improve the security of your site quickly:

  • Ensure all cookies are configured for security
// config/app.php 'Session' => [ // .. other configurations 'cookie' => 'CUSTOM_NAME_FOR_YOUR_SESSION_COOKIE', 'ini' => [ 'session.cookie_secure' => true, 'session.cookie_httponly' => true, 'session.cookie_samesite' => 'Strict', ], ],
  • Audit your dependencies
    • Both backend and frontend dependencies could be impacted by security issues. In the case of the backend, you can have a quick look by running composer audit. In case of issues, you'll see an output similar to:
$ composer audit Found 7 security vulnerability advisories affecting 4 packages: +-------------------+----------------------------------------------------------------------------------+ | Package | composer/composer | | CVE | CVE-2024-35241 | | Title | Composer has a command injection via malicious git branch name | | URL | https://github.com/advisories/GHSA-47f6-5gq3-vx9c | | Affected versions | >=2.3,<2.7.7|>=2.0,<2.2.24 | | Reported at | 2024-06-10T21:36:32+00:00 | +-------------------+----------------------------------------------------------------------------------+ // in src/Application::middleware() // Cross Site Request Forgery (CSRF) Protection Middleware // https://book.cakephp.org/4/en/security/csrf.html#cross-site-request-forgery-csrf-middleware ->add(new CsrfProtectionMiddleware([ 'httponly' => true, ]));
  • Enforce HTTPS
    • Ensure your live applications are enforcing HTTPS to prevent downgrading to HTTP. You can handle that in a number of ways, for example using your webserver configuration, or a Proxy. If you want to handle it via CakePHP builtins, add
// in src/Application::middleware() ->add(new HttpsEnforcerMiddleware([ 'hsts' => [ 'maxAge' => 10, 'includeSubDomains' => true, 'preload' => false, // use preload true when you are sure all subdomains are OK with HTTPS ], ])) // in src/Application::middleware() $securityHeaders = (new SecurityHeadersMiddleware()) ->setReferrerPolicy() // limit referrer info leaked ->setXFrameOptions() // mitigates clickjacking attacks ->noOpen() // don't save file in downloads auto ->noSniff(); // mitigates mime type sniffing $middlewareQueue // ... ->add($securityHeaders) // ... This is just a quick example of 5 changes in code you could apply today to improve your CakePHP website security. Keep your projects safe! This article is part of the CakeDC Advent Calendar 2024 (December 20th 2024)

Testing DCI with Behavior-Driven Development

This article is part of the CakeDC Advent Calendar 2024 (December 19th 2024) In our previous article, we explored the Data-Context-Interaction (DCI) pattern and its implementation in PHP using CakePHP. We demonstrated how DCI helps separate data structures from their runtime behaviors through roles and contexts, using a money transfer system as an example. Now, let's dive into testing DCI implementations using Behavior-Driven Development (BDD) with Behat, exploring a practical hotel room reservation system.

Room Reservation System Overview

The room reservation system demonstrates DCI's power in managing complex business rules and interactions. In this system, rooms and guests are our core data objects, while the reservation process involves multiple roles and behaviors. A room can be reservable under certain conditions (availability, capacity), and guests can have different privileges based on their loyalty levels. The reservation context orchestrates these interactions, ensuring business rules are followed and the system maintains consistency.

Database Structure

The database schema reflects our domain model with proper relationships between entities: erDiagram rooms { id integer PK number varchar(10) type varchar(50) capacity integer base_price decimal status varchar(20) created datetime modified datetime } guests { id integer PK name varchar(100) email varchar(100) phone varchar(20) loyalty_level varchar(20) created datetime modified datetime } reservations { id integer PK room_id integer FK primary_guest_id integer FK check_in date check_out date status varchar(20) total_price decimal created datetime modified datetime } reservation_guests { id integer PK reservation_id integer FK guest_id integer FK created datetime } audit_logs { id integer PK model varchar(100) foreign_key integer operation varchar(50) data json created datetime } reservations ||--|| rooms : "has" reservations ||--|| guests : "primary guest" reservation_guests }|--|| reservations : "belongs to" reservation_guests }|--|| guests : "includes" audit_logs }|--|| reservations : "logs" Key aspects of this schema:
  • Rooms table stores physical hotel rooms with their properties
  • Guests table maintains customer information including loyalty status
  • Reservations table handles booking details with pricing
  • Reservation_guests enables multiple guests per reservation
  • Audit_logs provides system-wide operation tracking
classDiagram class Room { +String number +String type +Integer capacity +Decimal basePrice +String status } class Guest { +String name +String email +String phone +String loyaltyLevel } class Reservation { +Room room +Guest primaryGuest +Date checkIn +Date checkOut +String status +Decimal totalPrice } class ReservationGuest { +Reservation reservation +Guest guest } Reservation --> Room Reservation --> Guest ReservationGuest --> Reservation ReservationGuest --> Guest The class diagram above shows our core data model. Each entity has specific attributes that define its state, but the interesting part comes with how these objects interact during the reservation process. Let's examine how DCI roles enhance this basic structure: classDiagram class ReservableRoom { +isAvailableForDates(checkIn, checkOut) +canAccommodateGuests(guestCount) +calculatePrice(checkIn, checkOut) } class ReservingGuest { +canMakeReservation() +calculateDiscount(basePrice) } class RoomReservationContext { +Room room +Guest primaryGuest +List~Guest~ additionalGuests +Date checkIn +Date checkOut +execute() } Room ..|> ReservableRoom : implements Guest ..|> ReservingGuest : implements RoomReservationContext --> ReservableRoom : uses RoomReservationContext --> ReservingGuest : uses The reservation process involves multiple interactions between objects, each playing their specific roles. The sequence diagram below illustrates how these components work together: sequenceDiagram participant RC as ReservationsController participant RRC as RoomReservationContext participant R as Room participant G as Guest participant RR as ReservableRoom participant RG as ReservingGuest participant DB as Database RC->>RRC: new RoomReservationContext(room, guest, dates) activate RRC RRC->>R: addRole('ReservableRoom') RRC->>G: addRole('ReservingGuest') RC->>RRC: execute() RRC->>R: isAvailableForDates(checkIn, checkOut) R->>RR: isAvailableForDates(checkIn, checkOut) RR-->>RRC: true/false alt Room is available RRC->>R: canAccommodateGuests(guestCount) R->>RR: canAccommodateGuests(guestCount) RR-->>RRC: true/false alt Can accommodate guests RRC->>G: canMakeReservation() G->>RG: canMakeReservation() RG-->>RRC: true/false alt Guest can make reservation RRC->>R: calculatePrice(checkIn, checkOut) R->>RR: calculatePrice(checkIn, checkOut) RR-->>RRC: basePrice RRC->>G: calculateDiscount(basePrice) G->>RG: calculateDiscount(basePrice) RG-->>RRC: discount RRC->>DB: save reservation DB-->>RRC: success else RRC-->>RC: throw GuestCannotReserveException end else RRC-->>RC: throw CapacityExceededException end else RRC-->>RC: throw RoomNotAvailableException end RRC->>R: removeRole('ReservableRoom') RRC->>G: removeRole('ReservingGuest') deactivate RRC This sequence diagram demonstrates the complete reservation flow, including role attachment, validation checks, price calculations, and proper error handling. Each step ensures that business rules are followed and the system maintains consistency.

Testing with Behavior-Driven Development

While our DCI implementation provides clear separation of concerns and maintainable code, we need to ensure it works correctly through comprehensive testing. Behavior-Driven Development (BDD) with Behat is particularly well-suited for testing DCI implementations because both approaches focus on behaviors and interactions.

Understanding Behat and Gherkin

Behat is a PHP framework for BDD, which allows us to write tests in natural language using Gherkin syntax. Gherkin is a business-readable domain-specific language that lets you describe software's behavior without detailing how that behavior is implemented. This aligns perfectly with DCI's focus on separating what objects are from what they do. A typical Gherkin feature file consists of:
  • Feature: A description of the functionality being tested
  • Scenario: A specific situation being tested
  • Given: The initial context
  • When: The action being taken
  • Then: The expected outcome

Setting Up Behat Testing Environment

First, add the required dependencies to your composer.json: { "require-dev": { "behat/behat": "^3.13", "behat/mink-extension": "^2.3", "behat/mink-browserkit-driver": "^2.1", "dmore/behat-chrome-extension": "^1.4" } } Here's how we configure Behat for our project: # behat.yml default: autoload: "": "%paths.base%/tests/Behat" suites: reservation: paths: features: "%paths.base%/tests/Behat/Features/Reservation" contexts: - App\Test\Behat\Context\ReservationContext - App\Test\Behat\Context\DatabaseContext extensions: Behat\MinkExtension: base_url: 'http://localhost' sessions: default: browser_stack: ~

Complete Behat Test Implementation

Our test implementation consists of several key components that work together to verify our DCI implementation:

Base Test Context Setup

The BaseContext class provides basic test infrastructure, handling test environment initialization and database connections. It loads the application bootstrap file and configures the test environment, including database connections and debug settings. // tests/Behat/Context/BaseContext.php <?php declare(strict_types=1); namespace App\Test\Behat\Context; use Behat\Behat\Context\Context; use Cake\Core\Configure; use Cake\ORM\TableRegistry; use Cake\TestSuite\ConnectionHelper; abstract class BaseContext implements Context { public function __construct(string $bootstrap = null) { } protected function initialize(): void { require_once dirname(__DIR__, 3) . '/tests/bootstrap.php'; require_once dirname(dirname(dirname(__DIR__))) . '/config/bootstrap.php'; ConnectionHelper::addTestAliases(); Configure::write('debug', true); } protected function getTableLocator() { return TableRegistry::getTableLocator(); } }

Database Management and Fixtures

The DatabaseContext class handles database setup and cleanup, including table creation, data insertion, and deletion. It uses fixtures to populate the database with initial data, ensuring tests start with a known state. This setup allows for consistent testing conditions across different scenarios. // tests/Behat/Context/DatabaseContext.php <?php declare(strict_types=1); namespace App\Test\Behat\Context; use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\TableNode; use Cake\ORM\TableRegistry; class DatabaseContext extends BaseContext { private $tables = [ 'audit_logs', 'reservation_guests', 'reservations', 'guests', 'rooms', ]; /** * @BeforeScenario */ public function initializeTest(BeforeScenarioScope $scope): void { $this->initialize(); $this->clearDatabase(); } /** * @BeforeScenario */ public function clearDatabase(): void { $connection = TableRegistry::getTableLocator() ->get('Reservations') ->getConnection(); $connection->execute('PRAGMA foreign_keys = OFF'); foreach ($this->tables as $tableName) { TableRegistry::getTableLocator()->get($tableName)->deleteAll([]); } $connection->execute('PRAGMA foreign_keys = ON'); } /** * @Given the following rooms exist: */ public function theFollowingRoomsExist(TableNode $rooms): void { $roomsTable = TableRegistry::getTableLocator()->get('Rooms'); $headers = $rooms->getRow(0); foreach ($rooms->getRows() as $i => $room) { if ($i === 0) { continue; } $room = array_combine($headers, $room); $entity = $roomsTable->newEntity($room); $roomsTable->save($entity); } } /** * @Given the following guests exist: */ public function theFollowingGuestsExist(TableNode $guests) { $guestsTable = TableRegistry::getTableLocator()->get('Guests'); $headers = $guests->getRow(0); foreach ($guests->getRows() as $i => $guest) { if ($i === 0) { continue; } $guest = array_combine($headers, $guest); $entity = $guestsTable->newEntity($guest); $guestsTable->save($entity); } } /** * @Given the following reservations exist: */ public function theFollowingReservationsExist(TableNode $reservations) { $reservationsTable = TableRegistry::getTableLocator()->get('Reservations'); $headers = $reservations->getRow(0); foreach ($reservations->getRows() as $i => $reservation) { if ($i === 0) { continue; } $reservation = array_combine($headers, $reservation); $entity = $reservationsTable->newEntity($reservation); $reservationsTable->save($entity); } } }

Reservation Testing Context

ReservationContext implements the business logic testing for our room reservation system. It manages the test workflow for reservation creation, guest management, and verification of reservation outcomes. This context translates Gherkin steps into actual system operations, handling authentication, room selection, guest assignment, and reservation confirmation. It also captures and verifies error conditions, ensuring our DCI roles and contexts behave correctly under various scenarios. // tests/Behat/Context/ReservationContext.php <?php declare(strict_types=1); namespace App\Test\Behat\Context; use App\Context\RoomReservation\RoomReservationContext; use App\Model\Entity\Guest; use App\Model\Entity\Room; use Behat\Behat\Context\Context; use Behat\Gherkin\Node\TableNode; use Behat\MinkExtension\Context\RawMinkContext; use Cake\I18n\DateTime; use Cake\ORM\TableRegistry; use PHPUnit\Framework\Assert; class ReservationContext extends RawMinkContext implements Context { private ?Guest $authenticatedGuest = null; private ?Room $selectedRoom = null; private array $additionalGuests = []; private ?string $lastError = null; private ?float $totalPrice = null; private ?array $reservationDates = null; private ?array $lastLoggedOperation = null; /** * @Given I am authenticated as :name */ public function iAmAuthenticatedAs(string $name): void { $this->authenticatedGuest = TableRegistry::getTableLocator() ->get('Guests') ->find() ->where(['name' => $name]) ->firstOrFail(); } /** * @When I try to reserve room :number for the following stay: */ public function iTryToReserveRoomForTheFollowingStay(string $number, TableNode $table): void { $this->selectedRoom = TableRegistry::getTableLocator() ->get('Rooms') ->find() ->where(['number' => $number]) ->contain(['Reservations']) ->firstOrFail(); $this->reservationDates = $table->getRowsHash(); } /** * @When I add :name as an additional guest */ public function iAddAsAnAdditionalGuest(string $name): void { $guest = TableRegistry::getTableLocator() ->get('Guests') ->find() ->where(['name' => $name]) ->firstOrFail(); $this->additionalGuests[] = $guest; } private function executeReservation(): void { if (!$this->selectedRoom || !$this->reservationDates || !$this->authenticatedGuest) { return; } try { $context = new RoomReservationContext( $this->selectedRoom, $this->authenticatedGuest, $this->additionalGuests, new DateTime($this->reservationDates['check_in']), new DateTime($this->reservationDates['check_out']) ); $reservation = $context->execute(); $this->totalPrice = (float)$reservation->total_price; $this->lastError = null; } catch (\Exception $e) { $this->lastError = $e->getMessage(); } } /** * @Then the reservation should be confirmed */ public function theReservationShouldBeConfirmed(): void { $this->executeReservation(); if ($this->lastError !== null) { throw new \Exception("Expected reservation to be confirmed but got error: {$this->lastError}"); } } /** * @Then the total price should be :price */ public function theTotalPriceShouldBe(string $price): void { $this->executeReservation(); $expectedPrice = (float)str_replace('"', '', $price); if ($this->totalPrice !== $expectedPrice) { throw new \Exception( "Expected price to be {$expectedPrice} but got {$this->totalPrice}" ); } } /** * @Then I should see an error :message */ public function iShouldSeeAnError(string $message): void { $this->executeReservation(); if ($this->lastError === null) { throw new \Exception("Expected error but none was thrown"); } if (strpos($this->lastError, $message) === false) { throw new \Exception( "Expected error message '{$message}' but got '{$this->lastError}'" ); } } /** * @Then the following operation should be logged: */ public function theFollowingOperationShouldBeLogged(TableNode $table): void { $expectedLog = $table->getRowsHash(); $AuditLogs = TableRegistry::getTableLocator()->get('AuditLogs'); $lastOperation = $AuditLogs->find()->orderByDesc('created')->first(); Assert::assertNotNull($lastOperation, 'No operation was logged'); Assert::assertEquals($expectedLog['model'], $lastOperation->model); Assert::assertEquals($expectedLog['operation'], $lastOperation->operation); $expectedData = []; foreach (explode(', ', $expectedLog['data']) as $pair) { [$key, $value] = explode('=', $pair); $expectedData[$key] = $value; } Assert::assertEquals($expectedData, json_decode($lastOperation->data, true)); } } And here's the Gherkin feature that describes tests for our reservation system: # tests/Behat/Features/Reservation/room_reservation.feature Feature: Room Reservation In order to stay at the hotel As a guest I need to be able to make room reservations Background: Given the following rooms exist: | id | number | type | capacity | base_price | status | | 1 | 101 | standard | 2 | 100.00 | available | | 2 | 201 | suite | 4 | 200.00 | available | | 3 | 301 | deluxe | 3 | 150.00 | available | And the following guests exist: | id | name | email | phone | loyalty_level | | 1 | John Smith | [email protected] | 1234567890 | gold | | 2 | Jane Doe | [email protected] | 0987654321 | silver | | 3 | Bob Wilson | [email protected] | 5555555555 | bronze | And the following reservations exist: | id | room_id | check_in | check_out | status | guest_id | total_price | primary_guest_id | | 1 | 2 | 2025-06-01 | 2025-06-05 | confirmed | 2 | 200.00 | 2 | Scenario: Successfully make a room reservation Given I am authenticated as "John Smith" When I try to reserve room "101" for the following stay: | check_in | 2025-07-01 | | check_out | 2025-07-05 | And I add "Bob Wilson" as an additional guest Then the reservation should be confirmed And the total price should be "360.00" And the following operation should be logged: | model | Reservations | | operation | reservation_created | | data | room_number=101, guest_name=John Smith, check_in=2025-07-01, check_out=2025-07-05, total_price=360, additional_guests=1 | Scenario: Cannot reserve an already booked room Given I am authenticated as "John Smith" When I try to reserve room "201" for the following stay: | check_in | 2025-06-03 | | check_out | 2025-06-07 | Then I should see an error "Room is not available for selected dates" Scenario: Cannot exceed room capacity Given I am authenticated as "John Smith" When I try to reserve room "101" for the following stay: | check_in | 2025-08-01 | | check_out | 2025-08-05 | And I add "Jane Doe" as an additional guest And I add "Bob Wilson" as an additional guest Then I should see an error "Total number of guests (3) exceeds room capacity (2)" Scenario: Apply loyalty discounts correctly Given I am authenticated as "Jane Doe" When I try to reserve room "301" for the following stay: | check_in | 2025-09-01 | | check_out | 2025-09-04 | Then the reservation should be confirmed And the total price should be "427.5" And the following operation should be logged: | model | Reservations | | operation | reservation_created | | data | room_number=301, guest_name=Jane Doe, check_in=2025-09-01, check_out=2025-09-04, total_price=427.5, additional_guests=0 | The test context mirrors our DCI implementation in several ways:
  1. Role Assignment: Just as our DCI implementation attaches roles to objects, our test context manages the state of actors (guests and rooms) involved in the reservation process.
  2. Context Creation: The test creates a RoomReservationContext with all necessary participants, similar to how our application would in production.
  3. Behavior Verification: Tests verify both successful scenarios and error conditions, ensuring our DCI roles enforce business rules correctly.
Last two scenarios demonstrate how BDD tests can effectively verify:
  1. Role Constraints: The ReservableRoom role's capacity constraints
  2. Role Behaviors: The ReservingGuest role's discount calculations
  3. Context Orchestration: The RoomReservationContext's coordination of multiple roles
The combination of DCI and BDD provides several benefits:
  • Clear Specifications: Gherkin scenarios serve as living documentation of system behavior
  • Role Verification: Each test verifies that roles implement their responsibilities correctly
  • Context Validation: Tests ensure that contexts properly orchestrate role interactions
  • Business Rule Enforcement: Scenarios verify that business rules are properly enforced through roles

Money Transfer Testing Example

Before concluding, let's look at how we tested the money transfer system from our previous article. This example demonstrates how BDD tests can effectively verify DCI pattern implementation: Feature: Money Transfer In order to move money between accounts As an account holder I need to be able to transfer funds between accounts # Setup initial test data Background: Given the following accounts exist: | id | balance | account_type | status | is_frozen | | 1 | 1000.00 | checking | active | false | | 2 | 500.00 | savings | active | false | | 3 | 200.00 | checking | active | true | | 4 | 300.00 | deposit_only | active | false | # Tests basic transfer functionality and audit logging Scenario: Successful transfer between active accounts When I transfer "200.00" from account "1" to account "2" Then account "1" should have balance of "800.00" And account "2" should have balance of "700.00" # Verifies that all transfer steps are properly logged And an audit log should exist with: | foreign_key | operation | | 1 | pre_withdrawal | | 1 | post_withdrawal | | 2 | pre_deposit | | 2 | post_deposit | # Verifies role constraints - frozen accounts cannot perform withdrawals Scenario: Cannot transfer from frozen account When I try to transfer "100.00" from account "3" to account "2" Then I should get an error "Source cannot withdraw this amount" And account "3" should have balance of "200.00" And account "2" should have balance of "500.00" # Verifies business rule - insufficient funds Scenario: Cannot transfer more than available balance When I try to transfer "1200.00" from account "1" to account "2" Then I should get an error "Source cannot withdraw this amount" And account "1" should have balance of "1000.00" And account "2" should have balance of "500.00" This feature file tests several key aspects of our DCI implementation:
  1. Role Behavior Testing
    • TransferSource role's withdrawal capabilities
    • TransferDestination role's deposit functionality
    • Role constraints (frozen accounts, insufficient funds)
  2. Context Orchestration
    • Proper execution of the transfer process
    • Transaction atomicity (all-or-nothing transfers)
    • Proper cleanup of role assignments
  3. Business Rules Verification
    • Balance constraints
    • Account status restrictions
    • Audit trail requirements
  4. Error Handling
    • Proper error messages for various failure scenarios
    • State preservation after failed transfers
    • Role constraint violations
These tests ensure that our DCI implementation maintains system integrity while enforcing business rules through role behaviors and context coordination.

Conclusion

Testing DCI implementations with Behat creates a perfect match between how we build our software and how we test it. Let's look at why this combination works so well: First, Behat's behavior-driven approach matches naturally with DCI's focus on what objects do rather than just what they are. When we write tests in Gherkin language, we describe actions and their results - just like DCI describes roles and their behaviors. This makes our tests easier to understand and write because they follow the same thinking pattern as our code. Second, both DCI and BDD focus on real-world scenarios. DCI helps us organize code around actual use cases (like making a room reservation or transferring money), while Behat lets us write tests that directly reflect these same use cases. This means our tests read like a story of what the system should do, making them valuable not just for testing but also as living documentation. Additionally, the way Behat structures tests with "Given-When-Then" steps fits perfectly with how DCI contexts work:
  • "Given" steps set up our data objects
  • "When" steps trigger the context's actions
  • "Then" steps verify that roles performed their behaviors correctly
This natural alignment between DCI and BDD testing makes our development process more straightforward and our tests more reliable. We can be confident that our tests are checking the right things because they're structured in the same way as the system they're testing.

Demo Project for Article

The complete example, including all tests and implementations, is available at: https://github.com/skie/cakephp-dci. This article is part of the CakeDC Advent Calendar 2024 (December 19th 2024)

We Bake with CakePHP