CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

Streaming database results

This article is part of the CakeDC Advent Calendar 2024 (December 17th 2024) This is a small tip related with an issue we fixed not so long ago. While executing a report, exporting the data as a csv file, we hit a max_memory issue. That's a very common issue when your tables grow bigger and the amount of data you need to export does not fit into the available memory for the PHP process. In that case you have a couple options like:

  • Push the report generation to a background job, then send the result link via email
  • Work with ob_start(), flush() etc
But today we are going to show a way to do it using CakePHP builtin methods. Let's imagine we are exporting a big Users table like: $users = $this->fetchTable('Users')->find(); foreach ($users as $user) { // handle the user row } Causing a 500 error due to a lack of memory to store all the users resultset. And we don't want to process anything in the background, we just want the user to "wait" for the result being generated (note this also could be an issue if it's too long). We could use the following approach in our controller: return $this->response ->withType('text/csv') // or other formats, in case of xml, json, etc ->withDownload('my-big-report.csv') ->withBody(new \Cake\Http\CallbackStream(function () { $users = $this->fetchTable('Users')->find() ->disableBufferedResults() ->disableHydration() ->toArray(); $outstream = fopen("php://output", 'w'); foreach ($users as $user) { fputcsv($outstream, $row); } })); A note about the solution
  • disableBufferedResults does not work for all datasources due to a limitation in PDO. It works for MySQL.
    • In case your are using a not compatible datasource, you'll need to paginate the query manually to get the results in chunks
So, using this approach we are paying time (more queries to the database, longer response time, user waiting) for RAM. Depending on your case, it could be a fair tradeoff. Marc (@ravage84) suggested in slack to also take a look at https://github.com/robotusers/cakephp-chunk too for a way to chunk the results of a query, processing them in a memory efficient way. This article is part of the CakeDC Advent Calendar 2024 (December 17th 2024)

New exciting features in PHP 8.4 Part III

This article is part of the CakeDC Advent Calendar 2024 (December 16th 2024) In this article, we will continue to explore the new features and improvements added in the latest version of PHP 8.4. This is the third article in this series, we encourage you to read the first and second article from the series. In this release, we have received several long-awaited improvements, improved functionality, and a modern approach to common tasks. These few seem to be the most interesting:

  • Lazy Objects
  • Object API for BCMath

Lazy Objects

Lazy objects are objects that are initialized when the state of the object is observed or modified. The use of this type of objects can take place in several scenarios:
  1. Let's assume that you have an object that is expensive to create and may not always be used. For example, when you have an Invoice object that has a LineItems property containing a large amount of data retrieved from the database. If the user asks to display a list of invoices without their content, Lazy Object functionality will prevent unnecessary waste of resources.
  2. Let's assume that you have an object that is expensive to create and you would like to delay its initialization until other expensive operations are performed. For example, you are preparing user data for export, but using Lazy Objects you can delay loading data from the database and perform, for example, authentication to an external API and preparation tasks, and only during export the data from the database will be loaded.
Lazy objects can be created in two strategies:
  1. Ghost Objects - such lazy objects are indistinguishable from normal objects and can be used without knowing that they are lazy.
  2. Virtual Proxies - in this case, the lazy object and the real instance are separate identities, so additional tasks must be performed when accessing the Virtual Proxy.

Creating Ghost Objects

Lazy ghost strategy should be used when we have control over the instatiation and initialization of an object. Lazy ghost is indistinguishable from a real instance of the object. class LazyGhostExample { public function __construct(public string $property) { echo "LazyGhost initialized\n"; } } $reflectionClass = new \ReflectionClass(LazyGhostExample::class); $newLazyGhost = $reflectionClass->newLazyGhost(function (LazyGhostExample $lazyGhostExample) { $lazyGhostExample->__construct('example'); }); // the object is not initialized yet var_dump($newLazyGhost); // the object is of class LazyGhostExample var_dump(get_class($newLazyGhost)); // the object is initialized on the first observation (reading the property) var_dump($newLazyGhost->property); The above example will output: lazy ghost object(LazyGhostExample)#15 (0) { ["property"]=> uninitialized(string) } string(28) "LazyGhostExample" LazyGhost initialized string(7) "example"

Creating Virtual Proxies

Lazy proxies after initialization are intermediaries to the real object instance, each operation on the proxy is redirected to the real object. This approach is good when we do not control the object initialization process. In the example below, we see that we are returning a new instance of the object, thus we do not interfere with what is happening in the constructor. class LazyProxyExample { public function __construct(public string $property) { echo "LazyGhost initialized\n"; } } $reflectionClass = new \ReflectionClass(LazyProxyExample::class); $newLazyProxy = $reflectionClass->newLazyProxy( function (LazyProxyExample $lazyProxyExample): LazyProxyExample { return new LazyProxyExample('example'); } ); // the object is not initialized yet var_dump($newLazyProxy); // the object is of class LazyGhostExample var_dump(get_class($newLazyProxy)); // the object is initialized on the first observation (reading the property) var_dump($newLazyProxy->property); The above example will output: lazy proxy objectLazyProxyExample)#15 (0) { ["property"]=> uninitialized(string) } string(28) "LazyProxyExample" LazyGhost initialized string(7) "example"

What triggers the initialization of Lazy Objects

Lazy Objects are initialized when one of the following operations occurs:
  • reading or writing a property
  • testing whether a property is set or unsetting it
  • reading, changing, or listing a property using the ReflectionProperty and ReflectionObject classes
  • serializing an object or cloning it
  • iterating through an object using foreach if the object does not implement Iterator or IteratorAggregate

Object API for BCMath

Another new feature is an object oriented way of performing mathematical operations on numbers with arbitrary precision numbers. The new class BcMatch\Number is used for this purpose. Below is an example of how to use objects of this class for mathematical operations. $pi = new BcMath\Number('3.14159'); $euler = new BcMath\Number('2.71828'); // we can just sum both instances $sum1 = $pi + $euler; var_dump($sum1); // we can use chaining to do the same $sum2 = $pi->add($euler); var_dump($sum2); // we can compare the objects var_dump($pi > $euler); // we also can compare using method chaining, it this case we will get results // -1 if argument of compare is greater that the number instance // 0 if the argument of compare is equal to number instance // 1 if the argument of compare is greater than number instance var_dump($euler->compare($pi)); This new class is not yet documented in the php documentatjo so is the complete list of methods that can be found in the BcMath\Number class: namespace BcMath { /** * @since 8.4 */ final readonly class Number implements \Stringable { public readonly string $value; public readonly int $scale; public function __construct(string|int $num) {} public function add(Number|string|int $num, ?int $scale = null): Number {} public function sub(Number|string|int $num, ?int $scale = null): Number {} public function mul(Number|string|int $num, ?int $scale = null): Number {} public function div(Number|string|int $num, ?int $scale = null): Number {} public function mod(Number|string|int $num, ?int $scale = null): Number {} /** @return Number[] */ public function divmod(Number|string|int $num, ?int $scale = null): array {} public function powmod(Number|string|int $exponent, Number|string|int $modulus, ?int $scale = null): Number {} public function pow(Number|string|int $exponent, ?int $scale = null): Number {} public function sqrt(?int $scale = null): Number {} public function floor(): Number {} public function ceil(): Number {} public function round(int $precision = 0, \RoundingMode $mode = \RoundingMode::HalfAwayFromZero): Number {} public function compare(Number|string|int $num, ?int $scale = null): int {} public function __toString(): string {} public function __serialize(): array {} public function __unserialize(array $data): void {} } }

Conclusion

The above features greatly extend the capabilities of the PHP language. In addition to these improvements, PHP 8.4 also offers a number of other minor improvements and additions, check the detailed changelog for more information This article is part of the CakeDC Advent Calendar 2024 (December 16th 2024)

Beyond MVC: Data, Context, and Interac...

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)

New exciting features in PHP 8.4 Part II

This article is part of the CakeDC Advent Calendar 2024 (December 14th 2024) In this article, we will continue to explore the new features and improvements added in the latest version of PHP 8.4. In this release, we have received several long-awaited improvements, improved functionality, and a modern approach to common tasks. These few seem to be the most interesting:

  • New ext-dom features and HTML5 support
  • new MyClass()->method() without parentheses
  • #[\Deprecated] Attribute

New ext-dom features and HTML5 support

One of the new feature in the latest PHP release is the new DOM API with support for HTML5, which makes it much easier to work with parsing and extracting data from HTML documents. $html = <<<'HTML' <main> <header> <h1>HTML5 Example Page</h1> </header> <nav class="top-nav"> <ul> <li><a href="">Home</a></li> <li><a href="">About</a></li> <li><a href="">Contact</a></li> </ul> </nav> <article>...</article> <nav class="bottom-nav"> <ul> <li><a href="">Home</a></li> <li><a href="">About</a></li> <li><a href="">Contact</a></li> </ul> </nav> </main> HTML; $htmlDocument = HTMLDocument::createFromString($html); $node = $htmlDocument->querySelector('main > nav:first-of-type'); echo $node->className; // prints top-nav

new MyClass()->method() without parentheses

New expressions can now be used without additional parentheses, making method chaining and property access much easier. // CakePHP collections can be created using new expression without additional parentheses new Collection([1, 2, 3]) ->filter(fn (int $item): bool => $item !== 2) ->map(fn (int $item): string => $item . "000 pcs") ->each(function (string $item): void { echo $item; }) // the same goes for the classes like DateTime or Date echo new DateTime()->addDays(10)->toIso8601String();

New #[\Deprecated] Attribute

The new attribute #[\Deprecated] will allow developers to mark their functions, methods and class constants as deprecated. Using a statement marked with this attribute will result in the error E_USER_DEPRECATED being emitted. This attribute makes it easier to manage deprecated code and uses existing mechanisms in PHP but in user code. class MyClass { #[\Deprecated(message: "use MyClass::MODERN_CLASS_CONST instead", since: "7.1")] public const LEGACY_CLASS_CONST = 'Legacy Class Constant'; public const MODERN_CLASS_CONST = 'Modern Class Constant'; public function doSomething(string $argument) { ... } #[\Deprecated(message: "use MyClass::doSomething() instead", since: "7.1")] public function doSomethingLegacyAndUnsafeWay(string $argument) { ... } } $obj = new MyClass(); $obj->doSomethingLegacyAndUnsafeWay('foo'); echo $obj::LEGACY_CLASS_CONST; The above code will emit warnings: Deprecated: Method MyClass::doSomethingLegacyAndUnsafeWay() is deprecated since 7.1, Use MyClass::doSomething() instead Deprecated: Constant MyClass::LEGACY_CLASS_CONST is deprecated since 7.1, use MyClass::MODERN_CLASS_CONST instead

Conclusion

The above features greatly extend the capabilities of the PHP language. Improved HTML5 support, simplified creation of new objects and deprecation management from PHP will greatly facilitate your tasks in these areas. In addition to these improvements, PHP 8.4 also offers a number of other minor improvements and additions, check the detailed changelog for more information This article is part of the CakeDC Advent Calendar 2024 (December 14th 2024)

New exciting features in PHP 8.4 Part I

This article is part of the CakeDC Advent Calendar 2024 (December 13th 2024) In this article we'll explore some the new features of the recently released PHP 8.4 version. Every year in the second half of autumn a new version of PHP, on which our beloved CakePHP is based, is released. This time it is the major version 8.4, and it adds many exciting features and improvements. Among the many new features and improvements, In this article, we will cover these two interesting added functionalities.:

  • Property hooks
  • Asymmetric Visibility

Property hooks

Property hooks also known as property accessors are a way to intercept and override the way properties are read and written. This new functionality significantly reduces the amount of boilerplate code by allowing you to skip separate getter and setter methods. To use property hooks use get and set hooks class ClassWithPropertyHooks { public string $first_name { get => $this->formatNamePart($this->first_name); set (string $first_name) => trim($first_name); } public string $last_name { get => $this->formatNamePart($this->last_name); set (string $last_name) => trim($last_name); } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } $obj = new ClassWithPropertyHooks(); $obj->first_name = 'ADAM'; echo $obj->first_name; // prints Adam; $obj->last_name = 'RUSINOWSKI'; echo $obj->last_name; // prints Rusinowski Hooks are placed in {} right after the property declaration, inside you can define both hooks, it is also allowed to define only one, get or set. Each hook can have a body enclosed in {} or if the hook is a single expression, arrow expression can be used. Set Hook can optionally define a name and type of the incoming value, this type must be the same or covariant with the type of the property. All hooks operate in the scope of the object, you can use any public, protected or private method or property inside the body of a hook. Hooks allow you to create virtual properties. Virtual properties are properties that do not have a backed value and no hook directly refers to the value of the property. Instead, the value when read may be the result of some processing or combination of other properties. Virtual properties do not occupy the object's memory and if the set hook is undefined, they cause an error. An example below presents the usage of a virtual property $full_name and the usage of the object method formatNamePart in the body of the hooks: class ClassWithPropertyHooks { public string $first_name { final get => $this->formatNamePart($this->first_name); final set (string $first_name) => trim($first_name); } public string $last_name { get => $this->formatNamePart($this->last_name); set (string $last_name) => trim($last_name); } public ?string $full_name { get { if ($this->first_name || $this->last_name) { return trim("$this->first_name $this->last_name"); } return null; } } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } $obj = new ClassWithPropertyHooks(); $obj->first_name = 'ADAM'; $obj->last_name = 'rusinowski'; echo $obj->full_name; // prints Adam Rusinowski; $obj->full_name = 'Adam Rusinowski'; // this will cause error since the set hook is not defined Hooks can be made final so they may not be overridden in child class. class ClassWithPropertyHooks { public string $first_name { get => $this->formatNamePart($this->first_name); final set (string $first_name) => trim($first_name); } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } class ChildClassWithPropertyHooks extends ClassWithPropertyHooks { public string $first_name { get => trim($this->first_name); set (string $first_name) => strtolower($first_name); // this is not allowed } } If you want to have access to the parent hook you can use the syntax parent::$propertyName::get() or parent::$property::set() inside the hook in the child class. class ClassWithPropertyHooks { public string $first_name { get => $this->formatNamePart($this->first_name); set (string $first_name) => trim($first_name); } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } class ChildClassWithPropertyHooks extends ClassWithPropertyHooks { public string $first_name { get => trim($this->first_name); set (string $first_name) => parent::$first_name::set(strtolower(%$first_name)); } } Properties with property hooks cannot be marked as readonly, so to limit their modification you can use the asymmetric visibility feature.

Asymmetric Visibility

With Asymmetric Visibility you can control the scope of writing and reading properties independently. This reduces the amount of boilerplate code when you want to prohibit modification of property values from outside the class. class ExampleClass { public private(set) int $counter = 0; public function increaseCounter(): void { ++$this->counter; } } $exampleClass = new ExampleClass(); echo $exampleClass->counter; // prints 0; $exampleClass->increaseCounter(); echo $exampleClass->counter; // prints 1; $exampleClass->counter = 5; // this is not allowed Asymmetric Visibility is subject to certain rules:
  • only properties with a defined type can have separate visibility for the set hook
  • the visibility of the set hook must be the same as the visibility of the get hook or more restrictive
  • getting a reference to a property will use the set visibility because the reference can change the value of the property
  • writing to an array property includes the get and set operations internally so the set visibility will be used
When using inheritance, remember that the child class can change the visibility of set and get but if the visibility of set or get is private in the parent class, changing it to something else in the child class will result in a Fatal Error.

Conclusion

The above features greatly extend the capabilities of the PHP language. Property hooks and asymmetric visibility can be useful in value objects, among other things. They can successfully replace getters and setters, thus reducing the amount of boilerplate code in your applications. In the next article, we will cover some more great improvements that the developer community has added to the new version of PHP. This article is part of the CakeDC Advent Calendar 2024 (December 13th 2024)

Almost 20 years: A bit of history abou...

This article is part of the CakeDC Advent Calendar 2024 (December 12th 2024) As many of you may know, CakePHP started back in 2005 with a minimal version of a framework for PHP. Then, in May 2006, version 1.0 of CakePHP was released including many of the features we still use today. Over the years a lot of features have been added to the project, and others have been greatly improved. As the framework has evolved, many people have been involved on this project, and we think now it is good time to say *THANK YOU* to everyone that has participated in greater or lesser extent. In this article, we will briefly recap some of the changes to the major (or most important) CakePHP versions throughout history. We will highlight the features that are still present in current version.   CakePHP 1.0 and 1.1: May 2006

  • Compatibility with PHP4 and PHP5
  • Integrated CRUD for database interaction and simplified queries
  • Application Scaffolding
  • Model View Controller (MVC) Architecture
  • Request dispatcher with good looking, custom URLs
  • Built-in Validation
  • Fast and flexible templating (PHP syntax, with helpers)
  • View Helpers for AJAX, Javascript, HTML Forms and more
  • Security, Session, and Request Handling Components
  • Flexible access control lists
  • Data Sanitization
  • Flexible View Caching
As you can see, most of the original features from CakePHP 1.x are still present in the 5.x version. Of course PHP4 and PHP5 compatibility does not makes sense right now, and Security and Session components have been replaced by most modern solutions.   CakePHP 1.2: Dec 2008
  • CakePHP 1.0 and 1.1 features
  • Code generation
  • Email and Cookie component joins the party
  • Localization
For CakePHP 1.2, we first see the code generation using the well-known Bake tool (now a plugin). Additionally, it is the first version where we have a way to create multi-language applications.   CakePHP 1.3: Apr 2010
  • Improved Logging
  • Removed inflections.php
  • Some internal library renames and changes.
  • Controller and Component improvements.
  • Routing changes
  • Performance improvement on Cache classes
  CakePHP 2.0: October 2011
  • Dropped PHP 4 support
  • New Error and Exception handlers which are easier to configure, and ease working with errors such as page not found, unauthorized error and lots more.
  • Improved I18n functions for easier multilingual development.
  • Support for injecting your own objects to act as CakePHP libraries
  • New Request and Response objects for easier handling of HTTP requests.
  • Completely refactored Auth system.
  • Brand new email library with support for multiple transports.
  • Dropped SimpleUnit in favor of PHPUnit.
  • Improved support for PostgreSql, SQLite and SqlServer.
  • HTML 5 form inputs support in form helper.
  • Huge performance improvements compared to version 1.3
CakePHP 2.0 did not change a lot from the way things were done in CakePHP 1.3, but it laid the foundation for the success of the following versions (including 2.x and the next major 3.x)   CakePHP 3.0: March 2015
  • A New ORM
  • Faster and more flexible routing
  • Improved migrations using Phinx
  • Better internationalization
  • Improved Debugging Toolbar
  • Composer usage
  • Standalone libraries
  • View cells
It is impossible to explain how much the framework changed from 2.x to 3.x. It is probably the most revolutionary version, since it introduced lots of improvements and changes like the amazing ORM and the database migrations management, both still in use. Even after almost 10 years it looks very timely and up-to-date.   CakePHP 4.0: December 2019
  • PHP 7.2 required.
  • Streamlined APIs with all deprecated methods and behavior removed.
  • Additional typehints across the framework giving you errors faster.
  • Improved error messages across the framework.
  • A refreshed application skeleton design.
  • New database types for fixed length strings (CHAR), datetime with
    microseconds, and datetime with timezone types.
  • Table now features OrFail methods that raise exceptions on failure
    making error handling more explicit and straightforward.
  • Middleware for CSP headers, Form tampering prevention, and HTTPS enforcement.
  • Cake\Routing\Asset to make generating asset URLs simple from anywhere in
    your application code.
  • FormHelper now generates HTML5 validation errors.
  • FormHelper now generates HTML5 datetime input elements.
It seems as though the list is self-explanatory. If CakePHP 3.0 was revolutionary, CakePHP 4.0 continued modernizing the framework with middleware, new table methods, database types and a new application skeleton.   CakePHP 5.0: September 2023
  • PHP 8.1 required.
  • Improved typehints across the framework. CakePHP now leverages union types to formalize the types of many parameters across the framework.
  • Upgraded to PHPUnit 10.x
  • Support for read and write database connection roles.
  • New enum type mapping support in the ORM enabling more expressive model layers with improved type checking.
  • Table finders with named parameters, providing more expressive query building APIs.
  • Added time-only Time type and greatly improved Date and DateTime support via chronos 3.x.
  • Support for PSR17 HTTP factories was added.
And here we are, 1 year after CakePHP 5 was released, we are currently in version 5.1.20 (released November 10th). It is unbelievable that 20 years have already passed, and the team is more than excited about the upcoming changes and features we expect in CakePHP 6, 7 ,8 and beyond!   To finish we would like to make a special tribute (and thank them again) to the top 8 contributors from all time! Especially to our lead (and oldest active) developer, Mark Story (@markstory on Github or his Website) , who has been contributing for almost 17 years with more than 17k commits (an incredible average of 1000+ commits per year). Remember your name could be here as well! You just need to start contributing! (check out https://github.com/cakephp/cakephp/blob/5.x/.github/CONTRIBUTING.md). This article is part of the CakeDC Advent Calendar 2024 (December 12th 2024)

Troubleshooting Queue Jobs with Queue ...

This article is part of the CakeDC Advent Calendar 2024 (December 11th 2024) In this article we'll show how to easily monitor the queue jobs in CakePHP 5.0 using the CakePHP Queue Monitor plugin. Queue Monitor Plugin is a companion plugin for the official CakePHP Queue plugin .

Why I should monitor the queues

Monitoring queue jobs is crucial for maintaining the health and efficiency of your application's background processing. Here are some compelling reasons why you should monitor queue jobs:
  • Performance and Throughput: Monitoring helps ensure that your jobs are processed within acceptable time frames, preventing bottlenecks and ensuring that your application runs smoothly.
  • Error Detection and Handling: By keeping an eye on your queue jobs, you can quickly identify and address errors or failures in the jobs. This allows you to reduce the potential downtime.
  • Scalability: As your application grows, monitoring helps you understand how the queue system is handling the increased load, guiding decisions about scaling up or distributing workloads among multiple workers to speed up the job processing.
  • User Experience: Ensuring that background tasks are processed efficiently directly impacts the user experience. For example, timely processing of tasks like email notifications or data updates keeps users satisfied.
By monitoring your queue jobs, you can maintain a robust and efficient application that enhances performance and ensures reliability.

Key Features

This plugin will allow you to monitor the queue, in particular:
  • monitor and notify about jobs that exceed the specified working time
  • check if configured queues are working correctly
  • clear the queue of queued jobs
  • inspect job messages that have been processed

Getting Started

Before we discuss the functionality, we will go through the installation and configuration of the plugin.

Step 1: Installing the plugin

First you need to install the plugin into your CakePHP 5 application. To do this, you will need to run this command in the root of your project: composer require cakedc/queue-monitor When the plugin is installed please load it into your application: bin/cake plugin load CakeDC/QueueMonitor After the plugin is loaded please run the required migrations: bin/cake migrations migrate -p CakeDC/QueueMonitor

Step 2: Configuring the monitoring

To configure monitoring, place the following directives in your config/app_local.php and adjust values if needed. // ... 'QueueMonitor' => [ 'disable' => false, 'mailerConfig' => 'default', 'longJobInMinutes' => 30, 'purgeLogsOlderThanDays' => 7, 'notificationRecipients' => '[email protected],[email protected],[email protected]', ], // ... Options explained:
  • QueueMonitor.disable - With this setting you can enable or disable the queue monitoring, as the queue monitoring is enabled by default.
  • QueueMonitor.mailerConfig - mailer config used for notification, the default is default mailer.
  • QueueMonitor.longJobInMinutes - This is the time in minutes after which the job will be considered as working too long, and will be taken into account when sending notifications. The default values is 30 minutes.
  • QueueMonitor.purgeLogsOlderThanDays - This is the time in days after which logs will be deleted from the database to keep the log table small. The default value is 7 days.
  • QueueMonitor.notificationRecipients - This is a comma-separated list of recipients of notifications about jobs exceeding the configured working time.

Step 3: Configuring queue to use the monitoring

To enable the monitoring capabilities to a queue, you will need to add the listener to the queue configurations that you want monitor: 'Queue' => [ 'default' => [ // ... 'listener' => \CakeDC\QueueMonitor\Listener\QueueMonitorListener::class, // ... ] ],

Step 4: Configure notification cronjob

To receive emails about jobs that take too long to process, you need to add the following command to cron: bin/cake queue-monitor notify It is best to configure this command to run every few minutes.

Step 5: Configure the log purging

To keep the log table size small, you need to add automatic log purging to cron: bin/cake queue-monitor purge-logs It is best to configure this command to run every day.

Features

Continuous queue job monitoring

With the Queue Monitoring plugin installed and configured, all of your queued jobs are logged with time points and full message content. The entire processing of each job is also logged, starting from the moment the job is enqueued until one of the many ways the job ends. When any of the jobs exceeds the configured working time, you will be notified by email.

Inspecting the queue logs

Using CakeDC\QueueMonitor\Model\Table\LogsTable you can view all logged job messages. This functionality will be expanded in subsequent versions of the plugin.

Testing the queues

To quickly test if all queues are running correctly, run this command (replace [email protected] with working email address: bin/cake queue-monitor test-enqueue [email protected] This command will send the email through all configured queues.

Purging the queues

To purge the content of a specified queue you can use the purge queue command: bin/cake queue-monitor purge-queue your-queue-name The above command will remove all pending queue jobs from the specified queue. To purge all queues you can use command: bin/cake queue-monitor purge-queue --all

Conclusion

And that's it, you have successfully enabled queue monitoring and troubleshooting tools in your application. This plugin is in active development and more features will be added to it soon, some of them are:
  • browsing the content of messages in the queue
  • deleting a specified number of messages from the queue
  • deleting the first job in the queue
  • CRUD for queue logs
  • asynchronous queue logging to prevent degradation of job processing performance in case of high job frequency
We look forward to your feedback and contributions as we continue to enhance this plugin for the benefit of the entire CakePHP community. This article is part of the CakeDC Advent Calendar 2024 (December 11th 2024)

Introducing the CakePHP Goto View VSCo...

This article is part of the CakeDC Advent Calendar 2024 (December 10th 2024) As we embrace the spirit of giving during this holiday season, I'm glad to announce a new tool that aims to enhance the development experience for CakePHP developers using Visual Studio Code. The CakePHP Goto View extension brings intelligent navigation and autocompletion features to streamline your CakePHP development workflow.

Why Another Extension?

While CakePHP's convention over configuration approach makes development intuitive, navigating between related files in large projects can still be challenging. Whether you're working with templates, elements, cells, or assets, finding the right file quickly can impact your development speed and focus. This extension was born from the daily challenges we face as CakePHP developers, designed to make file navigation and discovery as seamless as possible while respecting CakePHP's conventions.

Key Features

Smart Navigation

Controller and Template Integration

  • Seamlessly navigate between controllers and their views by hovering over ->render() calls
  • Quick access to template files directly from controller actions
  • Smart detection of template paths based on controller context

Element and Cell Management

  • Instant access to element files by hovering over $this->element() calls
  • Complete cell navigation showing both cell class and template files
  • Support for nested elements and plugin elements

Asset File Navigation

  • Quick access to JavaScript files through Html->script() calls
  • CSS file navigation via Html->css() helper references
  • Support for both direct paths and plugin assets
  • Integration with asset_compress.ini configuration for compressed assets

Email Template Support

  • Navigate to both HTML and text versions of email templates
  • Quick access from Mailer classes to corresponding templates
  • Support for plugin-specific email templates

Plugin-Aware Intelligence

  • Smart resolution of files across different plugins
  • Support for both app-level and plugin-level resources
  • Automatic detection of plugin overrides in your application
  • Composer autoload integration for accurate namespace resolution

Autocompletion

  • Context-aware suggestions for elements, cells, and assets
  • Support for plugin resources with proper namespacing
  • Intelligent path completion based on your project structure

Developer Experience

  • Hover information showing related files and their locations
  • Real-time map updates as you modify your project structure
  • Support for both application and plugin resources
  • Minimal configuration needed - it just works!

Community Contribution

As with any open-source project, this extension is open to community involvement. Whether it's reporting bugs, suggesting features, or contributing code, every input helps make this tool better for everyone in the CakePHP ecosystem.

Getting Started

The extension is available in the Visual Studio Code marketplace. Simply search for "CakePHP Goto View" in your extensions panel or visit the marketplace website. Once installed, the extension automatically detects your CakePHP project structure and begins working immediately. No complex configuration required - it's designed to follow CakePHP conventions out of the box.

Conclusion

In the spirit of the CakePHP community's collaborative nature, this extension is our contribution to making CakePHP development even more enjoyable. We hope it helps streamline your development workflow and makes navigating your CakePHP projects a breeze. We look forward to your feedback and contributions as we continue to enhance this tool for the benefit of the entire CakePHP community. This article is part of the CakeDC Advent Calendar 2024 (December 10th 2024)

Integrating Telegram Bot with CakePHP ...

This article is part of the CakeDC Advent Calendar 2024 (December 9th 2024) Want to add a Telegram bot to interact with your users? TeBo is a great plugin that simplifies the process. In this guide, I’ll walk you through integrating a Telegram bot into your CakePHP 5 application with a practical example using the Pokémon public API. The bot will respond with details about a Pokémon when users send the command /pokemon <name>. TeBo is a plugin designed specifically for managing bots in CakePHP, focusing on easy configuration and custom commands. GitHub Repository for TeBo

Step 1: Install the Plugin

Start by installing the TeBo plugin in your CakePHP project. You’ll need Composer, the PHP dependency manager. Run this command in the root of your project: composer require arodu/tebo After the plugin is installed, load it into your application: bin/cake plugin load TeBo That’s it! You’re ready to use the plugin.

Step 2: Set Up the Telegram Token

Every Telegram bot requires an authentication token, which you get by creating a bot on Telegram. Follow these steps:
  1. Open Telegram and search for BotFather, the official bot for creating and managing other bots.
  2. Send the command /newbot and follow the instructions. BotFather will ask for a bot name and a unique username.
  3. When you’re done, BotFather will give you an authentication token that looks like this: 1234567890:ABCDefghIJKlmNoPQRstuVWXyz.
Add this token to your .env file in your project: export TELEGRAM_TOKEN="1234567890:ABCDefghIJKlmNoPQRstuVWXyz"

Step 3: Configure the Webhook

For your bot to receive updates from Telegram, you need to set up a webhook. This tells Telegram where to send messages for your bot in real time. TeBo provides commands to easily manage webhooks from the terminal:
  • Get webhook URL: Shows the current webhook URL.
  • Set webhook: Links your bot to a URL.
  • Remove webhook: Deletes the webhook configuration.
  • Get webhook info: Displays connection details.
  • Get bot info: Shows basic bot information.
To manage the webhook, use the following command and select option 2 from the menu: bin/cake tebo Or, set the webhook directly with this command: bin/cake tebo webhook -s

Step 4: Create a Custom Command

Now that your bot is set up, let’s create a command to respond with Pokémon information when it receives /pokemon <name>.

4.1 Create the Command Action

In your project’s src/Actions folder, create a file named PokemonAction.php with the following code: <?php declare(strict_types=1); namespace App\TeBo\Action; use Cake\Cache\Cache; use Cake\Http\Client; use Cake\Utility\Hash; use TeBo\Action\Action; use TeBo\Action\Command\MessageCommandTrait; use TeBo\Response\HtmlMessage; use TeBo\Response\TextMessage; class PokemonAction extends Action { use MessageCommandTrait; public function description(): ?string { return __('Get information about a Pokémon'); } public function execute(): void { $pokemonName = $this->getMessageCommand()->getArgument(0); if (!$pokemonName) { $this->getChat()->send(new TextMessage(__('Please provide a Pokémon name.'))); return; } $pokemonData = $this->getPokemonData($pokemonName); if (isset($pokemonData['name'])) { $types = Hash::extract($pokemonData, 'types.{n}.type.name'); $message = [ 'Name: ' . $pokemonData['name'], 'Order: ' . $pokemonData['order'], 'Types: ' . implode(', ', $types), ]; $this->getChat()->send(new HtmlMessage($message)); } else { $this->getChat()->send(new TextMessage(__('Pokémon not found.'))); } } private function getPokemonData($pokemonName) { return Cache::remember('pokemon_' . $pokemonName, function () use ($pokemonName) { $url = "https://pokeapi.co/api/v2/pokemon-form/{$pokemonName}"; $http = new Client(); return $http->get($url)->getJson(); }); } }

4.2 Register the Command

Add this command to your plugin configuration file (config/tebo.php): return [ 'tebo.actions.command' => [ 'pokemon' => \App\TeBo\Action\PokemonAction::class, ], ]; This associates the pokemon command with the action you just created, you can add as many commands as you need.

4.3 Test the Command

Send the command /pokemon pikachu to your bot, and it should respond with details about Pikachu.

4.4 Customize the Response

You can make the response more engaging by including images or additional details: if (isset($pokemonData['name'])) { $sprite = $pokemonData['sprites']['front_default']; $message = new \TeBo\Response\Photo($sprite, [ 'Name: ' . $pokemonData['name'], 'Types: ' . implode(', ', $types), ]); }

Step 5: Deploy the Bot to Production

For production, ensure your server is HTTPS-enabled, as Telegram requires secure webhooks. During development, you can use a tool like ngrok to temporarily expose your local server. Update your .env file with your server’s domain: export WEBHOOK_BASE="mydomain.com" Make sure to test your bot thoroughly in the production environment.

Additional Resources

Conclusion

And that’s it! You’ve successfully integrated a Telegram bot into your CakePHP application. Your bot can now interact with users and provide useful information, like Pokémon details. TeBo makes it easy to add custom commands and manage user interactions. Feel free to explore and expand your bot with more features! This article is part of the CakeDC Advent Calendar 2024 (December 9th 2024)

World of TESTING

This article is part of the CakeDC Advent Calendar 2024 (December 8th 2024) We live without a doubt in a digital era, where everything is now taking place online. From shopping, entertainment, and information to self development, studies in general, and a way of living. As our world moves into this era, testing of these digital products is indeed a need that is sometimes overlooked by the client... but that is why we as IT professionals must emphasize and create awareness. Testing is a crucial part of the software development lifecycle. It involves not only proving that something is working fine now, it is an action that we can do many times accidentaly and as we work more and more on that project. We have to be reviewing constantly. Luckily, with some tools we can now do as much as needed by creating automated tests.

But what should we be testing?

Everything! I know, time is precious, but clients are too. We have to think about a project like it is someone else's baby. They have something in mind and the team delivers it. For starters, when it is the testers' time to work on that project, we carefully inspect all the product. We have to act like a regular user by browsing the page following primarily the main workflows and when they are done, we start writing a document with the procedure of testing a functionality, then, is coding time baby!

Automated testing with cypress: coding time!

Although testers must not per se know any coding language, it is helpful to have a notion of JavaScript (because cypress is a frontend web automation tool) which will help us improve our coding with some logic for testing more complex functionalities. For most test you only use commands from cypress for selecting items and performing actions, but sometimes you will need to add some logic for your test to be more reusable and reliable. If you are new to cypress, check out variables, loops, and functions on JS. Gain a basic knowledge on these topics, and you are set to code test on cypress!

Tip: Something new to me while coding on cypress:

Cypress never stops surprising me. Something that I found very interesting while creating some code is that cypress cannot natively open a new tab or window that usually opens when browsing a webpage. So here is a simple solution for this problem. You can stub the process with this simple function, and it will still do all the process needed on that second page, but you will be kept on the main page. Then, if all is good with the action, your test will succeed cy.window().then((win) => { cy.stub(win, 'open').callsFake((url) => { win.location.href = url; }).as('windowOpen'); });

Useful links:

Here you'll find endless information provided from cypress platform, and you can always check forums when you have a specific question and need some clarity on a topic: This article is part of the CakeDC Advent Calendar 2024 (December 8th 2024)

Building Dynamic Web Applications with...

This article is part of the CakeDC Advent Calendar 2024 (December 7th 2024) This article continues our exploration of htmx integration with CakePHP, focusing on two powerful features that can significantly enhance user experience: inline editing and lazy loading of actions. These features demonstrate how htmx can transform traditional web interfaces into dynamic, responsive experiences while maintaining clean, maintainable code.

Other Articles in the Series

Inline Editing with htmx

Inline editing allows users to modify content directly on the page without navigating to separate edit forms. This pattern is particularly useful for content-heavy applications where users need to make quick updates to individual fields. With htmx, we can implement this feature with minimal JavaScript while maintaining a smooth, intuitive user experience.

Basic Implementation

The inline editing feature consists of three main components:
  1. A display view that shows the current value with an edit button
  2. An edit form that appears when the user clicks the edit button
  3. A controller action that handles both display and edit modes
Let's implement each component:

Controller Setup

First, we'll create a dedicated action in our controller to handle both viewing and editing states: // /src/Controller/PostsController.php public function inlineEdit(int $id, string $field) { $post = $this->Posts->get($id, contain: []); $allowedFields = ['title', 'overview', 'body', 'is_published']; if (!in_array($field, $allowedFields)) { return $this->response->withStatus(403); } $mode = 'edit'; if ($this->request->is(['post', 'put'])) { if ($this->request->getData('button') == 'cancel') { $mode = 'view'; } else { $value = $this->request->getData($field); $post->set($field, $value); if ($this->Posts->save($post)) { $mode = 'view'; } } } if ($this->getRequest()->is('htmx')) { $this->viewBuilder()->disableAutoLayout(); $this->Htmx->setBlock('edit'); } $this->set(compact('post', 'mode', 'field')); }

View Helper

To maintain consistency and reduce code duplication, we'll create a helper to generate inline-editable fields: // /src/View/Helper/HtmxWidgetsHelper.php public function inlineEdit(string $field, $value, EntityInterface $entity): string { $url = $this->Url->build([ 'action' => 'inlineEdit', $entity->get('id'), $field ]); return sprintf( '<div class="inline-edit-wrapper"> <span class="field-value">%s</span> <button class="btn btn-sm inline-edit-btn" hx-get="%s"> <i class="fas fa-edit"></i> </button> </div>', $value, $url ); }

Template Implementation

The template handles both view and edit modes: // /templates/Posts/inline_edit.php <?php $formOptions = [ 'id' => 'posts', 'hx-put' => $this->Url->build([ 'action' => 'inlineEdit', $post->id, $field, ]), 'hx-target' => 'this', 'hx-swap' => 'outerHTML', 'class' => 'inline-edit-form inline-edit-wrapper', ]; ?> <?php $this->start('edit'); ?> <?php if ($mode == 'view'): ?> <?php if ($field == 'is_published'): ?> <?= $this->HtmxWidgets->inlineEdit($field, $post->is_published ? 'Published' : 'Unpublished', $post); ?> <?php elseif ($field == 'body'): ?> <?= $this->HtmxWidgets->inlineEdit('body', $this->Text->autoParagraph(h($post->body)), $post) ?> <?php elseif ($field == 'overview'): ?> <?= $this->HtmxWidgets->inlineEdit('overview', $this->Text->autoParagraph(h($post->overview)), $post) ?> <?php else: ?> <?= $this->HtmxWidgets->inlineEdit($field, $post->get($field), $post); ?> <?php endif; ?> <?php else: ?> <?= $this->Form->create($post, $formOptions) ?> <?= $this->Form->hidden('id'); ?> <?php if ($field == 'title'): ?> <?= $this->Form->control('title'); ?> <?php elseif ($field == 'overview'): ?> <?= $this->Form->control('overview'); ?> <?php elseif ($field == 'body'): ?> <?= $this->Form->control('body'); ?> <?php elseif ($field == 'is_published'): ?> <?= $this->Form->control('is_published'); ?> <?php endif; ?> <div class="inline-edit-actions"> <?= $this->Form->button('<i class="fas fa-check"></i>', [ 'class' => 'btn btn-primary btn-sm inline-edit-trigger', 'name' => 'button', 'value' => 'save', 'escapeTitle' => false, ]); ?> <?= $this->Form->button('<i class="fas fa-times"></i>', [ 'class' => 'btn btn-secondary btn-sm inline-edit-trigger', 'name' => 'button', 'value' => 'cancel', 'escapeTitle' => false, ]); ?> </div> <?= $this->Form->end() ?> <?php endif; ?> <?php $this->end(); ?> <?= $this->fetch('edit'); ?>

Styling

The CSS ensures a smooth transition between view and edit modes: .inline-edit-wrapper { display: inline-flex; align-items: center; gap: 0.5rem; } .inline-edit-btn { padding: 0.25rem; background: none; border: none; cursor: pointer; opacity: 0.5; } .inline-edit-wrapper:hover .inline-edit-btn { opacity: 1; } .inline-edit-form { display: inline-flex; align-items: center; gap: 0.5rem; } .inline-edit-form .input { margin: 0; } .inline-edit-form input { padding: 0.25rem 0.5rem; height: auto; width: auto; } .inline-edit-actions { display: inline-flex; gap: 0.25rem; } .inline-edit-actions .btn { padding: 0.25rem 0.5rem; line-height: 1; height: auto; } .inline-edit-actions .btn { padding: 0.25rem; min-width: 24px; min-height: 24px; } .inline-edit-actions .btn[title]:hover::after { content: attr(title); position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); color: white; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 12px; white-space: nowrap; }

Usage example

In the index template, we can use the helper to create inline-editable fields and provide a button to trigger the edit mode inside a table cell: // /templates/Posts/index.php <?= $this->HtmxWidgets->inlineEdit('title', $post->title, $post) ?> <?= $this->HtmxWidgets->inlineEdit('is_published', $post->is_published ? __('Yes') : __('No'), $post) ?>

Inline Editing Flow

The inline editing feature transforms static content into interactive, editable fields directly on the page. This implementation follows a clear state-based workflow that provides immediate visual feedback while maintaining data integrity.

State Management

The system maintains two distinct states for each editable field:
  • View State: Displays the current value with an edit button
  • Edit State: Shows an editable form with save and cancel options

Workflow Steps

  1. Initial Display
    • Each editable field is wrapped in a container that includes both the value and an edit button
    • The edit button remains subtle until the user hovers over the field, providing a clean interface
    • The field's current value is displayed in a formatted, read-only view
  2. Entering Edit Mode
    • When the user clicks the edit button, htmx sends a GET request to fetch the edit form
    • The server determines the appropriate input type based on the field (text input, textarea, or checkbox)
    • The edit form smoothly replaces the static display
  3. Making Changes
    • Users can modify the field's value using the appropriate input control
    • The form provides clear save and cancel options
    • Visual feedback indicates the field is in edit mode
  4. Saving or Canceling
    • Saving triggers a PUT request with the updated value
    • The server validates and updates the field
    • If the value is invalid, the form is redisplayed with error messages
    • Canceling reverts to the view state without making changes
    • Both actions transition smoothly back to the view state that has been performed on success for edit and always on cancel

HTMX Attributes in Action

The implementation uses several key htmx attributes to manage the editing flow:
  1. View State Attributes
    • hx-get: Fetches the edit form when the edit button is clicked
    • hx-target: Ensures the form replaces the entire field container
    • hx-swap: Uses "outerHTML" to maintain proper DOM structure
  2. Edit State Attributes
    • hx-put: Submits the updated value to the server
    • hx-target: Targets the form container for replacement
    • hx-swap: Manages the transition back to view mode

Lazy Loading Actions

Lazy loading actions is a performance optimization technique where we defer loading action buttons until they're needed. This is particularly useful in tables or lists with many rows, where each row might have multiple actions that require permission checks or additional data loading.

Implementation

First, let's create a controller action to handle the lazy loading of actions: // /src/Controller/PostsController.php public function tableActions(int $id) { $post = $this->Posts->get($id, contain: []); if ($this->getRequest()->is('htmx')) { $this->viewBuilder()->disableAutoLayout(); $this->Htmx->setBlock('actions'); } $this->set(compact('post')); }

Table Actions Template

Create a reusable element for the action buttons: // /templates/Posts/table_actions.php <?php $this->start('actions'); ?> <?= $this->Html->link(__('View'), ['action' => 'view', $post->id]) ?> <?= $this->Html->link(__('Edit'), ['action' => 'edit', $post->id]) ?> <?= $this->Form->postLink(__('Delete'), ['action' => 'delete', $post->id], ['confirm' => __('Are you sure you want to delete # {0}?', $post->id)]) ?> <?php $this->end(); ?> <?= $this->fetch('actions'); ?>

Template Element

Create a reusable element for the action buttons: // /templates/element/lazy_actions.php <div class="action-wrapper" hx-get="<?= $this->Url->build([ 'action' => 'tableActions', $entity->id, ]) ?>" hx-trigger="click" hx-swap="outerHTML" hx-target="this" hx-indicator="#spinner-<?= $entity->id ?>" > <?= $this->Html->tag('button', '<i class="fas fa-ellipsis-vertical"></i>', [ 'class' => 'btn btn-light btn-sm rounded-circle', 'type' => 'button' ]) ?> <div id="spinner-<?= $entity->id ?>" class="htmx-indicator" style="display: none;"> <div class="spinner-border spinner-border-sm text-secondary" role="status"> <span class="visually-hidden">Loading...</span> </div> </div> </div>

Usage in Tables

Implementation of the lazy loading trigger in your table rows is done by replacing the static actions with the lazy loading trigger: The static actions is displayed as: <!-- /templates/Posts/index.php --> <td class="actions"> <?= $this->Html->link(__('View'), ['action' => 'view', $post->id]) ?> <?= $this->Html->link(__('Edit'), ['action' => 'edit', $post->id]) ?> <?= $this->Form->postLink(__('Delete'), ['action' => 'delete', $post->id], ['confirm' => __('Are you sure you want to delete # {0}?', $post->id)]) ?> </td> And lazy loading trigger is displayed as: <!-- /templates/Posts/index.php --> <td class="actions"> <?= $this->element('lazy_actions', ['entity' => $post]) ?> </td>

Modal Forms and Views with htmx

Modal dialogs provide a focused way to present forms and content without navigating away from the current page. Using htmx, we can create dynamic modals that load their content asynchronously while maintaining a clean, maintainable codebase.

Implementation Overview

The modal implementation consists of several components:
  1. A modal container element in the default layout
  2. A dedicated modal layout for content
  3. A helper class for generating modal-triggering links
  4. JavaScript handlers for modal lifecycle events

Basic Setup

First, add the modal container to your default layout: <!-- /templates/element/modal_container.php --> <?php if (!$this->getRequest()->is('htmx')): ?> <div id="modal-area" class="modal modal-blur fade" style="display: none" aria-hidden="false" tabindex="-1"> <div class="modal-dialog modal-lg modal-dialog-centered" role="document"> <div class="modal-content"></div> </div> </div> <script type="text/x-template" id="modal-loader"> <div class="modal-body d-flex justify-content-center align-items-center" style="min-height: 200px;"> <div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"> <span class="visually-hidden">Loading...</span> </div> </div> </script> <?php endif; ?>

Modal Layout

Create a dedicated layout for modal content: <!-- /templates/layout/modal.php --> <?php /** * Modal layout */ echo $this->fetch('css'); echo $this->fetch('script'); ?> <div class="modal-dialog modal-dialog-centered"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><?= $this->fetch('title') ?></h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> </div> <div class="modal-body"> <?= $this->fetch('content') ?> </div> </div> </div>

Modal Helper

Create a helper to generate modal-triggering links: <?php // /src/View/Helper/ModalHelper.php declare(strict_types=1); namespace App\View\Helper; use Cake\View\Helper; class ModalHelper extends Helper { protected array $_defaultConfig = [ 'modalTarget' => '#modal-area', ]; public array $helpers = ['Html', 'Url']; public function link(string $title, array|string $url, array $options = []): string { $defaultOptions = $this->getModalOptions($this->Url->build($url), 'get'); $options = array_merge($defaultOptions, $options); return $this->Html->tag('a', $title, $options); } public function getModalOptions(string $url, string $method): array { $options = [ 'hx-target' => $this->getConfig('modalTarget'), 'hx-trigger' => 'click', 'hx-headers' => json_encode([ 'X-Modal-Request' => 'true', ]), 'href' => 'javascript:void(0)', 'data-bs-target' => $this->getConfig('modalTarget'), ]; if (strtolower($method) === 'get') { $options['hx-get'] = $url; } else { $options['hx-' . strtolower($method)] = $url; } return $options; } }

Controller Integration

Update your AppController to handle modal requests: // /src/Controller/AppController.php public function beforeRender(EventInterface $event) { if ($this->isModalRequest()) { $this->viewBuilder()->setLayout('modal'); $this->viewBuilder()->enableAutoLayout(); } } protected function isModalRequest(): bool { return $this->getRequest()->getHeader('X-Modal-Request') !== []; }

JavaScript Integration

Add event handlers to manage modal behavior in your application's JavaScript: // /webroot/js/app.js document.addEventListener('htmx:beforeRequest', function(evt) { const target = evt.detail.target; if (target.id === 'modal-area') { const modalContent = document.querySelector('#modal-area .modal-content'); if (modalContent) { modalContent.innerHTML = document.getElementById('modal-loader').innerHTML; } const modal = bootstrap.Modal.getInstance(target) || new bootstrap.Modal(target); modal.show(); } }); This handler ensures proper modal initialization and loading state display.

Usage Example

To create a modal-triggering link: <!-- /templates/Posts/infinite.php --> <?= $this->Modal->link(__('Edit'), ['action' => 'edit', $post->id]) ?>

Implementing Edit Form in Modal

Let's look at how to implement a complete edit form using modals. This requires changes to both the controller action and template.

Controller Action

Update the edit action to handle both regular and modal requests: // /src/Controller/PostsController.php public function edit($id = null) { $post = $this->Posts->get($id, contain: []); if ($this->request->is(['patch', 'post', 'put'])) { $post = $this->Posts->patchEntity($post, $this->request->getData()); $success = $this->Posts->save($post); if ($success) { $message = __('The post has been saved.'); $status = 'success'; } else { $message = __('The post could not be saved. Please, try again.'); $status = 'error'; } $redirect = Router::url(['action' => 'index']); if ($this->getRequest()->is('htmx')) { if ($success) { $response = [ 'messages' => [ ['message' => $message, 'status' => $status], ], 'reload' => true, ]; return $this->getResponse() ->withType('json') ->withHeader('X-Response-Type', 'json') ->withStringBody(json_encode($response)); } } else { $this->Flash->{$status}($message); if ($success) { return $this->redirect($redirect); } } } $this->set(compact('post')); if ($this->getRequest()->is('htmx')) { $this->Htmx->setBlock('post'); } }

Edit Form Template

Create a template that works both as a standalone page and within a modal: <!-- /templates/Posts/edit.php --> <?php $this->assign('title', __('Edit Post')); ?> <?php $this->start('post'); ?> <div class="row"> <div class="column-responsive column-80"> <div class="posts form content"> <?= $this->Form->create($post) ?> <fieldset> <?php echo $this->Form->control('title'); echo $this->Form->control('body'); echo $this->Form->control('overview'); echo $this->Form->control('is_published'); ?> </fieldset> <?= $this->Form->button(__('Submit')) ?> <?= $this->Form->end() ?> </div> </div> </div> <?php $this->end(); ?> <?= $this->fetch('post'); ?> The edit implementation seamlessly handles both modal and regular form submissions while maintaining consistent behavior across different request types. When validation errors occur, they are displayed directly within the modal, providing immediate feedback to users. Upon successful save, the page automatically refreshes to reflect the changes, and users receive feedback through toast notifications that appear in the corner of the screen. The response processing on the client side follows the same pattern we explored in the previous article of this series.

Modal Workflow

Our modal implementation creates a smooth, intuitive user experience through several coordinated steps. During initial setup, we add the modal container to the default layout and initialize Bootstrap's modal component. A loading indicator template is also defined to provide visual feedback during content loading. When a user clicks a modal-triggering link, HTMX sends a request with a special X-Modal-Request header. During this request, the loading indicator appears, giving users immediate feedback that their action is being processed. The server recognizes the modal request through the special header and switches to the modal layout. This layout ensures content is properly formatted for display within the modal structure. As soon as the content is ready, the modal automatically appears on screen with a smooth animation. For form submissions within the modal, HTMX handles the process using its attributes system. The server's response determines whether to update the modal's content (in case of validation errors) or close it (on successful submission). Throughout this process, toast notifications keep users informed of the operation's status, appearing briefly in the corner of the screen before automatically fading away.

Conclusion

Implementing inline editing and lazy loading actions with htmx in CakePHP demonstrates the framework's flexibility and htmx's power in creating dynamic interfaces. The combination allows developers to build modern, responsive features with minimal JavaScript while maintaining clean, maintainable code. CakePHP's built-in helpers and htmx's declarative approach work together seamlessly to create a superior user experience. This article is the last one of the series of articles about htmx and CakePHP. We have covered a lot of ground and I hope you have learned something new and useful.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-htmx/tree/3.0.0 and available for testing. This article is part of the CakeDC Advent Calendar 2024 (December 7th 2024)

Integrate Vite for front side in CakePHP

This article is part of the CakeDC Advent Calendar 2024 (December 6th 2024) Vite can be easily integrated to manage assets such as JavaScript, CSS, images, and fonts. This integration is particularly useful in modern applications where the frontend and backend work together. Advantages of Using Vite with CakePHP

  • Development Optimization: HMR allows developers to see changes instantly in the browser, improving the development experience. There's no need to refresh the page constantly to view updates.
  • Efficient Bundling: Production assets are minimized and optimized, reducing loading times.
  • Modern Technology Adoption: It enables the integration of modern frontend tools like Vue.js, React, or TypeScript into traditional CakePHP projects.
Use Cases
  • Applications with Dynamic Frontends: Ideal for CakePHP projects where the frontend requires interactive components built with React or Vue.js.
  • Hybrid Applications: Integration of a SPA (Single Page Application) with a robust backend like CakePHP.
  • Enterprise Applications: Management of dashboards with interactive charts and reports, leveraging modern frontend tools while using CakePHP for data handling and business logic.

1.- Create a new View:

src/View/ViteView.php declare(strict_types=1); namespace App\View; use Cake\View\View; class ViteView extends View { public function initialize(): void { $this->loadHelper('Vite'); } }

2.- Create a new Trait

src/Traits/ViteResponseTrait.php namespace App\Traits; use App\View\ViteView; use Cake\Event\EventInterface; trait ViteResponseTrait { public function beforeRender(EventInterface $event) { $this->viewBuilder()->setClassName(ViteView::class); } }

3.- Create a new Helper

src/View/Helper/ViteHelper.php namespace App\View\Helper; use Cake\Routing\Router; use Cake\View\Helper; class ViteHelper extends Helper { public array $helpers = ['Html']; public function loadAssets(): string { if (!file_exists(WWW_ROOT . 'hot')) { $manifest = json_decode( file_get_contents(WWW_ROOT . 'js' . DS . 'manifest.json'), true ); $path = Router::fullBaseUrl() . DS . 'js' . DS; $firstBlock = []; $secondBlock = []; foreach($manifest as $key => $data){ $part = explode('.', $key); $part = $part[count($part) - 1]; if ($part == 'css') { $firstBlock[] = $this->Html->tag( 'link', '', ['as' => 'style', 'rel' => 'preload', 'href' => $path . $data['file']] ); $secondBlock[] = $this->Html->tag( 'link', '', ['as' => 'style', 'rel' => 'stylesheet', 'href' => $path . $data['file']] ); } if ($part == 'js') { $firstBlock[] = $this->Html->tag( 'link', '', ['as' => 'style', 'rel' => 'preload', 'href' => $path . $data['css'][0]] ); $secondBlock[] = $this->Html->tag( 'link', '', ['as' => 'style', 'rel' => 'stylesheet', 'href' => $path . $data['css'][0]] ); $firstBlock[] = $this->Html->tag( 'link', '', ['rel' => 'modulepreload', 'href' => $path . $data['file']] ); $secondBlock[] = $this->Html->tag( 'script', '', ['type' => 'module', 'src' => $path . $data['file']] ); } } return implode('', $firstBlock) . implode('', $secondBlock); } else { $domain = file_get_contents(WWW_ROOT . 'hot'); $head = $this->Html->script( $domain . '/@vite/client', ['rel' => 'preload', 'type' => 'module'] ); $head .= $this->Html->css($domain . '/resources/css/app.css'); $head .= $this->Html->script( $domain . '/resources/js/app.js', ['rel' => 'preload', 'type' => 'module'] ); return $head; } } }

4.- Create function vite and add the trait to the controller

src/Controller/PagesController.php use App\Traits\ViteResponseTrait; class PagesController extends AppController { use ViteResponseTrait; public function vite() { $this->viewBuilder()->setLayout('vite'); } }

5.- Add a new layout

templates/layout/vite.php
<!DOCTYPE html> <head lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Cake with Vite and Vue</title> <?php echo $this->Vite->loadAssets();?> </head> <body class="antialiased"> <div id="app"> <example-component></example-component> </div> </body> </html>

6 .- Install and configure Vite (using DDEV)

on .ddev/config.yaml add this new configuration and run ddev restart web_extra_exposed_ports: - name: vite container_port: 5173 http_port: 5172 https_port: 5173 create package.json { "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build" }, "devDependencies": { "autoprefixer": "^10.4.20", "laravel-vite-plugin": "^1.0.0", "vite": "^5.0.0" }, "dependencies": { "@vitejs/plugin-vue": "^5.1.4", "vue": "^3.5.8", "vuex": "^4.0.2" } } create vite.config.js import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import vue from '@vitejs/plugin-vue'; const port = 5173; const origin = `${process.env.DDEV_PRIMARY_URL}:${port}`; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], publicDirectory: 'webroot', refresh: true, }), vue(), ], build: { outDir: 'webroot/js' }, server: { host: '0.0.0.0', port: port, strictPort: true, origin: origin }, }); create .env APP_NAME=cakePHP APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL="https://advent2024.ddev.site" to install and configure all run in console: ddev npm install

7. Create an Example App

resources/js/components/ExampleComponent.vue
<template> <div class="text-center p-4 bg-blue-100 rounded-lg"> <h1 class="text-2xl font-bold">Hello from Vue 3!</h1> <p class="mt-2">This is a Vue component integrated with CakePHP and Vite.</p> <h2 class="mt-4">Counter: {{ count }}</h2> <p class="mt-2"> <button @click="increment">Increment</button> <button @click="decrement">Decrement</button> </p> </div> </template> <script> import { mapGetters, mapActions } from 'vuex'; export default { name: 'ExampleComponent', computed: { ...mapGetters(['getCount']), count() { return this.getCount; }, }, methods: { ...mapActions(['increment', 'decrement']), }, }; </script> <style scoped> button { margin: 5px; } </style>
  resources/js/app.js import { createApp } from 'vue'; import ExampleComponent from './components/ExampleComponent.vue'; import store from './store'; createApp(ExampleComponent).use(store).mount('#app'); resources/js/store.js import {createStore} from 'vuex'; const store = createStore({ state: { count: Number(localStorage.getItem('count')) || 0, }, mutations: { increment(state) { state.count++; localStorage.setItem('count', state.count); }, decrement(state) { state.count--; localStorage.setItem('count', state.count); }, }, actions: { increment({ commit }) { commit('increment'); }, decrement({ commit }) { commit('decrement'); }, }, getters: { getCount(state) { return state.count; }, }, });

8.- Launch

For development run Vite ddev npm run dev For production run Vite build ddev npm run build This generates the assets directory inside webroot dir, the helper automatically load the files parsing the manifest.json You can see in front the app   You can see a complete example in https://github.com/ACampanario/advent2024        

Generate JWT with CakePHP and Authenti...

This article is part of the CakeDC Advent Calendar 2024 (December 5th 2024) The use of JWT in communication is vital for modern applications because of its ability to ensure secure authentication and authorization. As applications shift toward distributed architectures, such as microservices or client-server models, JWT becomes an essential tool for securely transmitting sensitive data. Advantages

  • JWT is ideal for authentication, as it can include user information and access levels. Once authenticated, the client can make multiple requests without re-authenticating, optimizing performance.
  • JWT includes an expiration time (exp), which mitigates the risk of compromised tokens and ensures expired tokens cannot be reused.
  • JWT is an open standard, and widely supported, which enables seamless integration between different systems and services, even if they use different programming languages.
Cases of use
  • After login, a JWT containing user data is sent to the client, allowing them to access protected resources.
  • Allows services to validate requests independently, without relying on a central authentication server.
  • JWT ensures that data exchanged between APIs is not altered, providing mutual trust.

1.- Add with composer the library https://github.com/lcobucci/jwt

  composer require "lcobucci/jwt"

2.- Create the logic to generate the token with the secret phrase

src/Controller/PagesController.php use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key; class PagesController extends AppController { public function generateJwt() { $configuration = Configuration::forSymmetricSigner( new Sha256(), Key\InMemory::plainText('EBB86CEF-63B0-411E-BA99-55F68E39049C1732552248') ); $now = FrozenTime::now(); $token = $configuration->builder() ->issuedBy('https://advent.ddev.site') ->permittedFor('https://advent.ddev.site/') ->identifiedBy('4f1g23a12aa') ->issuedAt($now) ->expiresAt($now->modify('+1 hour')) ->withClaim('uid', 'Generated JWT in CakePHP and decoded on a node app') ->getToken($configuration->signer(), $configuration->signingKey()) ->toString(); $this->set('token', $token); }

3.- In the view of this function load socket.io and connect to your server and port.

templates/Pages/generate_jwt.php
<div id="message"></div> <script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script> <script type="module"> const socket = io('https://advent2024.ddev.site:8182/', { auth: { token: '<?= $token; ?>' } }); socket.on('welcome', (message) => { document.getElementById('message').innerHTML = message; console.log('Connnected!!!' + message) }); socket.on('connect_error', (err) => { document.getElementById('message').innerHTML = 'Error connecting to the server'; }); </script>

4.- Create the node server, for example in: node/app.js

You can check the authentication in the function by using jsonwebtoken verify for the token received, and the secret JWT_SECRET, which is the same as the one used when the token was generated. import express from 'express'; import { createServer } from 'http'; import jsonwebtoken from 'jsonwebtoken'; import { Server } from 'socket.io'; const { sign, decode, verify } = jsonwebtoken; let app = express(); const httpServer = createServer(app); const io = new Server(httpServer, { cors: { origin: '*' } }); // jwt secret const JWT_SECRET = 'EBB86CEF-63B0-411E-BA99-55F68E39049C1732552248'; //authentication middleware io.use(async(socket, next) => { // fetch token from handshake auth sent by FE const token = socket.handshake.auth.token; try { // verify jwt token and get user data and save the user data into socket object, to be used further socket.decoded = await jsonwebtoken.verify(token, JWT_SECRET); console.log(socket.decoded); next(); } catch (e) { // if token is invalid, close connection console.log('info', `Not Valid authentication! ${e.message} disconnected!`); return next(new Error(e.message)); } }); io.on('connection',function (socket) { console.log('info',`A client with socket id ${socket.id} connected!`); // Emitir un mensaje de bienvenida al cliente socket.emit('welcome', 'Welcome to Node server!!!'); }); httpServer.listen(8180); console.log('listen...');

5.- Launch the Node server

then execute your node server. In the below image, you see printed the valid token decoded. node node/app.js Connection on client side Conclusion Using JWT is crucial for modern systems that require secure, efficient, and scalable communication. Its ability to encapsulate signed and verifiable information, along with its lightweight design and open standard, makes it a powerful tool for distributed architectures and applications reliant on robust authentication and authorization. Implementing JWT in an application ensures an optimal balance between security and performance. You can see a complete example in https://github.com/ACampanario/advent2024 This article is part of the CakeDC Advent Calendar 2024 (December 5th 2024)

Building Dynamic Web Applications with...

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

Other Articles in the Series

In this tutorial, we'll demonstrate how to implement infinite scroll pagination using htmx in CakePHP applications. Infinite scroll has become a popular user interface pattern, allowing users to seamlessly load more content as they scroll down a page. We'll implement this pattern for both table and card layouts, showing the flexibility of htmx in handling different UI scenarios. This article continues our development based on the application created in the previous tutorial. As initial setup, we've added Bootstrap 5 styles to our layout to enhance the visual presentation.

Implementing Infinite Table Pagination

Our implementation maintains the same controller logic from the previous article, but introduces significant view changes. We've removed the traditional pagination block and instead added pagination functionality as the last table row when there's content to load. This creates a seamless scrolling experience without explicit page numbers. When this last row is revealed, htmx will load the next page of results. <?php // templates/Post/infinite.php $rows = 0; ?> <div id="posts" class="posts index content"> <?php $this->start('posts'); ?> <?= $this->Html->link(__('New Post'), ['action' => 'add'], ['class' => 'button float-right']) ?> <h3><?= __('Posts') ?></h3> <div class="table-container"> <div id="table-loading" class="htmx-indicator"> <div class="spinner"></div> </div> <div class="table-responsive"> <table id="posts-table"> <thead hx-boost="true" hx-target="#posts" hx-indicator="#table-loading" hx-push-url="true" > <tr> <th><?= $this->Paginator->sort('id') ?></th> <th><?= $this->Paginator->sort('title') ?></th> <th><?= $this->Paginator->sort('is_published') ?></th> <th><?= $this->Paginator->sort('created') ?></th> <th><?= $this->Paginator->sort('modified') ?></th> <th class="actions"><?= __('Actions') ?></th> </tr> </thead> <tbody> <?php foreach ($posts as $post): ?> <tr class="item-container"> <td><?= $this->Number->format($post->id) ?></td> <td><?= h($post->title) ?></td> <td><?= h($post->is_published) ?></td> <td><?= h($post->created) ?></td> <td><?= h($post->modified) ?></td> <td class="actions"> <?= $this->Html->link(__('View'), ['action' => 'view', $post->id]) ?> <?= $this->Html->link(__('Edit'), ['action' => 'edit', $post->id]) ?> <?= $this->Form->postLink(__('Delete'), ['action' => 'delete', $post->id], ['confirm' => __('Are you sure you want to delete # {0}?', $post->id)]) ?> </td> </tr> <?php $rows++; ?> <?php endforeach; ?> <?php if ($rows > 0): ?> <tr hx-get="<?= $this->Paginator->generateUrl(['page' => $this->Paginator->current() + 1]) ?>" hx-select="#posts-table tbody tr" hx-swap="outerHTML" hx-trigger="intersect" class="infinite-paginator" > <td class="text-center" colspan="6"> <div class="d-flex justify-content-center align-items-center py-2"> <i class="fas fa-spinner fa-spin me-2"></i> <span><?= __('Loading more...') ?></span> </div> </td> </tr> <?php elseif (($this->getRequest()->getQuery('page', 1) == 1)): ?> <tr> <td class="text-center" colspan="6"><?= __('No items found') ?></td> </tr> <?php endif; ?> </tbody> </table> </div> </div> <?php $this->end(); ?> <?= $this->fetch('posts'); ?> </div> The htmx attributes used for table pagination are:
  • hx-get: Specifies the URL for the next page of results
  • hx-select: Targets only the table rows from the response
  • hx-swap="outerHTML": Replaces the loading row with new content
  • hx-trigger="intersect": Activates when the element becomes visible in the viewport
  • class="infinite-paginator": Allows styling of the loading indicator

Card-Based Infinite Pagination

Card-based layouts are increasingly important for modern frontend designs, especially for content-rich applications. This layout style provides better visual hierarchy and improved readability for certain types of content. Instead of bind htmx to last table row, we bind htmx to last card in the grid, and when this card is revealed, htmx will load the next page of results. <?php // templates/Post/cards.php $rows = 0; ?> <div id="posts" class="posts index content"> <?php $this->start('posts'); ?> <?= $this->Html->link(__('New Post'), ['action' => 'add'], ['class' => 'button float-right']) ?> <h3><?= __('Posts') ?></h3> <div class="row"> </div> <div class="cards-grid"> <?php foreach ($posts as $index => $post): ?> <div class="card item-container" <?php if ($index === count($posts) - 1): ?> hx-get="<?= $this->Paginator->generateUrl(['page' => $this->Paginator->current() + 1]) ?>" hx-trigger="revealed" hx-swap="afterend" hx-select="div.card" hx-target="this" hx-headers='{"HX-Disable-Loader": "true"}' hx-indicator="#infinite-scroll-indicator" <?php endif; ?>> <div class="card-content"> <h3><?= h($post->title) ?></h3> <p class="post-body"><?= h($post->body) ?></h3> <p class="post-created"><?= h($post->created) ?></p> </div> </div> <?php $rows++; ?> <?php endforeach; ?> </div> <?php if ($rows > 0): ?> <div id="infinite-scroll-indicator" class="d-flex justify-content-center align-items-center py-3"> <i class="fas fa-spinner fa-spin me-2"></i> <span><?= __('Loading more...') ?></span> </div> <?php endif; ?> <?php $this->end(); ?> <?= $this->fetch('posts'); ?> </div> The htmx attributes for card-based pagination differ slightly from the table implementation:
  • hx-trigger="revealed": Triggers when the last card becomes visible
  • hx-target="this": Targets the current card element
  • hx-swap="afterend": Places new content after the current element
  • hx-headers: Disables the default loading indicator
We use revealed instead of intersect for cards because it provides better control over the trigger point. The hx-target="this" is crucial here as it allows us to maintain proper positioning of new cards in the grid layout. Unlike the table implementation, we can't remove the loader div in the same way, which is why we have to use a different approach for handling the loading state. .cards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; padding: 1.5rem; } .cards-grid .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem; position: relative; } .cards-grid .card-content { display: flex; flex-direction: column; gap: 0.75rem; } .cards-grid .post-body { display: flex; flex-direction: column; } .cards-grid .post-created { font-weight: bold; font-size: 0.875rem; color: #666; } .cards-grid .field-value { margin-top: 0.25rem; } @media (max-width: 640px) { .cards-grid { grid-template-columns: 1fr; padding: 1rem; } } .cards-grid .infinite-scroll-trigger { width: 100%; min-height: 60px; margin-bottom: 1.5rem; }

Enhanced Table Row Deletion

With infinite loading implemented, we want to avoid full page reloads when deleting items. This creates a more fluid user experience and maintains the scroll position.

Initial Layout Setup

To support our enhanced deletion functionality, we need to add CSRF protection and pass it to htmx requests. # /templates/layout/default.php <meta name="csrf-token" content="<?= $this->request->getAttribute('csrfToken') ?>"> We also need to include toast library to display messages. # /templates/layout/default.php <?= $this->Html->css('toast'); ?> <?= $this->Html->script('toast'); ?>

Controller Updates for Delete Action

The delete action now supports two modes: traditional and htmx-based deletion. When using htmx, the response includes a JSON object containing the status message and instructions for removing the deleted item from the DOM. <?php public function delete($id = null) { $this->request->allowMethod(['post', 'delete']); $post = $this->Posts->get($id); $deleted = $this->Posts->delete($post); if ($deleted) { $message = __('The post has been deleted.'); $status = 'success'; } else { $message = __('The post could not be deleted. Please, try again.'); $status = 'error'; } if ($this->getRequest()->is('htmx')) { $response = [ 'messages' => [ ['message' => $message, 'status' => $status], ], 'removeContainer' => true, ]; return $this->getResponse() ->withType('json') ->withHeader('X-Response-Type', 'json') ->withStringBody(json_encode($response)); } else { $this->Flash->{$status}($message); return $this->redirect(['action' => 'index']); } }

View Updates for Delete Action

We're replacing the standard CakePHP form postLink with a htmx-based delete link. This approach allows us to handle the deletion process entirely through JavaScript, providing a more dynamic and seamless user experience. We define container class for item to be deleted, in case of table this is tr.item-container, in case of cards this is div.card.item-container.

Standard CakePHP Form PostLink

# /templates/Post/infinite.php <?= $this->Form->postLink(__('Delete'), ['action' => 'delete', $post->id], ['confirm' => __('Are you sure you want to delete # {0}?', $post->id)]) ?>

HTMX-Based Delete Link

# /templates/Post/infinite.php <?php $csrfToken = $this->getRequest()->getAttribute('csrfToken'); $linkOptions = [ 'hx-delete' => $this->Url->build(['action' => 'delete', $post->id]), 'hx-confirm' => __('Are you sure you want to delete # {0}?', $post->id), 'hx-target' => 'closest .item-container', 'hx-headers' => json_encode([ 'X-CSRF-Token' => $csrfToken, 'Accept' => 'application/json', ]), 'href' => 'javascript:void(0)', ]; echo $this->Html->tag('a', __('Delete'), $linkOptions); ?> htmx allow define headers in htmx-delete link, so we can include CSRF token and accept JSON response. The htmx attributes for deletion:
  • hx-delete: Specifies the deletion endpoint
  • hx-confirm: Shows a confirmation dialog
  • hx-target: Targets the container of the item to be deleted
  • hx-headers: Includes necessary CSRF token and accepts JSON response

HTMX JavaScript Callbacks

The JavaScript code handles two main aspects:
  1. configRequest: Ensures CSRF token is included in all htmx requests
  2. beforeSwap: Manages the response handling, including:
    • Displaying toast notifications
    • Animating the removal of deleted items
    • Handling page reloads when necessary
# /templates/Post/infinite.php <script> let toasts = new Toasts({ offsetX: 20, offsetY: 20, gap: 20, width: 300, timing: 'ease', duration: '.5s', dimOld: true, position: 'top-right', dismissible: true, autoClose: true, }); document.addEventListener('htmx:configRequest', function(event) { const element = event.detail.elt; const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); if (csrfToken) { event.detail.headers['X-CSRF-Token'] = csrfToken; } }); document.addEventListener('htmx:beforeSwap', function(evt) { const xhr = evt.detail.xhr; const responseType = xhr.getResponseHeader('X-Response-Type'); if (responseType === 'json') { try { const data = JSON.parse(xhr.responseText); evt.detail.shouldSwap = false; if (data.messages) { data.messages.forEach(message => { toasts.push({ title: message.message, content: '', style: message.status, dismissAfter: '10s', dismissible: true, }); }); } if (data.removeContainer) { const item = evt.detail.target.closest('.item-container'); if (item) { evt.detail.shouldSwap = false; item.style.transition = 'opacity 0.5s ease-out'; item.style.opacity = '0'; setTimeout(() => { item.style.transition = 'max-height 0.5s ease-out'; item.style.maxHeight = '0'; setTimeout(() => { item.remove(); }, 500); }, 500); } } if (data.reload) { if (data.url) { window.location.href = data.url; } else { window.location.reload(); } } } catch (e) { console.error('JSON parsing error:', e); } } }); </script>

Conclusion

Implementing infinite scroll pagination and enhanced deletion with htmx in CakePHP demonstrates the framework's flexibility and htmx's power in creating dynamic interfaces. The combination allows developers to build modern, responsive features with minimal JavaScript while maintaining clean, maintainable code. CakePHP's built-in helpers and htmx's declarative approach work together seamlessly to create a superior user experience.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-htmx/tree/2.0.0 and available for testing. This article is part of the CakeDC Advent Calendar 2024 (December 4th 2024)

Dynamic Enums (CakePHP 5 / PHP 8.3)

This article is part of the CakeDC Advent Calendar 2024 (December 3rd 2024)

In modern PHP development, utilizing constants and enums can significantly improve code readability, maintainability, and robustness. This article explores practical implementations of constants and enums in a CakePHP application.  By leveraging these features, developers can write cleaner and more efficient code. Let's dive into the specifics with code examples and practical use cases.
 

1.- Define a constant class of user roles:

src/Constants/UserRoles.php namespace App\Constants; class UserRoles { public const string ADMIN = 'admin'; public const string EDITOR = 'editor'; public const string VIEWER = 'viewer'; }

2.- Define an Enum class for the status of the posts:

src/Enum/PostStatus.php namespace App\Enum; enum PostStatus: string { case DRAFT = 'draft'; case PUBLISHED = 'published'; case ARCHIVED = 'archived'; public static function list(): array { return [ self::DRAFT->value => __('DRAFT'), self::PUBLISHED->value => __('PUBLISHED'), self::ARCHIVED->value => __('ARCHIVED'), ]; } }

3.- Define an Enum class for the status of the comments:

src/Enum/CommentStatus.php namespace App\Enum; enum CommentStatus: string { case APPROVED = 'approved'; case DECLINED = 'declined'; public static function list(): array { return [ self::APPROVED->value => __('APPROVED'), self::DECLINED->value => __('DECLINED'), ]; } }

4.- Check the dynamic use in the access to the Enums values.

templates/Pages/enums.php // Dynamic constants new in php 8.3 and php 8.4 use App\Constants\UserRoles; use App\Enum\PostStatus; $roleName = 'ADMIN'; $roleValue = UserRoles::{$roleName}; // Dynamically accesses UserRoles::ADMIN echo "Role Value: " . $roleValue; // Print “Role Value: admin” // Dynamic Enum members new in php 8.3 and php 8.4 $statusName = 'PUBLISHED'; $statusValue = PostStatus::{$statusName}->value; // Dynamically accesses PostStatus::Published echo "Post Status: " . $statusValue; // Print “Post Status: published”

5.- We can also use its functions, for example “list” for selectors, and print its value.

//generate a select element with all status options echo $this->Form->control('status', ['options' => PostStatus::list(), 'empty' => true]); //print the text associated with the status value echo h(PostStatus::list()['published']);

6.- They can be used dynamically in query operations, for example: to get a post and only your comments approved.

src/Controller/PostsController.php $postWithCommentsApproved = $this->Posts ->find('onlyCommentsByStatusEnum', status: CommentStatus::APPROVED) ->contain(['Users']) ->where(['Posts.id' => $id]) ->firstOrFail(); src/Model/Table/PostsTable.php public function findOnlyCommentsByStatusEnum(SelectQuery $query, CommentStatus $status): SelectQuery { return $this->find() ->contain(['Comments' => function ($q) use ($status) { return $q->where([ 'Comments.status' => $status->value, ]); }]); } You can see a complete example in https://github.com/ACampanario/advent2024.

Building Dynamic Web Applications with...

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

Other Articles in the Series

This article explores how to integrate htmx with CakePHP to create more dynamic and interactive web applications while writing less JavaScript code. We'll cover the basics of htmx, its setup with CakePHP, and practical examples to demonstrate its power.

Introduction to htmx library

htmx is a modern JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML, using attributes. It's designed to be simple, powerful, and a natural extension of HTML's existing capabilities. The library's main purpose is to allow you to build modern user interfaces with the simplicity of HTML, reducing the need for complex JavaScript. Instead of writing JavaScript to handle frontend interactions, you can use HTML attributes to define dynamic behaviors. htmx works by intercepting HTML events (like clicks or form submissions), making HTTP requests in the background, and updating the DOM with the response. This approach, often called "hypermedia-driven applications," allows for rich interactivity while maintaining the simplicity of the web's original architecture.

Basic setup with CakePHP

To get started with htmx in your CakePHP application, follow these steps:
  1. Install the CakePHP htmx plugin using Composer: composer require zunnu/cake-htmx
  2. Load the htmx JavaScript library in your layout file (templates/layout/default.php): <?= $this->Html->script('https://unpkg.com/[email protected]') ?>
  3. Load the plugin in your application (Application.php): public function bootstrap(): void { // ... other plugins $this->addPlugin('CakeHtmx'); }

Boost your CakePHP application with htmx

One of the simplest yet powerful features of htmx is the hx-boost attribute. By adding this attribute to any container element (often the <body> tag), you can automatically enhance all anchor tags and forms within that container to use AJAX instead of full page loads.

Basic Implementation

Add the hx-boost attribute to your layout file (templates/layout/default.php): <body hx-boost="true"> <?= $this->Flash->render() ?> <?= $this->fetch('content') ?> </body> With this single attribute, all links and forms in your application will automatically use AJAX requests instead of full page loads. The content will be smoothly updated without refreshing the page, while maintaining browser history and back/forward button functionality.

How it Works

When hx-boost is enabled:
  1. Clicks on links (<a> tags) are intercepted
  2. Form submissions are captured
  3. Instead of a full page load, htmx makes an AJAX request
  4. The response's <body> content replaces the current page's <body>
  5. The URL is updated using the History API
  6. Browser history and navigation work as expected

Practical Example

Here's a typical CakePHP navigation setup enhanced with hx-boost: <!-- templates/layout/default.php --> <!DOCTYPE html> <html> <head> <title><?= $this->fetch('title') ?></title> <?= $this->Html->script('https://unpkg.com/[email protected]') ?> </head> <body hx-boost="true"> <nav> <?= $this->Html->link('Home', ['controller' => 'Pages', 'action' => 'display', 'home']) ?> <?= $this->Html->link('Posts', ['controller' => 'Posts', 'action' => 'index']) ?> <?= $this->Html->link('About', ['controller' => 'Pages', 'action' => 'display', 'about']) ?> </nav> <main> <?= $this->Flash->render() ?> <?= $this->fetch('content') ?> </main> </body> </html>

Selective Boosting

You can also apply hx-boost to specific sections of your page: <!-- Only boost the post list --> <div class="post-section" hx-boost="true"> <?php foreach ($posts as $post): ?> <?= $this->Html->link( $post->title, ['action' => 'view', $post->id], ['class' => 'post-link'] ) ?> <?php endforeach; ?> </div> <!-- Regular links outside won't be boosted --> <div class="external-links"> <a href="https://example.com">External Link</a> </div>

Excluding Elements

You can exclude specific elements from being boosted using hx-boost="false": <body hx-boost="true"> <!-- This link will use AJAX --> <?= $this->Html->link('Profile', ['controller' => 'Users', 'action' => 'profile']) ?> <!-- This link will perform a full page load --> <a href="/logout" hx-boost="false">Logout</a> </body> The hx-boost attribute provides a simple way to enhance your CakePHP application's performance and user experience with minimal code changes. It's particularly useful for:
  • Navigation between pages
  • Form submissions
  • Search results
  • Pagination
  • Any interaction that traditionally requires a full page reload
By using hx-boost, you get the benefits of single-page application-like behavior while maintaining the simplicity and reliability of traditional server-rendered applications.

Going deeper with htmx with custom attributes

First, let's see how we can transform a traditional CakePHP index page to use htmx.

Index page example

Here's a traditional index page without htmx, showing a list of posts: // PostsController.php public function index() { $query = $this->Posts->find(); $posts = $this->paginate($query, ['limit' => 12]); $this->set(compact('posts')); } <!-- templates/Posts/index.php --> <div class="posts index content"> <div class="table-responsive"> <table> <thead> <tr> <th><?= $this->Paginator->sort('id') ?></th> <?php // .... ?> </tr> </thead> <tbody> <?php foreach ($posts as $post): ?> <?php // .... ?> <?php endforeach; ?> </tbody> </table> </div> <div class="paginator"> <ul class="pagination"> <?php // .... ?> </ul> </div> </div>

Index page example with htmx

Now, let's enhance the same page with htmx to handle pagination and sorting without page reloads: // PostsController.php public function index() { $query = $this->Posts->find(); $posts = $this->paginate($query, ['limit' => 12]); $this->set(compact('posts')); if($this->getRequest()->is('htmx')) { $this->viewBuilder()->disableAutoLayout(); $this->Htmx->setBlock('posts'); } } <!-- templates/Posts/index.php --> <div id="posts" class="posts index content"> <?php $this->start('posts'); ?> <div class="table-container"> <div id="table-loading" class="htmx-indicator"> <div class="spinner"></div> </div> <div class="table-responsive"> <table> <thead hx-boost="true" hx-target="#posts" hx-indicator="#table-loading" hx-push-url="true" > <tr> <th><?= $this->Paginator->sort('id') ?></th> <?php // .... ?> </tr> </thead> <tbody> <?php foreach ($posts as $post): ?> <?php // .... ?> <?php endforeach; ?> </tbody> </table> </div> <div class="paginator" hx-boost="true" hx-target="#posts" hx-indicator="#table-loading" hx-push-url="true" > <ul class="pagination"> <?php // .... ?> </ul> </div> </div> <?php $this->end(); ?> </div> <?= $this->fetch('posts'); ?> Now let's look at the changes we made to the controller and the HTML structure.

Controller Changes

In the controller, we've added htmx-specific handling. When a request comes from htmx, we:
  1. Disable the layout since we only want to return the table content
  2. Use the Htmx helper to set a specific block that will be updated
  3. Maintain the same pagination logic, making it work seamlessly with both regular and htmx requests

Out-of-Band (OOB) Swaps with htmx

htmx supports Out-of-Band (OOB) Swaps, which allow you to update multiple elements on a page in a single request. This is particularly useful when you need to update content in different parts of your page simultaneously, such as updating a list of items while also refreshing a counter or status message.

How OOB Works

  1. In your response HTML, include elements with hx-swap-oob="true" attribute
  2. These elements will update their counterparts on the page based on matching IDs
  3. The main response content updates normally, while OOB content updates independently

HTML Structure Changes

The main changes to the HTML structure include:
  1. Adding an outer container with a specific ID (posts) for targeting updates
  2. Wrapping the content in a block using $this->start('posts') and $this->end() to allow for OOB swaps
  3. Adding a loading indicator element
  4. Implementing htmx attributes on the table header and paginator sections

HTMX Attributes Explained

The following htmx attributes were added to enable dynamic behavior:
  • hx-boost="true": Converts regular links into AJAX requests
  • hx-target="#posts": Specifies where to update content (the posts container)
  • hx-indicator="#table-loading": Shows/hides the loading spinner
  • hx-push-url="true": Updates the browser URL for proper history support
These attributes work together to create a smooth, dynamic experience while maintaining proper browser history and navigation.

Loading Indicator Implementation

The loading indicator provides visual feedback during AJAX requests:
  1. A centered spinner appears over the table during loading
  2. The table content is dimmed using CSS opacity
  3. The indicator is hidden by default and only shows during htmx requests
  4. CSS transitions provide smooth visual feedback
.table-container { position: relative; min-height: 200px; } .htmx-indicator { display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 100; } .htmx-indicator.htmx-request { display: block; } .htmx-indicator.htmx-request ~ .table-responsive, .htmx-indicator.htmx-request ~ .paginator { opacity: 0.3; pointer-events: none; transition: opacity 0.2s ease; } .spinner { width: 40px; height: 40px; border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

Problems with current htmx implementation and boost implementation

Browser History and Back Button Issues

When using htmx with hx-boost or AJAX requests, you might encounter issues with the browser's back button showing partial content. This happens because:
  1. htmx requests only return partial HTML content
  2. The browser's history stack stores this partial content
  3. When users click the back button, the partial content is displayed instead of the full page

Preventing Cache Issues in Controllers

To disable htmx caching by browsers, you can add the following headers to your response in your controller: if ($this->request->is('htmx') || $this->request->is('boosted')) { $this->response = $this->response ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate') ->withHeader('Pragma', 'no-cache') ->withHeader('Expires', '0'); }

General Solution

Prevent caching issues with htmx requests by creating a middleware: // src/Middleware/HtmxMiddleware.php public function process(ServerRequest $request, RequestHandler $handler): Response { $response = $handler->handle($request); if ($request->is('htmx')) { return $response ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->withHeader('Pragma', 'no-cache') ->withHeader('Expires', '0'); } return $response; }

Conclusion

htmx is a powerful library that can significantly enhance the interactivity and user experience of your CakePHP applications. By using htmx attributes, you can create dynamic, responsive, and efficient web applications with minimal JavaScript code.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-htmx/tree/1.0.0 and available for testing. This article is part of the CakeDC Advent Calendar 2024 (December 2th 2024)

Our Gift To You - The CakeDC Advent Ca...

So, we are back! It’s been a while, right?    We are aware that the blog has been quiet, but boy do we have a surprise for you all.    First, let’s recap this year… a lot of releases from CakeDC like plugins and contributions to the latest CakePHP versions. We, like every other baker, have been enjoying all of the new features that Cake 5 has presented. We look forward to seeing what the core team has in store next. It is a company goal to be more involved in the CakePHP community in 2025, so you’ll be seeing some familiar faces in the community channels.     Oh, and I would be remiss if I did not also express our gratitude to our clients. We have had a great year with all of you.  We are so thankful to work with each and every one of you and it has been a pleasure baking code for your many projects.    Now… on to the good stuff.  The team has decided to write a series of blogs, in the form of an advent calendar. It is the holiday season after all. So what can you expect? I don’t want to give away too much, but I will say you’ll get to savor some tasty cake related topics like: HTMX, JWT with CakePHP, plugins, security, PHP 8.4 … and so much more.    This is the gift that keeps on giving... the whole month. So pre heat those ovens… and get ready for a new blog each day from the CakeDC team! 
CakeDC Advent Calendar 2024! 

Build a Single Page Application Using ...

The Inertia Plugin allows a CakePHP application to integrate Vue 3 components in the front end, without the need to write a specific API for data transfer. This is done  by adding a Middleware and view classes that facilitate the conversion of objects and data in JSON almost automatically, as well as the direct load in the components. The plugin is thought of as a base to extend and use your app’s specific controllers and views from. Just because  it works out of the box doesn't mean it is intended to be used exactly as is,  but this will  provide you a good kick start. See the repo here: https://github.com/CakeDC/cakephp-inertia

Requirements

  • CakePHP 4.5
  • PHP >= 8.1
  • NodeJS 18.9 (only for build Vue Components, not required on running site)

 

Step 1: Create a basic CakePHP install

  For this example I will use a basic installation using Docker and Composer.  First you must create project from cakephp/app  
$> composer create-project --prefer-dist cakephp/app:~4.5 inertia_app $> cd inertia_app $> cp config/app_local.example.php config/app_local.php
  Then write an docker-compose.yml file as:
version: '3' services:   psql13:     image: postgres:13     container_name: inertia-app-postgres13     volumes:       - ./tmp/data/inertia-postgres13__db:/var/lib/postgresql:delegated     environment:       - POSTGRES_USER=my_app       - POSTGRES_PASSWORD=secret       - POSTGRES_DB=my_app       - PGUSER=my_app       - PGDATABASE=my_app       - PGPASSWORD=secret     ports:       - '7432:5432'     cakephp:     image: webdevops/php-nginx:8.1     container_name: inertia-app-cakephp     working_dir: /application     volumes:       - ./:/application:cached       - ~/.ssh:/home/application/.ssh:ro     environment:       - WEB_DOCUMENT_ROOT=/application/webroot       - DATABASE_URL=postgres://my_app:secret@inertia-app-postgres13:5432/my_app     ports:       - "9099:80"
  Launch the container and go to http://localhost:9099/  
$> docker-compose up -d
 

Step 2: Add CakePHP Inertia plugin

  Install plugin via command line:
$> composer require cakedc/cakephp-inertia
  Once installed enable it in src/Application.php, adding at the bottom of bootstrap function:
$this->addPlugin('CakeDC/Inertia');
  or by command line:
$> bin/cake plugin load CakeDC/Inertia

 

Step 3: Create Vue App and install it

  To create Vue App type in command line:
$> bin/cake create_vue_app
  This command create in the resources directory the files that use our App, also create in root directory the files:
  • webpack.mix.js
  • package.json
  Then in root directory install with NPM:
$> npm install

 

Step 4: Create simple SPA (Single Page Application)

  Create a single page called dashboard that show values sets in a controller action We need to first add InertiaResponseTrait  
use CakeDC\Inertia\Traits\InertiaResponseTrait;   class PagesController extends AppController {    use InertiaResponseTrait;    ...  ...   }
  Create a new function that would look like this:
public function dashboard() {   //set default php layout of plugin that use vue   $this->viewBuilder()->setTheme('CakeDC/Inertia');     $page = [       'text' => 'hello world 1',       'other' => 'hello world 2',   ];   $this->set(compact('page')); }
  in config/routes.php uncomment lines to catch all routes:
$builder->connect('/{controller}', ['action' => 'index']); $builder->connect('/{controller}/{action}/*', []);
and comment line:
$builder->connect('/pages/*', 'Pages::display');
  Then create file resources/js/Components/Pages/Dashboard.vue that would look like this:
<script setup> import Layout from '../Layout' import { Head } from '@inertiajs/vue3' import {onMounted} from "vue";   defineProps({     csrfToken: String,     flash: Array,     page: Array, })     onMounted(() => {     console.log('Component Dashboard onMounted hook called') }) </script>   <template>     <Layout>         <Head title="Welcome" />         <h1>Welcome</h1>         <p>{{page.text}}</p>         <p>{{page.other}}</p>     </Layout> </template>
  On root directory execute:
$> npm run dev
  IMPORTANT: Whenever you modify the .vue templates, you must run this script. Go to http://localhost:9099/pages/dashboard to see that Dashboard Vue Component prints values assignments on Dashboard CakePHP function.
   

 

Step 5: Bake CRUD system

  For this example, we use sql file on config/sql/example/postgresql.pgsql   That creates a database with the relations     Once the database has been created, bake models and controllers as normal using:
$> bin/cake bake model Pages --theme CakeDC/Inertia $> bin/cake bake controller Pages --theme CakeDC/Inertia $> bin/cake bake model Tags --theme CakeDC/Inertia $> bin/cake bake controller Tags --theme CakeDC/Inertia $> bin/cake bake model Categories --theme CakeDC/Inertia $> bin/cake bake controller Categories --theme CakeDC/Inertia
  and bake templates using vue_template instead of template as:
$> bin/cake bake vue_template Pages --theme CakeDC/Inertia $> bin/cake bake vue_template Tags --theme CakeDC/Inertia $> bin/cake bake vue_template Categories --theme CakeDC/Inertia
  Again run:
$> npm run dev
  You can the results from this example by going to http://localhost:9099/pages/index   In the following recording you can see how to add, edit and delete a record without reloading the page at any time.

 

Step 6: Using prefix and adding a navigation menu

  Add route to prefix Admin on config/routes.php
$builder->prefix('admin', function (RouteBuilder $builder) {    $builder->fallbacks(DashedRoute::class); });
  To generate controllers and template with a prefix use --prefix option of bake command as:
$> bin/cake bake controller Pages --prefix Admin --theme CakeDC/Inertia $> bin/cake bake controller Tags --prefix Admin --theme CakeDC/Inertia $> bin/cake bake controller Categories --prefix Admin --theme CakeDC/Inertia $> bin/cake bake vue_template Pages --prefix Admin --theme CakeDC/Inertia $> bin/cake bake vue_template Tags --prefix Admin --theme CakeDC/Inertia $> bin/cake bake vue_template Categories --prefix Admin --theme CakeDC/Inertia
  You can add a horizontal menu to navigate through controllers   Edit resources/Components/Layout.vue and put inside header tag links as:
<header>    <Link as="button" href="/pages/index" class="button shadow radius right small">Pages</Link>    <Link as="button" href="/tags/index" class="button shadow radius right small">Tags</Link>    <Link as="button" href="/categories/index" class="button shadow radius right small">Categories</Link> </header>
  Again run:
$> npm run dev
  You can see the results from this  example by going to http://localhost:9099/admin/pages/index   In the following recording you can see how to add, edit and delete a record without reloading the page at any time and navigate through pages, tags and categories.

  Hopefully this example will make your experience easier! Let us know: [email protected].

When and why should you upgrade to Cak...

CakePHP 5.0.0 was released on September 10th. The current version as of today is 5.0.3 (released Nov 28th and compatible with PHP 8.3 https://github.com/cakephp/cakephp/releases/tag/5.0.3). You might be asking yourself some questions related to the upgrade… here's what we've been recommending to our clients to do since version 5 was released. Leaving aside the obvious reasons for an upgrade, today we're going to categorize the decision from 2 different points of view: Your current CakePHP version, and your role in the project.

When should you upgrade? 

  We are going to use current CakePHP version as the main criteria: * If you are in CakePHP <= 2   * We strongly recommend an upgrade as soon as possible. If you are unable to upgrade, try to keep your PHP version and all the underlying dependencies as fresh as you can and isolate the application as much as possible. If your application is internal, consider using a VPN blocking all outside traffic. If your site is open to the public, consider using an isolated environment, hardened. Adding a web application firewall and a strict set of rules could also help to mitigate potential security issues. Even if CakePHP is very secure, the older versions of CakePHP, like  1 and 2  have a very old code base , and other vendors/ libraries could be a serious security risk for your project at this point.   * If you are in CakePHP 3.x   * The effort to upgrade at least to CakePHP 4.x should not be a blocker. We would recommend upgrading at least to the latest CakePHP 4.5.x. You can actually "ignore" the deprecations for now, you don't need to plan for upgrading your authentication/authorization layers just yet, focus on getting your project stable and up to CakePHP 4.5.x in the first round.   * If you are in CakePHP 4.x   * Upgrading to CakePHP 5.x is not an immediate priority for you.   * I would say, 2024 is a good time to start planning for an upgrade. Feature and bugfix releases for 4.x will continue until September 2025. Security fixes will continue for 4.x until September 2026. You have plenty of time to consider an upgrade, and take advantage of newer (and faster!) PHP versions.  

Why should you upgrade? 

  We are going to use your role in the project to provide some good reasons: * If you are a developer   * More strict types, meaning better IDE support and more errors catched at development time.   * New features in CakePHP 5.x will make your code more readable, like Typed finder parameters https://book.cakephp.org/5/en/appendices/5-0-migration-guide.html#typed-finder-parameters      * Quality of life features, reducing development time like https://book.cakephp.org/5/en/appendices/5-0-migration-guide.html#plugin-installer   * Compatibility with PHP 8.3 for extra performance & support   * If you are a manager   * Ensure your development team is forced to drop old auth code and embrace the new authentication/authorization layer https://book.cakephp.org/5/en/appendices/5-0-migration-guide.html#auth   * The new authentication layer will allow you to easily integrate features like single sign on, two factor authentication or hardware keys (like Yubikeys), as there are plugins available handling all these features.   * Get an extended support window. CakePHP is one of the longest maintained frameworks out there, upgrading to CakePHP 5 will keep your core maintained past 2026.   * Upgrade to PHP 8.3 and force legacy vendors to be up to date with the new version, this will also push your team to get familiar with the new PHP core features.   * If you are an investor, not directly related with the project day-to-day operations   * Secure your inversion for a longer period.   * Reduce your exposure to security issues.   * Send a strong message to your partners, keeping your product updated with the latest technology trends.   * Send a strong message to your team, investing in the upgrade of your application will let them know the project is aiming for a long term future.   In conclusion, upgrading to CakePHP 5 is a good move for 2024 whether you're a developer, manager, or investor. The version 5 is stable and ready to go. Staying current becomes not just a best practice but a strategic advantage.   If you are in doubt, feel free to contact us. We'll review your case (for free) and provide an actionable recommendation based on your current situation in the next business day.  

We Bake with CakePHP