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.

Other artciles of the series

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

PHP 8.5 Pipe Operator: A New Era of Readable Code

This article is part of the CakeDC Technical Blog Series (5th December 2025)

PHP 8.5 Pipe Operator: A New Era of Readable Code

The PHP 8.5 pipe operator brings a powerful new way to write clear, maintainable code. Drawing inspiration from functional programming languages and Unix command-line tools, this feature transforms how we chain operations and handle data flow in our applications.

Background: What is Piping and the Pipe Operator

The concept of piping originates from Unix systems in the 1960s, where Douglas McIlroy introduced the pipe symbol (|) to connect commands together. Each command processes data and passes the result to the next command, creating a smooth flow of information: cat users.txt | grep "active" | sort | uniq This simple pattern revolutionized how programmers think about data transformation. Instead of storing intermediate results in variables or nesting function calls, piping lets us read code from left to right, following the natural flow of data as it transforms step by step. Modern programming languages embraced this concept through the pipe operator. Elixir uses |>, F# has its pipe-forward operator, and R provides the %>% pipe from the magrittr package. Each implementation shares the same core idea: take the result from one expression and feed it as input to the next function.

The Journey to PHP 8.5

PHP developers have long wanted a native pipe operator. Before PHP 8.5, we worked around this limitation using various creative approaches. One common pattern involved custom pipe functions using closures and array reduction: function pipe(...$functions) { return fn($input) => array_reduce( $functions, fn($carry, $fn) => $fn($carry), $input ); } $transform = pipe( fn($text) => trim($text), fn($text) => strtoupper($text), fn($text) => str_replace('HELLO', 'GOODBYE', $text) ); echo $transform(" hello world "); This approach works, but it requires extra boilerplate and doesn't feel as natural as a language-level operator. The PHP 8.5 pipe operator (|>) changes everything by making piping a first-class language feature.

Understanding the Pipe Operator Syntax

The pipe operator in PHP 8.5 uses the |> symbol to pass values through a chain of transformations. Here's the basic pattern: $result = " hello world " |> (fn($text) => trim($text)) |> (fn($text) => strtoupper($text)) |> (fn($text) => str_replace('HELLO', 'GOODBYE', $text)); // Result: "GOODBYE WORLD" Each closure receives the result from the previous step and returns a new value. The pipe operator automatically passes this value to the next closure in the chain. Notice how we wrap each closure in parentheses - this is required by the PHP 8.5 implementation to ensure proper parsing.

The Short Syntax with Spread Operator

When a pipe step simply passes its input directly to a function without transformation, spread operator provides a cleaner syntax: // Verbose: wrapping in a closure $result = " hello " |> (fn($text) => trim($text)) |> (fn($text) => strtoupper($text)); // Clean: using spread operator $result = " hello " |> trim(...) |> strtoupper(...); The ... syntax tells PHP "pass whatever comes from the pipe as arguments to this function." This works beautifully when you're not transforming the data between steps, making your pipelines even more readable. The real power emerges when we combine pipes with pattern matching and result types, creating clear, maintainable code that handles both success and failure cases elegantly.

Adopting Elixir Phoenix Style in CakePHP Controllers

This article demonstrates a particular approach: bringing the elegant functional patterns from Elixir's Phoenix framework to CakePHP's controller layer. Phoenix developers are familiar with piping data through transformations, using pattern matching for control flow, and explicitly handling success and error cases through result types. These patterns have proven themselves in production applications, making code more maintainable and easier to reason about. By combining PHP 8.5's pipe operator with custom result types, we can write CakePHP controllers that feel similar to Phoenix controllers while staying true to PHP's object-oriented nature. Instead of nested conditionals and scattered error checks, we create clear pipelines where data flows from one transformation to the next. The Result and FormResult classes mirror Elixir's tagged tuples ({:ok, data} and {:error, reason}), giving us the same expressiveness for handling outcomes. This isn't about replacing CakePHP's conventions - it's about enhancing them. We still use CakePHP's ORM, validation, and view rendering, but we organize the control flow in a more functional style. The result is controller code that reads like a story: fetch the data, validate it, save it, send notifications, redirect the user. Each step is explicit, each error case is handled, and the overall flow is immediately clear to anyone reading the code.

Building Blocks: Result Types for Functional Flow

Before diving into practical examples, we need to establish our foundation: result types that represent success and failure outcomes. These classes work hand-in-hand with the pipe operator to create robust, type-safe data flows.

The Result Class: Success or Error

The Result class represents any operation that can succeed or fail. It's a simple but powerful abstraction that eliminates messy error handling and null checks: <?php declare(strict_types=1); namespace App\Result; use Exception; /** * Result type for functional programming pattern * * @template T */ class Result { public function __construct( public readonly string $status, public readonly mixed $data = null ) { } public static function ok(mixed $data): self { return new self('ok', $data); } public static function error(mixed $data): self { return new self('error', $data); } public function match(callable $ok, callable $error): mixed { return match ($this->status) { 'ok' => $ok($this->data), 'error' => $error($this->data), default => throw new Exception('Unknown result status') }; } public function isOk(): bool { return $this->status === 'ok'; } public function isError(): bool { return $this->status === 'error'; } } The Result class uses PHP 8.0's constructor property promotion and readonly properties to create an immutable container. We can create results using static factory methods: Result::ok($data) for success cases and Result::error($data) for failures. The match() method provides pattern matching - we give it two functions (one for success, one for error) and it automatically calls the right one based on the result's status. This eliminates conditional logic and makes our code more declarative.

The FormResult Class: Rendering Responses

While Result handles business logic outcomes, FormResult specializes in web application responses. It represents the two main actions a controller can take: redirect to another page or render a template: <?php declare(strict_types=1); namespace App\Result; use Exception; /** * Form result type for controller actions */ class FormResult { private ?string $flashMessage = null; private string $flashType = 'success'; public function __construct( public readonly string $type, public readonly mixed $data = null ) { } public static function redirect(string $url): self { return new self('redirect', $url); } public static function render(string $template, array $vars): self { return new self('render', ['template' => $template, 'vars' => $vars]); } public function withFlash(string $message, string $type = 'success'): self { $this->flashMessage = $message; $this->flashType = $type; return $this; } public function getFlashMessage(): ?string { return $this->flashMessage; } public function getFlashType(): string { return $this->flashType; } public function match(callable $onRedirect, callable $onRender): mixed { return match ($this->type) { 'redirect' => $onRedirect($this->data), 'render' => $onRender($this->data['template'], $this->data['vars']), default => throw new Exception('Unknown result type') }; } } FormResult includes a fluent interface for adding flash messages through withFlash(). This method returns $this, allowing us to chain the flash message directly onto the result creation: FormResult::redirect('/posts') ->withFlash('Post created successfully!', 'success') Both result types use the same pattern matching approach, creating a consistent programming model throughout our application.

Viewing a Post: Simple Pipe Flow

Let's start with a straightforward example: viewing a single post. This action demonstrates the basic pipe operator pattern and how FormResult handles different outcomes.

The View Action

public function view($id = null) { return $id |> $this->findPost(...) |> (fn($post) => $post ? FormResult::render('view', ['post' => $post]) : FormResult::redirect('/posts') ->withFlash('Post not found', 'error')) |> $this->handleFormResult(...); } This compact method demonstrates the elegance of pipe-based programming. Let's trace how data flows through each step.

Step 1: Starting with the ID

return $id |> $this->findPost(...) We begin with the post ID parameter. The pipe operator passes this ID directly to findPost() using the spread operator syntax. This clean notation means "take the piped value and pass it as the argument to findPost()". The method attempts to retrieve the post from the database.

The findPost Helper

private function findPost(string|int $id): mixed { try { return $this->Posts->get($id); } catch (\Exception $e) { return null; } } This helper method wraps the database query in a try-catch block. If the post exists, we return the entity. If it doesn't exist or any error occurs, we return null. This simple pattern converts exceptions into nullable returns, making them easier to handle in our pipe flow.

Step 2: Making a Decision

|> (fn($post) => $post ? FormResult::render('view', ['post' => $post]) : FormResult::redirect('/posts') ->withFlash('Post not found', 'error')) The second step receives either a Post entity or null. Using a ternary operator, we create different FormResult objects based on what we received. When the post exists, we create a render result containing the post data. When the post is null, we create a redirect result with an error message. Notice how the flash message chains directly onto the redirect using withFlash() - this fluent interface keeps the code clean and expressive.

Step 3: Converting to HTTP Response

|> $this->handleFormResult(...); The final step takes our FormResult and converts it into a CakePHP HTTP response. Let's look at this helper method: private function handleFormResult(FormResult $result): Response|null { if ($result->getFlashMessage()) { $this->Flash->{$result->getFlashType()}(__($result->getFlashMessage())); } return $result->match( onRedirect: fn($url) => $this->redirect($url), onRender: fn($template, $vars) => $this->renderResponse($template, $vars) ); } First, we check if the result contains a flash message. If it does, we set it using CakePHP's Flash component. The dynamic method call $this->Flash->{$result->getFlashType()} allows us to call success(), error(), or warning() based on the flash type. Then we use pattern matching to handle the two possible result types. For redirects, we call CakePHP's redirect() method. For renders, we delegate to another helper: private function renderResponse(string $template, array $vars): Response|null { foreach ($vars as $key => $value) { $this->set($key, $value); } return $this->render($template); } This helper extracts all variables from the FormResult and sets them as view variables, then renders the specified template.

The Complete Data Flow

Let's visualize how data flows through the view action: Input: $id (e.g., "123") ↓ findPost($id) ↓ Post entity or null ↓ Ternary decision: - If Post: FormResult::render('view', ['post' => $post]) - If null: FormResult::redirect('/posts')->withFlash('...') ↓ handleFormResult($result) ↓ - Set flash message (if present) - Pattern match on result type: * redirect: return $this->redirect($url) * render: return $this->renderResponse($template, $vars) ↓ HTTP Response to browser Each step in this flow has a single responsibility, making the code easy to understand and test. The pipe operator connects these steps without requiring intermediate variables or nested function calls.

Editing a Post: Complex Pipeline with Validation

Editing a post involves more complexity: we need to find the post, validate the submitted data, save changes, and provide appropriate feedback. This scenario showcases the real power of combining pipes with result types.

The Edit Action

public function edit($id = null) { if ($this->request->is(['patch', 'post', 'put'])) { return [$id, $this->request->getData()] |> (fn($context) => $this->findAndValidate(...$context)) |> (fn($result) => $result->match( ok: fn($data) => $this->savePost($data), error: fn($error) => Result::error($error))) |> (fn($result) => $result->match( ok: fn($post) => FormResult::redirect('/posts') ->withFlash('The post has been updated!', 'success'), error: fn($error) => FormResult::render('edit', $error) ->withFlash('The post could not be saved. Please, try again.', 'error'))) |> $this->handleFormResult(...); } return $id |> $this->findPost(...) |> (fn($post) => $post ? FormResult::render('edit', ['post' => $post]) : FormResult::redirect('/posts') ->withFlash('Post not found', 'error')) |> $this->handleFormResult(...); } This method handles two scenarios: GET requests to display the edit form, and POST/PUT requests to save changes. Let's explore the POST request flow in detail.

Step 1: Creating the Context

return [$id, $this->request->getData()] |> (fn($context) => $this->findAndValidate(...$context)) We start by creating an array containing both the post ID and the form data. The pipe operator passes this array to the next step, where we use the spread operator (...$ctx) to unpack it into individual arguments for findAndValidate(). This makes it clear that we're passing the ID and data as separate parameters rather than working with array indexes like $context[0] and $context[1].

Finding and Validating Together

private function findAndValidate(string|int $id, array $data): Result { $post = $this->findPost($id); if (!$post) { return Result::error([ 'post' => null, 'errors' => ['Post not found'], ]); } $validation = $this->validatePost($data); if ($validation->isError()) { return Result::error([ 'post' => $post, 'errors' => $validation->data, ]); } return Result::ok([ 'post' => $post, 'data' => $validation->data, ]); } This method performs two checks in sequence. First, we verify the post exists. If it doesn't, we return an error Result immediately. If the post exists, we validate the submitted data: private function validatePost(array $data): Result { $post = $this->Posts->newEmptyEntity(); $post = $this->Posts->patchEntity($post, $data); if ($post->hasErrors()) { return Result::error($post->getErrors()); } return Result::ok($data); } The validation creates a new entity and patches it with the submitted data. If CakePHP's validation rules find any problems, we return a Result::error() with the validation errors. Otherwise, we return Result::ok() with the validated data. This two-step validation ensures we have both a valid post ID and valid form data before proceeding. The Result type makes it easy to handle errors at each step without nested if-else blocks.

Step 2: Saving the Post

|> (fn($result) => $result->match( ok: fn($data) => $this->savePost($data), error: fn($error) => Result::error($error))) Now we have a Result that either contains our post and validated data, or an error. Pattern matching handles both cases elegantly. On the success path, we call savePost() with the validated data. On the error path, we simply pass the error through unchanged. This is a key pattern in pipe-based programming: errors propagate automatically through the pipeline without special handling. The match() call ensures type consistency since both branches return a Result object.

The savePost Helper

private function savePost(array $context): Result { $post = $this->Posts->patchEntity($context['post'], $context['data']); if ($this->Posts->save($post)) { return Result::ok($post); } return Result::error([ 'post' => $post, 'errors' => $post->getErrors() ?: ['Save failed'], ]); } This method patches the existing post entity with the validated data and attempts to save it. If saving succeeds, we return Result::ok() with the updated post. If saving fails, we return Result::error() with any validation errors from the database.

Step 3: Creating the Response

|> (fn($result) => $result->match( ok: fn($post) => FormResult::redirect('/posts') ->withFlash('The post has been updated!', 'success'), error: fn($error) => FormResult::render('edit', $error) ->withFlash('The post could not be saved. Please, try again.', 'error'))) The third step transforms our Result into a FormResult. Again, pattern matching handles both cases. On success, we create a redirect with a success message. On error, we re-render the edit form with the error data and an error message. Notice how errors from any previous step automatically flow to this error handler. Whether validation failed in step 1 or saving failed in step 2, we end up here with the appropriate error information to show the user.

Step 4: Converting to HTTP Response

|> $this->handleFormResult(...); The final step uses the same handleFormResult() method we saw in the view action, converting our FormResult into an HTTP response. The spread operator syntax keeps this final step clean and readable.

Visualizing the Edit Flow

The complexity of the edit action becomes clearer with a sequence diagram showing how data flows through each transformation: sequenceDiagram participant User participant Controller participant Pipeline participant Helpers participant Database User->>Controller: POST /posts/edit/123 Controller->>Pipeline: [$id, $data] Note over Pipeline: Step 1: Find & Validate Pipeline->>Helpers: findAndValidate(123, $data) Helpers->>Database: Get post by ID alt Post not found Database-->>Helpers: null Helpers-->>Pipeline: Result::error(['Post not found']) Pipeline->>Pipeline: Skip to Step 3 (error path) else Post found Database-->>Helpers: Post entity Helpers->>Helpers: Validate form data alt Validation failed Helpers-->>Pipeline: Result::error(['errors' => [...]]) Pipeline->>Pipeline: Skip to Step 3 (error path) else Validation passed Helpers-->>Pipeline: Result::ok(['post' => $post, 'data' => $validData]) Note over Pipeline: Step 2: Save Post Pipeline->>Helpers: savePost(['post' => $post, 'data' => $validData]) Helpers->>Database: Save updated post alt Save failed Database-->>Helpers: false Helpers-->>Pipeline: Result::error(['errors' => [...]]) Pipeline->>Pipeline: Continue to Step 3 (error path) else Save successful Database-->>Helpers: true Helpers-->>Pipeline: Result::ok($updatedPost) Note over Pipeline: Step 3: Create Response Pipeline->>Pipeline: FormResult::redirect('/posts') Pipeline->>Pipeline: ->withFlash('Success!', 'success') end end end Note over Pipeline: Step 4: Handle Result Pipeline->>Helpers: handleFormResult($formResult) Helpers->>Controller: HTTP Response Controller->>User: Redirect or render edit form This diagram illustrates several important aspects of our pipeline: Error Propagation: When an error occurs at any step, it flows through the remaining steps until reaching the error handler in Step 3. We don't need explicit error checking at each level. Type Transformations: Notice how data types evolve through the pipeline:
  • Start: [int, array] (ID and form data)
  • After Step 1: Result<array> (post and validated data, or errors)
  • After Step 2: Result<Post> (saved post, or errors)
  • After Step 3: FormResult (redirect or render decision)
  • After Step 4: Response (HTTP response)
Decision Points: Each match() call represents a decision point where the pipeline branches based on success or failure. These branches merge back into a common FormResult type, ensuring consistent handling at the end.

The GET Request Flow

The GET request handling in the edit action is simpler, following the same pattern we saw in the view action: return $id |> $this->findPost(...) |> (fn($post) => $post ? FormResult::render('edit', ['post' => $post]) : FormResult::redirect('/posts') ->withFlash('Post not found', 'error')) |> $this->handleFormResult(...); We find the post, create a FormResult based on whether it exists, and convert it to an HTTP response. The pipe operator makes this three-step process read naturally from top to bottom.

Benefits and Patterns

Working with the pipe operator reveals several powerful patterns that improve our code quality.

Linear Reading Flow

Traditional nested function calls or method chains force us to read code inside-out or bottom-up: // Without pipes: read from inside to outside return $this->handleFormResult( $this->findPost($id) ? FormResult::render('view', ['post' => $this->findPost($id)]) : FormResult::redirect('/posts')->withFlash('Not found', 'error') ); The pipe operator lets us read top-to-bottom, following the natural flow of data: // With pipes: read from top to bottom return $id |> $this->findPost(...) |> (fn($post) => $post ? FormResult::render(...) : FormResult::redirect(...)) |> $this->handleFormResult(...);

Debugging Made Easy

When debugging a pipeline, we can easily insert a tap() function to inspect values at any point without disrupting the flow: private function tap(mixed $value, string $label = 'Debug'): mixed { debug("{$label}: " . json_encode($value, JSON_PRETTY_PRINT)); return $value; } Then add it anywhere in the pipeline: return [$id, $this->request->getData()] |> (fn($context) => $this->tap($context, 'Context')) |> (fn($context) => $this->findAndValidate(...$context)) |> (fn($result) => $this->tap($result, 'After validation')) |> (fn($result) => $result->match(...)) The tap() function logs the value and returns it unchanged, letting us peek into the pipeline without modifying its behavior.

Type Safety Through the Pipeline

Each step in our pipeline has clear input and output types. The Result and FormResult classes enforce type consistency, making it impossible to accidentally pass the wrong data type to the next step. PHP's type system, combined with these result types, catches errors at development time rather than runtime.

Separation of Concerns

Each helper method has a single, clear purpose. The findPost() method handles database retrieval, while validatePost() focuses on data validation. The savePost() method takes care of database persistence, and handleFormResult() manages HTTP response generation. The pipe operator connects these focused functions into a complete workflow. This separation makes each function easy to test in isolation while maintaining a clear picture of the overall process.

Error Handling Without Try-Catch

The Result type eliminates the need for try-catch blocks throughout our code. Instead of throwing and catching exceptions, we return Result::error() and use pattern matching to handle failures. This approach makes error handling explicit and forces us to consider both success and failure paths.

Practical Considerations

Performance

You might wonder if all these function calls and object creations impact performance. In practice, the overhead is negligible. Modern PHP's opcache optimizes these patterns effectively, and the benefits in code maintainability far outweigh any microscopic performance difference.

Learning Curve

Developers new to functional programming patterns might initially find pipes and result types unfamiliar. However, once the concepts click, most developers find this style more intuitive than traditional imperative code. The linear flow and explicit error handling reduce cognitive load compared to nested conditionals and scattered error checks.

When to Use Pipes

The pipe operator shines in scenarios with multiple sequential transformations. Form processing workflows benefit greatly from pipes as they typically involve validating data, saving it to the database, sending notifications, and finally redirecting the user. Data transformation pipelines that fetch, filter, transform, and format information also work beautifully with pipes. Multi-step business processes like checking inventory, calculating prices, creating orders, and sending confirmations become more readable when expressed as pipe chains. For simple operations with just one or two steps, traditional code often reads better. Consider a basic calculation that needs no error handling: // Overkill with pipes - harder to read $total = $items |> (fn($items) => array_sum(array_column($items, 'price'))) |> (fn($sum) => $sum * 1.2); // Clearer without pipes $subtotal = array_sum(array_column($items, 'price')); $total = $subtotal * 1.2; Similarly, simple database queries don't benefit from piping: // Unnecessary complexity with pipes $posts = [] |> (fn() => $this->Posts->find()) |> (fn($query) => $query->where(['status' => 'published'])) |> (fn($query) => $query->all()); // Much clearer as method chain $posts = $this->Posts->find() ->where(['status' => 'published']) ->all(); Use pipes when they genuinely improve readability and maintainability, particularly when handling multiple transformations with different return types or error handling needs.

Conclusion

The PHP 8.5 pipe operator brings functional programming elegance to PHP without sacrificing the language's pragmatic, object-oriented roots. By combining pipes with result types and pattern matching, we can write code that clearly expresses intent, handles errors gracefully, and remains easy to test and maintain. The examples in this article demonstrate how pipes transform complex controller actions into readable, step-by-step transformations. Each step has a clear purpose, errors flow naturally through the pipeline, and the final code reads like a description of what happens rather than a series of imperative commands. As PHP continues to evolve, features like the pipe operator show the language's commitment to adopting the best ideas from functional programming while staying true to its accessible, practical nature. Whether you're building simple CRUD applications or complex business workflows, the pipe operator gives you a powerful new tool for writing better code. This article is part of the CakeDC Technical Blog Series (5th December 2025)

CakePHP and the Power of Artificial Intelligence

This article is part of the CakeDC Advent Calendar 2025 (December 2th 2025)

Bringing smart automation to modern web development

When we talk about Artificial Intelligence today, we are not talking about the future, we are talking about tools we already use every day, such as our phones, code editors, browsers and productivity apps. For developers, AI represents a new wave of innovation that allows us to embed intelligence directly into our projects to build smarter, more adaptive, and more valuable digital products. At CakeDC, we’ve been exploring how CakePHP 5 can be seamlessly integrated with AI to deliver powerful, automated, and intelligent solutions.

Why combine CakePHP and AI?

Both technologies share a core philosophy: efficiency and structure. CakePHP offers a clean MVC framework, robust validation, and an ORM that keeps your data organized and secure. On the other hand, AI brings reasoning, summarization, and contextual understanding to your application. By combining them, we can:
  • Automate repetitive processes.
  • Enhance user experience.
  • Add value to existing products.
  • Unlock new opportunities for digital innovation.
The result? Smarter apps with a strong core.

What AI means today

AI enhances productivity not by replacing people, but by amplifying human capabilities. It helps analyze data, generate content, automate workflows, and make better decisions faster. And thanks to APIs like OpenAI’s, this power is now accessible to every PHP developer. Imagine a world where your CakePHP app can:
  • Understand natural language input.
  • Summarize uploaded reports.
  • Classify customer feedback.
  • Generate tailored content or recommendations.
That work is already here.

Real use cases with CakePHP + AI

Here are some real examples of how we’re integrating AI into CakePHP projects:
  • Document upload with automatic summaries or data extraction.
  • Customer support chatbots directly embedded in web portals.
  • Image analysis for quality control or content tagging.
  • Smart products or content recommendations.
  • Automated reporting and document generation.
Each of these features leverages the same clean CakePHP architecture (controllers, services, and models) combined with a simple AI API call.

Technical integration made simple

Here’s how easy it is to call an AI model directly from your CakePHP app: use Cake\Http\Client; $http = new \http\Client(); $response = $http->post( 'https://api.openai.com/v1/chat/completions', [ 'model' => 'gpt-4o-mini', 'messages' => [ ['role' => 'system', 'content' => 'You are an assistant.'], ['role' => 'user', 'content' => 'Summarize this text...'], ], ], [ 'headers' => [ 'Authorization' => 'Bearer ' . Configure::Read('OPENAI_API_KEY'), 'Content-Type' => 'application/json', ], ], ); $result = $response->getJson(); From there, you simply parse the JSON response, store or display the data, and integrate it into your workflow. The simplicity of CakePHP’s Http Client makes this process smooth and reliable.

Challenges and best practices

As with any emerging technology, integrating AI comes with responsibilities and considerations:
  • Manage API costs efficiently by batching requests or caching responses.
  • Respect user privacy and comply with GDPR, especially when handling sensitive data.
  • Implement robust error handling and retry logic for API calls.
  • Log and monitor AI interactions for transparency and quality assurance.
  • Use AI responsibly — as a tool to empower developers and users, not to replace them.

Looking ahead

The combination of CakePHP and AI opens exciting possibilities for the next generation of web applications: fast, smart, and secure. AI is not a replacement, it’s an enhancement. And with CakePHP’s solid foundation, developers can bring these intelligent capabilities to life faster than ever. This article is part of the CakeDC Advent Calendar 2025 (December 2th 2025)

The CakeDC Advent Calendar is BACK!

It’s the most wonderful time of the year! I don’t just mean the holidays… I’m talking about the CakeDC Advent Calendar!    If you missed it last year, we put together a series of blog posts in the form of a holiday advent calendar. Each day, you will get to open the gift of a new article written by one of our team members. You can wake up every morning in December with Cake(PHP). Does it get any better?    So what can you expect this year?  Great topics like: 

  • CakePHP upgrades
  • Security tips
  • CakePHP and the power of AI
  • Supabase + CakePHP
  • CakePHP Horizontal Scaling
  • CakePHP and FrankenPHP
  • Advanced Exports in CakePHP 5
  • + so much more! 

  Enjoy our gift to you that lasts the whole month through (maybe I should write poems instead of blogs?).    While you wait, here are some links from last year’s calendar to hold you over: https://www.cakedc.com/yevgeny_tomenko/2024/12/21/cakedc-search-filter-plugin   https://www.cakedc.com/ajibarra/2024/12/12/almost-20-years-a-bit-of-history-about-cakephp   https://www.cakedc.com/jorge_gonzalez/2024/12/20/5-cakephp-security-tips
  See you tomorrow! 

We Bake with CakePHP