CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

Scaling Your CakePHP App: From Monolith to Distributed Powerhouse

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

Your CakePHP application is a success story – users love it, and traffic is booming! But what happens when that single, mighty server starts to groan under the load? That's when you need to think about scaling.

In this article, we'll dive into the world of application scaling, focusing on how to transform your regular CakePHP project into a horizontally scalable powerhouse. We'll cover why, when, and how to make the necessary changes to your application and infrastructure.

Vertical vs. Horizontal Scaling: What's the Difference?

Before we jump into the "how," let's clarify the two fundamental ways to scale any application:

  1. Vertical Scaling (Scaling Up):

    • Concept: Adding more resources (CPU, RAM, faster storage) to your existing server. Think of it as upgrading your car's engine.
    • Pros: Simpler to implement initially, no major architectural changes needed.
    • Cons: Hits a hard limit (you can only get so much RAM or CPU on a single machine), higher cost for diminishing returns, and still a single point of failure.
  2. Horizontal Scaling (Scaling Out):

    • Concept: Adding more servers to distribute the load. This is like adding more cars to your fleet.
    • Pros: Virtually limitless scalability (add as many servers as needed), high availability (if one server fails, others take over), better cost-efficiency at large scales.
    • Cons: Requires significant architectural changes, more complex to set up and manage.

When Do You Need to Scale Horizontally?

While vertical scaling can buy you time, here are the key indicators that it's time to invest in horizontal scaling for your CakePHP application:

  • Hitting Performance Ceilings: Your server's CPU or RAM regularly maxes out, even after vertical upgrades.
  • Single Point of Failure Anxiety: You dread a server crash because it means your entire application goes down.
  • Inconsistent Performance: Your application's response times are erratic during peak hours.
  • Anticipated Growth: You're expecting a marketing campaign or feature launch that will significantly increase traffic.
  • High Availability Requirements: Your business demands minimal downtime, making a single server unacceptable.

From Regular to Resilient: Necessary Changes for CakePHP

The core principle for horizontal scaling is that your application servers must become "stateless." This means any server should be able to handle any user's request at any time, without relying on local data. If a user lands on App Server A for one request and App Server B for the next, both servers must act identically.

Here's what needs to change in a typical CakePHP, MySQL, cache, and logs setup:

1. Sessions: The Single Most Critical Change

  • Problem: By default, CakePHP stores session files locally (tmp/sessions). If a user's request is handled by a different server, their session is lost.
  • Solution: Centralize session storage using a distributed cache system like Redis or Memcached.
  • CakePHP Action: Modify config/app.php to tell CakePHP to use a cache handler for sessions, pointing to your centralized Redis instance, Consult the official RedisEngine Options documentation.
// config/app.php
'Session' => [
    'defaults' => 'cache', // Use 'cache' instead of 'php' (file-based)
    'handler' => [
        'config' => 'session_cache' // Name of the cache config to use
    ],
],
// ...
'Cache' => [
    'session_cache' => [
        'className' => 'Redis',
        'host' => 'your_redis_server_ip_or_hostname',
        'port' => 6379,
        'duration' => '+1 days',
        'prefix' => 'cake_session_',
    ],
    // ... (ensure 'default' and '_cake_core_' also use Redis)
]

2. Application Cache

  • Problem: Local cache (tmp/cache) means each server builds its own cache, leading to inefficiency and potential inconsistencies.
  • Solution: Just like sessions, point all your CakePHP cache configurations (default, _cake_core_, etc.) to your centralized Redis or Memcached server.

3. User Uploaded Files

  • Problem: If a user uploads a profile picture to App Server A's local storage (webroot/img/uploads/), App Server B won't find it.
  • Solution: Use a shared, centralized file storage system.
  • CakePHP Action:
    • Recommended: Implement Object Storage (e.g., AWS S3, DigitalOcean Spaces). This involves changing your file upload logic to send files directly to S3 via an SDK or plugin, and serving them from there.
    • Alternative: Mount a Network File System (NFS) share (e.g., AWS EFS) at your upload directory (webroot/img/uploads) across all app servers. This requires no code changes but can introduce performance bottlenecks and complexity at scale.

4. Application Logs

  • Problem: Log files (logs/error.log) are scattered across multiple servers, making debugging a nightmare.
  • Solution: Centralize your logging.
  • CakePHP Action: Configure CakePHP's Log engine to use syslog (a standard logging protocol).To configure this, see the Logging to Syslog section in the documentation. Then, deploy a log collector (like Fluentd, Logstash) on each app server to forward these logs to a centralized logging system (e.g., Elasticsearch/Kibana, Papertrail, DataDog).

The Database Bottleneck: Database Replication (MySQL & PostgreSQL)

At this stage, your CakePHP application is fully stateless. However, your single database server now becomes the bottleneck. Whether you are using MySQL or PostgreSQL, the solution is Replication.

Understanding Replication

  • Primary (Writer): Handles all write operations (INSERT, UPDATE, DELETE).
  • Replica (Reader): Handles read operations (SELECT).
  • For MySQL: The Primary copies data changes to Replicas using the Binary Log (Binlog).
  • For PostgreSQL: It uses Streaming Replication via WAL (Write-Ahead Logging) files to keep replicas in sync.

CakePHP Configuration Note: CakePHP makes switching easy. In your config/app.php, you simply define your roles. The driver (Cake\Database\Driver\Mysql or Cake\Database\Driver\Postgres) handles the specific connection protocol underneath. You don't need to change your query logic.

The Challenge: "Replica Lag"

Because replication is typically asynchronous, there's always a delay (lag) between a write on the Primary and when it becomes available on the Replicas.

The Immediate Consistency Problem:

  1. User updates their profile (write to Primary).
  2. App immediately redirects to the profile page (read from Replica).
  3. Due to lag, the Replica might not yet have the updated data. The user sees old information or a "not found" error.

Mitigating this lag to guarantee a user sees their changes immediately often requires the application to intelligently direct reads to the Primary right after a write, before reverting to the Replicas.

Solutions for the Database Bottleneck

While your initial focus should be separating reads and writes in CakePHP, the Primary server will eventually hit its limits for write volume. Future solutions for database scaling depend heavily on the type of database server you use (Standard MySQL, Managed Cloud DB, MySQL Cluster, etc.).

Here are common advanced solutions for when the Primary MySQL server becomes the final performance constraint:

  • Database Proxies (Connection Pooling):
    • For MySQL: Tools like ProxySQL route queries automatically and split reads/writes.
    • For PostgreSQL: PgBouncer is the industry standard for connection pooling to prevent overhead, often paired with Pgpool-II for load balancing and read/write splitting.
  • High Availability Clusters:
    • MySQL: Uses Group Replication or Galera Cluster.
    • PostgreSQL: Tools like Patroni are widely used to manage high availability and automatic failover.

Local Testing: Scaling Your CakePHP App with Docker

Now that we understand the theory, let's see it in action with your actual CakePHP application. We will use Docker Compose to spin up a cluster of 3 application nodes, a Load Balancer, Redis, and MySQL.

To make this easy, we won't even build a custom Docker image. We will use the popular webdevops/php-nginx image, which comes pre-configured for PHP applications, if you already have a Docker container in your project, you can use that.

You only need to add two files to the root of your CakePHP project.

  1. nginx.conf (The Load Balancer Config) This file configures an external Nginx container to distribute traffic among your 3 CakePHP application nodes.
upstream backend_hosts {
    # 'app' matches the service name in docker-compose
    # Docker resolves this to the IPs of all 3 replicas
    server app:80;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend_hosts;

        # Pass necessary headers so CakePHP knows it's behind a proxy
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
  1. docker-compose.yml (The Cluster Infrastructure) Here we define the architecture. We mount your current local code into the containers so you don't need to rebuild anything.
version: '3.8'

services:
  # Your CakePHP Application Nodes
  app:
    image: webdevops/php-nginx:8.2 # Pre-built image with PHP 8.2 & Nginx
    # We do NOT map ports here (e.g., "80:80") to avoid conflicts between replicas
    deploy:
      replicas: 3 # <--- Runs 3 instances of your CakePHP app
    volumes:
      - ./:/app # Mount your current project code into the container
    environment:
      # 1. Tell the image where CakePHP's webroot is
      WEB_DOCUMENT_ROOT: /app/webroot

      # 2. Inject configuration for app.php
      DEBUG: "true"
      SECURITY_SALT: "ensure-this-is-long-and-identical-across-nodes"

      # 3. Database Config (Connecting to the 'db' service)
      MYSQL_HOST: db
      MYSQL_USERNAME: my_user
      MYSQL_PASSWORD: my_password
      MYSQL_DATABASE: my_cake_app

      # 4. Redis Config (Session & Cache)
      REDIS_HOST: redis
    depends_on:
      - db
      - redis
    networks:
      - cake_cluster

  # The Main Load Balancer (Nginx)
  lb:
    image: nginx:alpine
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "8080:80" # Access your app at localhost:8080
    depends_on:
      - app
    networks:
      - cake_cluster

  # Shared Services
  redis:
    image: redis:alpine
    networks:
      - cake_cluster

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: my_cake_app
      MYSQL_USER: my_user
      MYSQL_PASSWORD: my_password
    networks:
      - cake_cluster

networks:
  cake_cluster:

How to Run the Test

  1. Configure app.php: Ensure your config/app.php is reading the environment variables (e.g., getenv('MYSQL_HOST') and getenv('REDIS_HOST')) as discussed earlier.

  2. Launch: Run the cluster:

docker compose up -d
  1. Migrate: Run your database migrations on one of the containers (since they all share the same DB, you only need to do this once):
docker compose exec app-1 bin/cake migrations migrate

_(Note: Docker might name the container slightly differently, e.g., project_app_1. Use docker ps to check the name)._

  1. Test: Open http://localhost:8080.

You are now interacting with a load-balanced CakePHP cluster. Nginx (the Load Balancer) is receiving your requests on port 8080 and distributing them to one of the 3 app containers. Because you are using Redis for sessions, you can browse seamlessly, even though different servers are handling your requests!

Moving to Production

Simulating this locally with Docker Compose is great for understanding the concepts, but in the real world, we rarely manage scaling by manually editing a YAML file and restarting containers.

In a professional environment, more advanced tools take over to manage what we just simulated:

  1. Container Orchestrators (Kubernetes / K8s): The industry standard. Instead of docker-compose, you use Kubernetes. It monitors the health of your containers (Pods). If a CakePHP node stops responding due to memory leaks, Kubernetes kills it and creates a fresh one automatically to ensure you always have your desired number of replicas.
  2. Cloud Load Balancers (AWS ALB / Google Cloud Load Balancing): Instead of configuring your own Nginx container as we did above, you use managed services from your cloud provider (like AWS Application Load Balancer). These are powerful hardware/software solutions that handle traffic distribution, SSL termination, and security before the request even hits your servers.
  3. Auto-Scaling Groups: This is the ultimate goal. You configure rules like: "If average CPU usage exceeds 70%, launch 2 new CakePHP servers. If it drops below 30%, destroy them." This allows your infrastructure to "breathe"—expanding during Black Friday traffic and shrinking (saving money) at night.

Conclusion

Scaling a CakePHP application horizontally is a journey, not a destination. It means shifting from managing a single server to orchestrating a distributed system. By making your application stateless with Redis and leveraging database replication (for either MySQL or PostgreSQL), you empower your CakePHP app to handle massive traffic, offer high availability, and grow far beyond the limits of a single machine.

Are you ready to build a truly robust and scalable CakePHP powerhouse?

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

Latest articles

Goodbye to 2025!

Well bakers… another advent calendar is coming to an end. I hope you enjoyed all of the topics covered each day. We are also closing the year with so much gratitude.    2025 was the 20th year of CakePHP, can you believe it? We had an amazing year with our team, the community and the CakePHP core. It was great connecting with those who attended CakeFest in Madrid, and we hope to have the opportunity to see more of you in 2026.    I cannot let the year end without getting a little sentimental. There is no better way to say it… THANK YOU. Thank you to the team who worked so hard, the core team that keeps pumping out releases, and most of all … thank you to our clients that trust us with their projects. CakeDC is successful because of the strong relationships we build with our network, and we hope to continue working with all of you for many years.    There are a lot of great things still to come in year 21! Could 2026 will be bringing us CakePHP 6?! Considering 21 is the legal drinking age in the US, maybe CakePHP 6 should be beer cake? Delicious. Stay tuned to find out.    Before I go, I am leaving you with something special. A note from Larry!   As we close out this year, I just want to say thank you from the bottom of my heart. Twenty years ago, CakePHP started as a simple idea shared by a few of us who wanted to make building on the web easier and more enjoyable. Seeing how far it has come, and more importantly, seeing how many lives and careers it has impacted, is something I never take for granted. I am deeply grateful for our team, the core contributors, the community, and our clients who continue to believe in what we do. You are the reason CakePHP and CakeDC are still here, still growing, and still relevant after two decades. Here is to what we have built together, and to what is still ahead. Thank you for being part of this journey. Larry

Pagination of multiple queries in CakePHP

Pagination of multiple queries in CakePHP

A less typical use case for pagination in an appication is the need to paginate multiples queries. In CakePHP you can achieve this with pagination scopes.

Users list

Lest use as an example a simple users list. // src/Controller/UsersController.php class UsersController extends AppController { protected array $paginate = [ 'limit' => 25, ]; public function index() { // Default model pagination $this->set('users', $this->paginate($this->Users)); } } // templates/Users/index.php <h2><?= __('Users list') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($users as $user): ?> <tr> <td><?= h($user->name) ?></td> <td><?= h($user->email) ?></td> <td><?= $user->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?>

Pagination of multiple queries

Now, we want to display two paginated tables, one with the active users and the other with the inactive ones. // src/Controller/UsersController.php class UsersController extends AppController { protected array $paginate = [ 'Users' => [ 'scope' => 'active_users', 'limit' => 25, ], 'InactiveUsers' => [ 'scope' => 'inactive_users', 'limit' => 10, ], ]; public function index() { $activeUsers = $this->paginate( $this->Users->find()->where(['active' => true]), [scope: 'active_users'] ); // Load an additional table object with the custom alias set in the paginate property $inactiveUsersTable = $this->fetchTable('InactiveUsers', [ 'className' => \App\Model\Table\UsersTable::class, 'table' => 'users', 'entityClass' => 'App\Model\Entity\User', ]); $inactiveUsers = $this->paginate( $inactiveUsersTable->find()->where(['active' => false]), [scope: 'inactive_users'] ); $this->set(compact('users', 'inactiveUsers')); } } // templates/Users/index.php <?php // call `setPaginated` first with the results to be displayed next, so the paginator use the correct scope for the links $this->Paginator->setPaginated($users); ?> <h2><?= __('Active Users') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($users as $user): ?> <tr> <td><?= h($user->name) ?></td> <td><?= h($user->email) ?></td> <td><?= $user->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?> <?php // call `setPaginated` first with the results to be displayed next, so the paginator use the correct scope for the links $this->Paginator->setPaginated($inactiveUsers); ?> <h2><?= __('Inactive Users') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($inactiveUsers as $inactiveUser): ?> <tr> <td><?= h($inactiveUser->name) ?></td> <td><?= h($inactiveUser->email) ?></td> <td><?= $inactiveUser->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?> And with this you have two paginated tables in the same request.

Clean DI in CakePHP 5.3: Say Goodbye to fetchTable()

This article is part of the CakeDC Advent Calendar 2025 (December 23rd, 2025)

Introduction: The Death of the "Hidden" Dependency

For years, accessing data in CakePHP meant "grabbing" it from the global state. Whether using TableRegistry::getTableLocator()->get() or the LocatorAwareTrait’s $this->fetchTable(), your classes reached out to a locator to find what they needed. While convenient, this created hidden dependencies. A class constructor might look empty, despite the class being secretly reliant on multiple database tables. This made unit testing cumbersome, forcing you to stub the global TableLocator just to inject a mock. CakePHP 5.3 changes the game with Inversion of Control. With the framework currently in its Release Candidate (RC) stage and a stable release expected soon, now is the perfect time to explore these architectural improvements. By using the new TableContainer as a delegate for your PSR-11 container, tables can now be automatically injected directly into your constructors. This shift to explicit dependencies makes your code cleaner, fully type-hinted, and ready for modern testing standards. The Old Way (Hidden Dependency): public function execute() { $users = $this->fetchTable('Users'); // Where did this come from? } The 5.3 Way (Explicit Dependency): public function __construct(protected UsersTable $users) {} public function execute() { $this->users->find(); // Explicit and testable. }

Enabling the Delegate

Open src/Application.php and update the services() method by delegating table resolution to the TableContainer. // src/Application.php use Cake\ORM\TableContainer; public function services(ContainerInterface $container): void { // Register the TableContainer as a delegate $container->delegate(new TableContainer()); }

How it works under the hood

When you type-hint a class ending in Table (e.g., UsersTable), the main PSR-11 container doesn't initially know how to instantiate it. Because you've registered a delegate, it passes the request to the TableContainer, which then:
  1. Validates: It verifies the class name and ensures it is a subclass of \Cake\ORM\Table.
  2. Locates: It uses the TableLocator to fetch the correct instance (handling all the usual CakePHP ORM configuration behind the scenes).
  3. Resolves: It returns the fully configured Table object back to the main container to be injected.
Note: The naming convention is strict. The TableContainer specifically looks for the Table suffix. If you have a custom class that extends the base Table class but is named UsersRepository, the delegate will skip it, and the container will fail to resolve the dependency.

Practical Example: Cleaner Services

Now, your domain services no longer need to know about the LocatorAwareTrait. They simply ask for what they need. namespace App\Service; use App\Model\Table\UsersTable; class UserManagerService { // No more TableRegistry::get() or $this->fetchTable() public function __construct( protected UsersTable $users ) {} public function activateUser(int $id): void { $user = $this->users->get($id); // ... logic } } Next, open src/Application.php and update the services() method by delegating table resolution to the TableContainer. // src/Application.php use App\Model\Table\UsersTable; use App\Service\UserManagerService; use Cake\ORM\TableContainer; public function services(ContainerInterface $container): void { // Register the TableContainer as a delegate $container->delegate(new TableContainer()); // Register your service with the table as constructor argument $container ->add(UserManagerService::class) ->addArgument(UsersTable::class); }

Why this is a game changer for Testing

Because the table is injected via the constructor, you can now swap it for a mock effortlessly in your test suite without touching the global state of the application. $mockUsers = $this->createMock(UsersTable::class); $service = new UserManagerService($mockUsers); // Pure injection!

Conclusion: Small Change, Big Impact

At first glance, adding a single line to your Application::services() method might seem like a minor update. However, TableContainer represents a significant shift in how we approach CakePHP architecture. By delegating table resolution to the container, we gain:
  • True Type-Safety: Your IDE and static analysis tools now recognize the exact Table class being used. This is a massive win for PHPStan users—no more "Call to an undefined method" errors or messy @var docblock workarounds just to prove to your CI that a method exists.
  • Zero-Effort Mocking: Testing a service no longer requires manipulating the global TableRegistry state. Simply pass a mock object into the constructor and move on.
  • Standardization: Your CakePHP code now aligns with modern PHP practices found in any PSR-compliant ecosystem, making your application more maintainable and easier for new developers to understand.
If you plan to upgrade to CakePHP 5.3 upon its release, this is one of the easiest wins for your codebase. It’s time to stop fetching your tables and start receiving them. This article is part of the CakeDC Advent Calendar 2025 (December 23rd, 2025)

We Bake with CakePHP