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

Closing Advent Calendar 2024

This article is part of the CakeDC Advent Calendar 2024 (December 24th 2024) That’s a wrap on the CakeDC 2024 advent calendar blog series. Did you get to read all of them? Hopefully you obtained some useful information to use in your future baking. We would love to get your feedback, feel free to share! It is still hard to believe that 2024 is almost over, but we are looking forward to an extraordinary 2025. On behalf of CakeDC, we want to thank our team for all the hours of hard work they put in this year. Also, thank you to our clients for trusting us with your CakePHP projects, it is an absolute pleasure getting to work with each of you. We are thankful for the great relationships we have built, or carried on in the last 12 months. For our CakePHP community, especially the core team, please know how incredibly grateful we are for your support of the framework. There is a reason that Cake is still around after 20 years, and it’s great developers like you, who dedicate their time and efforts to keep the code going. THANK YOU, THANK YOU, THANK YOU. As far as what is to come for CakePHP in 2025, stay tuned. However, I am told that there are some top secret (not really, we are opensource after all) talks about CakePHP 6 happening. With the release of PHP 8.4, I am sure some awesome features will be implemented in Cake specifically. We will also be celebrating 20 years of CakePHP next year, can you believe it? CakeFest will be in honor of all core members past and present, and it may be a good time to introduce some new ones as well. If you are a core member (or former), we would love to have you attend the conference this year. The location will be announced soon. Interested in getting involved or joining the core team? You can find some helpful links here: https://cakephp.org/get-involved We hope you enjoyed our gift this year, it’s the least we could do. Wishing you a happy holiday season from our CakeDC family to yours. See you next year! … sorry, I had to do it. :) Also, here are some final words from our President: Larry Masters.

A Christmas Message to the CakePHP Community

As we gather with loved ones to celebrate the joy and hope of the Christmas season, I want to take a moment to reflect on the incredible journey we’ve shared this year as part of the CakePHP community. This is a special time of year when people around the world come together to celebrate love, grace, and the hope that light brings into the world. It’s also a time to give thanks for the connections that make our lives richer. The CakePHP framework has always been about more than just code, it’s about people. It’s the collective effort of contributors from around the world who believe in building something better, together. To everyone who has shared their expertise, contributed code, written documentation, tested features, or offered guidance to others, I want to express my deepest gratitude for your dedication and passion. As we approach 2025, it brings even greater meaning to reflect on how far we’ve come. Next year marks the 20th anniversary of CakePHP. From the first lines of code to the projects we support today, the journey has been nothing short of remarkable. As we look ahead to the new year, let us carry forward this spirit of generosity, collaboration, and unity. Together, we can continue to empower developers, build exceptional tools, and foster a community that is inclusive, welcoming, and supportive. On behalf of everyone at Cake Development Corporation, I wish you and your families a blessed Christmas filled with peace, joy, and love. May the new year bring us more opportunities to create, connect, and grow together. Thank you for being part of this journey. Merry Christmas and a very Happy New Year to everyone. With gratitude, Larry Masters This article is part of the CakeDC Advent Calendar 2024 (December 24th 2024)

Railway Oriented Programming: A Functional Approach to Error Handling

This article is part of the CakeDC Advent Calendar 2024 (December 23rd 2024) Scott Wlaschin, a well known figure in the functional programming community, introduced the Railway Oriented Programming (ROP) pattern in his presentations and blog posts. His innovative approach to error handling has revolutionized how developers think about managing failures in their applications. Drawing inspiration from railway switches and tracks, Wlaschin created a metaphor that makes complex functional programming concepts more accessible to mainstream developers.

The Two-Track Model

At its core, Railway Oriented Programming visualizes data flow as a railway system with two parallel tracks: the success track and the failure track. This metaphor provides an elegant way to understand how data moves through an application while handling both successful operations and errors. Unlike traditional error handling with try-catch blocks or null checks, ROP treats success and failure as equal citizens, each flowing along its own track. This approach eliminates the need for nested error checking and creates a more linear, maintainable flow of operations.

Understanding Track Combinations

The railway model introduces several types of functions based on how they handle inputs and outputs. The simplest is the one-track function (1-1), which operates only on successful values, similar to a straight railway track. These functions take a value and return a value, without any concept of failure. Next, we have switch functions (1-2), which are like railway switches that can direct our train (data) onto either the success or failure track. Finally, two-track functions (2-2) operate on both success and failure cases, similar to a railway section that handles trains on both tracks.

PHP Implementation

The PHP Railway Programming library provides a robust implementation of these railway concepts through its Railway and Result classes. The Result class serves as our basic switch mechanism, while the Railway class provides the fluent interface for chaining operations. This implementation brings the elegance of functional programming's error handling to the PHP ecosystem, making it accessible to developers working with traditional object-oriented codebases.

Core Operations in Railway Programming

The map operation transforms values on the success track without affecting the failure track. It's like having a maintenance station that only services trains on the success track, letting failed trains pass by untouched on the failure track. This operation is perfect for simple transformations that can't fail. Conceptually, it accepts a 1-1 function and returns a 2-2 function. The lift operation transforms a regular one-track function into a switch function. Think of it as installing a safety system on a regular railway track - the function can now handle both success and failure cases. When we lift a function, we're essentially wrapping it in error handling capability, allowing it to participate in our two-track system. Conceptually, it accepts a 1-1 function and returns a 1-2 function. The bind operation is perhaps the most fundamental concept in ROP. It takes a switch function and adapts it to work with our two-track system. Imagine a railway junction where tracks can merge and split - bind ensures that success values continue on the success track while failures are automatically routed to the failure track. This operation is crucial for chaining multiple operations together while maintaining proper error handling. Conceptually, it accepts a switch 1-2 function and returns a 2-2 function. The tee operation is like a railway observation post - it allows us to perform side effects (like logging or monitoring) without affecting the train's journey on either track. It's particularly useful for debugging or adding analytics without disrupting the main flow of operations. Conceptually, it is a dead function that bypass the success or failure track. The tryCatch acts as a special kind of switch that can catch derailments (exceptions) and route them to the failure track. It's essential for integrating traditional try-catch error handling into our railway system, making it compatible with existing code that might throw exceptions. Conceptually, it accepts a 1-1 function and convert it into a 1-2 function. The plus and unite combinators are like complex railway junctions that can combine multiple tracks. Plus allows parallel processing of two separate railways, combining their results according to custom rules, and conceptually it accepts two 1-2 functions and returns a 1-2 function. The unite joins two railways sequentially, taking the result of the second railway if the first one succeeds. It conceptually accepts two 1-2 functions and join them into a 1-2 function. The doubleMap operation is a special kind of switch function that can handle both success and failure cases. It's like having a maintenance station that can service trains on both tracks, allowing us to transform values on both tracks without affecting the other. Conceptually, it accepts a 1-1 function and returns a 2-2 function.

Result Monad

The Result is a type that can be used to represent the result of a computation that can either succeed or fail. It is used for representing the computation in railway oriented programming flow.

Pattern matching

Pattern matching is a technique used to match the result of a computation against a set of patterns. It is used to extract the value of the result or handle the error case. Pattern matching in PHP Railway implementation serves as the final resolver for the two-track system, providing a clean way to extract values from either the success or failure track. The Railway::match method takes two callback functions: one for handling successful results and another for handling failures. This approach eliminates the need for manual checking of the Railway's state and provides a type-safe way to access the final values. In practical PHP applications, pattern matching becomes useful when we need to transform our Railway result into concrete actions or responses. For instance, when working with web frameworks, we can use pattern matching to either return a success response with the processed data or handle errors by throwing exceptions or returning error messages. This is more elegant than traditional conditional statements because it forces us to handle both cases explicitly and keeps the success and failure handling code clearly separated.

Practical Implementation: Room Reservation System

Let's explore a practical implementation of Railway Oriented Programming through a hotel room reservation system that we described in the Testing DCI with Behavior-Driven Development article. This example demonstrates how ROP can elegantly handle complex business processes with multiple potential failure points.

System Components

The reservation system consists of three main components:
  1. ReservationData Context
It acts as an immutable data container that holds all necessary information about a reservation, including room details, guest information, check-in/out dates, and various state data. The immutability is ensured through a withState method that creates new instances when state changes are needed. namespace App\Reservation; use Cake\I18n\DateTime; class ReservationData { public function __construct( public readonly array $room, public readonly array $primaryGuest, public readonly array $additionalGuests, public readonly DateTime $checkIn, public readonly DateTime $checkOut, private array $state = [] ) {} public function withState(string $key, mixed $value): self { $clone = clone $this; $clone->state[$key] = $value; return $clone; } public function getState(string $key): mixed { return $this->state[$key] ?? null; } }
  1. ReservationOperations
This class contains all the core business operations for the reservation process. Each operation is designed to work within the railway pattern, either returning successful results or failing gracefully. The operations include:
  • Availability validation and price calculation
  • Reservation creation in the database
  • Email confirmation sending
  • Loyalty points management
  • Audit logging
namespace App\Reservation; use Cake\Mailer\Mailer; use ROP\Railway; use Cake\ORM\TableRegistry; class ReservationOperations { public static function validateAvailability(ReservationData $data): Railway { $reservationsTable = TableRegistry::getTableLocator()->get('Reservations'); $existingReservation = $reservationsTable->find() ->where([ 'room_id' => $data->room['id'], 'status !=' => 'cancelled', ]) ->where(function ($exp) use ($data) { return $exp->or([ function ($exp) use ($data) { return $exp->between('check_in', $data->checkIn, $data->checkOut); }, function ($exp) use ($data) { return $exp->between('check_out', $data->checkIn, $data->checkOut); } ]); }) ->first(); if ($existingReservation) { return Railway::fail("Room is not available for selected dates"); } $totalGuests = count($data->additionalGuests) + 1; if ($totalGuests > $data->room['capacity']) { return Railway::fail( "Total number of guests ({$totalGuests}) exceeds room capacity ({$data->room['capacity']})" ); } $basePrice = $data->room['base_price'] * $data->checkIn->diffInDays($data->checkOut); $discount = match($data->primaryGuest['loyalty_level']) { 'gold' => 0.1, 'silver' => 0.05, default => 0 }; $finalPrice = $basePrice * (1 - $discount); return Railway::of($data->withState('total_price', $finalPrice)); } public static function createReservation(ReservationData $data): ReservationData { $reservationsTable = TableRegistry::getTableLocator()->get('Reservations'); $reservation = $reservationsTable->newEntity([ 'room_id' => $data->room['id'], 'primary_guest_id' => $data->primaryGuest['id'], 'check_in' => $data->checkIn, 'check_out' => $data->checkOut, 'status' => 'confirmed', 'total_price' => $data->getState('total_price'), 'reservation_guests' => array_map( fn($guest) => ['guest_id' => $guest['id']], $data->additionalGuests ), ]); if (!$reservationsTable->save($reservation)) { throw new \RuntimeException('Could not save reservation'); } return $data->withState('reservation_id', $reservation->id); } public static function logReservation(ReservationData $data): ReservationData { TableRegistry::getTableLocator()->get('Reservations')->logOperation( // ... ); return $data; } public static function sendConfirmationEmail(ReservationData $data): Railway { $result = rand(0,10); return $result > 2 ? Railway::of($data) : Railway::fail('Failed to send confirmation email'); } public static function updateGuestLoyaltyPoints(ReservationData $data): ReservationData { // ... return $data; } }
  1. ReservationController
This class acts as the controller for the reservation system. It handles the HTTP request, validates the input, and orchestrates the reservation process using the Railway class. The controller uses the ReservationOperations class to perform the necessary operations and handles the result of each operation using the Railway::match method. namespace App\Reservation; use ROP\Railway; class ReservationController { public function add() { $Rooms = $this->fetchTable('Rooms'); $Guests = $this->fetchTable('Guests'); $rooms = $Rooms->find('list')->where(['status' => 'available']); $guests = $Guests->find('list'); $this->set(compact('rooms', 'guests')); if ($this->request->is('post')) { try { $room = $Rooms->get($this->request->getData('room_id'))->toArray(); $primaryGuest = $Guests->get($this->request->getData('primary_guest_id'))->toArray(); $additionalGuests = []; if ($this->request->getData('additional_guest_ids')) { $additionalGuests = $Guests->find() ->where(['id IN' => $this->request->getData('additional_guest_ids')]) ->all() ->map(fn($guest) => $guest->toArray()) ->toArray(); } $data = new ReservationData( room: $room, primaryGuest: $primaryGuest, additionalGuests: $additionalGuests, checkIn: new DateTime($this->request->getData('check_in')), checkOut: new DateTime($this->request->getData('check_out')) ); $connection = $this->fetchTable('Reservations')->getConnection(); return $connection->transactional(function($connection) use ($data) { $result = ReservationOperations::validateAvailability($data) // First validate and calculate price ->map(fn($data) => $data->withState('reservation_time', time())) // Create reservation with error handling ->tryCatch(fn($data) => ReservationOperations::createReservation($data)) // Send confirmation email (might fail) ->bind(fn($data) => ReservationOperations::sendConfirmationEmail($data)) // Log the reservation (with error handling) ->tryCatch(fn($data) => ReservationOperations::logReservation($data)) // Update room status (simple transformation) ->map(fn($data) => $data->withState('room_status', 'occupied')) // Calculate loyalty points (simple transformation) ->map(fn($data) => $data->withState( 'loyalty_points', floor($data->getState('total_price') * 0.1) )) // Update guest loyalty points (with error handling) ->tryCatch(fn($data) => ReservationOperations::updateGuestLoyaltyPoints($data)) // Log all operations for audit ->tee(fn($data) => error_log(sprintf( "Reservation completed: %s, Points earned: %d", $data->getState('reservation_id'), $data->getState('loyalty_points') ))); return $result->match( success: function($data) { $this->Flash->success(__('Reservation confirmed! Your confirmation number is: {0}', $data->getState('reservation_id') )); return $this->redirect(['action' => 'view', $data->getState('reservation_id')]); }, failure: function($error) { if ($error instanceof \Exception) throw $error; throw new \RuntimeException($error); } ); }); } catch (\Exception $e) { $this->Flash->error(__('Unable to complete reservation: {0}', $e->getMessage())); } } } }

The Railway Flow

The reservation process showcases several key aspects of Railway Oriented Programming:
  1. Input Validation: The process begins with validating room availability and guest capacity, demonstrating how early failures can be handled gracefully.
  2. State Transformation: Throughout the process, the ReservationData object is transformed through various states while maintaining immutability.
  3. Error Handling: Each step can potentially fail, but the railway pattern keeps the error handling clean and predictable.
  4. Transaction Management: The entire process is wrapped in a database transaction, showing how ROP can work with traditional database operations.
  5. Side Effects: The pattern handles side effects (like sending emails and logging) in a controlled manner through the tee operation.
The sequence diagram illustrates how the Railway pattern creates a clear separation between success and failure paths, making it easier to reason about the system's behavior. This implementation shows that Railway Oriented Programming is not just a theoretical concept but a practical approach to handling complex business processes in real-world applications. sequenceDiagram participant C as Controller participant DB as Database participant E as Email participant R as Railway Track Note over R: Success Track ✅ Note over R: Failure Track ❌ C->>DB: Check Room Availability alt Room not available DB-->>R: ❌ "Room not available" R-->>C: Railway::fail else Room available DB-->>R: ✅ Room data Note over R: Validate Guest Count alt Exceeds capacity R-->>C: ❌ Railway::fail("Exceeds capacity") else Guest count OK R-->>C: ✅ Calculate price & set state C->>DB: Creating Reservation alt Save successful DB-->>R: ✅ reservation_id C->>E: Send Confirmation alt Email sent E-->>R: ✅ Continue else Email failed E-->>R: ❌ "Failed to send email" R-->>C: Railway::fail end C->>DB: Adding Audit Log DB-->>R: ✅ Continue C->>DB: Updating Loyalty Points alt Update successful DB-->>R: ✅ Final success R-->>C: Railway::of(data) else Update failed DB-->>R: ❌ "Failed to update points" R-->>C: Railway::fail end else Save failed DB-->>R: ❌ "Could not save reservation" R-->>C: Railway::fail end end end This room reservation system demonstrates several key benefits of Railway Oriented Programming:
  1. Clarity: The code clearly shows the flow of operations and potential failure points, making it easier to understand and maintain.
  2. Robustness: Error handling is comprehensive and consistent throughout the entire process.
  3. Maintainability: New steps can be easily added to the reservation process by extending the railway chain.
  4. Transaction Safety: The pattern works seamlessly with database transactions, ensuring data consistency.
  5. Testability: Each operation is isolated and can be tested independently, while the entire flow can be tested as a unit.
This example serves as a blueprint for implementing similar patterns in other business domains where complex workflows and error handling are required. It demonstrates how functional programming concepts can be successfully applied in a traditionally object-oriented environment like PHP.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-dci/tree/3.0.0 and available for testing. The controller code is located at src/Controller/RopReservationsController.php.

Conclusion

Railway Oriented Programming represents a paradigm shift in error handling, moving away from imperative try-catch blocks toward a more functional, flow-based approach. By visualizing our program as a railway system, we gain a powerful metaphor for understanding and managing the complexity of error handling in our applications. The PHP implementation of ROP brings these concepts to the PHP community, enabling developers to write more maintainable, readable, and robust code. This article is part of the CakeDC Advent Calendar 2024 (December 23rd 2024)

Using RBAC and rules for authorization

This article is part of the CakeDC Advent Calendar 2024 (December 22nd 2024) Authorization can become a complex topic. If you go over the options described in the CakePHP Book, https://book.cakephp.org/authorization/3/en/index.html and the specific tutorial https://book.cakephp.org/5/en/tutorials-and-examples/cms/authorization.html, you'll see that there are options to define the authorization in a very flexible way. In CakePHP, the Authorization Plugin will allow you to define subjects of authorization, entities that want to get access to one of these subjects, and rules to determine if the entities can have access to a given subject. Many CakePHP applications coming from versions 1,2,3 don't require a lot of flexibility because they define:

  • Subject: a plugin/prefix/Controller/action, like a "url" in our site, for example: "/admin/users/add"
  • Entity: a logged in user, or a guest user who is not logged in yet. Usually we'll group the users in a role, to allow assigning permissions per role
  • Rule: a function, returning true or false
In these cases, we can build an authorization table, like URL Role CanAccess? /admin/users/index admins yes /admin/users/index users no ... To apply these concepts in you CakePHP Application, you can use existing plugins like: But, following our spirit of simplicity, let's imagine you've implemented the CakePHP CMS Tutorial https://book.cakephp.org/5/en/tutorials-and-examples.html. Or, you can clone the project from here: https://github.com/cakephp/cms-tutorial. In this case, to enable url base authentication we would need to change:
  • composer require cakedc/auth
  • Update the AuthorizationService configuration to map the request object with a collection of policies
// src/Application::getAuthorizationService public function getAuthorizationService(ServerRequestInterface $request): AuthorizationServiceInterface { $map = new MapResolver(); $map->map( ServerRequest::class, new CollectionPolicy([ SuperuserPolicy::class, new RbacPolicy([ [ 'role' => '*', 'controller' => 'Pages', 'action' => 'display', ], // other rules // the rules could be stored in a configuration file or database ]), ]) ); $orm = new OrmResolver(); $resolver = new ResolverCollection([ $map, $orm, ]); return new AuthorizationService($resolver); }
  • The last piece to this approach would be adding the RequestAuthorizationMiddleware:
// src/Application::middleware public function middleware($middlewareQueue): \Cake\Http\MiddlewareQueue { $middlewareQueue // ... ->add(new AuthorizationMiddleware($this)) ->add(new RequestAuthorizationMiddleware());

How will it work?

For each request to your application, the RequestAuthorizationMiddleware will trigger an authorization check, looking for a Policy in the MapResolver. We'll check first if the user has the column is_superuser set as true, and if not, we'll use the Rbac to check if the user can access the given URL, using the routing parameters. The Rbac class provides a lot of flexibility to configure the rules, see https://github.com/CakeDC/auth/blob/8.next-cake5/Docs/Documentation/Rbac.md. Note: the users table included in the CMS Tutorial does not include a role column. If you want to define roles, you would need to add it using a Migration.

Recap

We've used the cms tutorial application from the CakePHP Book to implement a Request Authorization Rbac policy for simple applications looking for a way to determine if a given URL can be accessed by a user role. This article is part of the CakeDC Advent Calendar 2024 (December 22nd 2024)

We Bake with CakePHP