Skip to main content
CakeDC Blog

How to Limit Too Many Requests in CakePHP with Middleware

Mastering Middlewares in CakePHP: Protecting Your App with a Custom Rate Limiter

Before talking about middleware, it is important to clarify what we mean by rate limiting. Rate limiting controls how many requests a user, IP address, or client can send to an application within a specific period of time. For example, an application may allow 100 requests per hour from the same IP address, and return a 429 Too Many Requests response once that limit is exceeded.

This is useful for protecting login forms, APIs, search endpoints, booking flows, or any area that could be abused by sending too many requests too quickly. In CakePHP, the key question is not only how to limit those requests, but where that logic should live. Since rate limiting should happen before routing, controllers, or application logic are executed, middleware is the right place to handle it.

In the development of enterprise web applications, the architecture and execution flow of HTTP requests dictate the scalability, maintainability, and security of our software. When working with robust frameworks like CakePHP, understanding with precision which layer each piece of logic should reside in, is the difference between an agile infrastructure and an application that is prone to severe bottlenecks.

1. Introduction and the Real-World Experience Approach

To visualize the flow of a request within CakePHP, we must look to the pattern known as Onion Architecture. The application consists of multiple concentric layers wrapping around the central core: your controllers and models.

Onion Architecture

When an HTTP request hits the server, it does not immediately interact with routing or controllers. Instead, it sequentially traverses each of these layers from the outside in. Once the controller processes the request and generates a response, the response travels in reverse, exiting through those same layers.

Middleware vs. Controller Component

One of the most common mistakes made by developers in their early or mid-stages is saturating the controller layer (AppController), or utilizing Components to manage global infrastructure tasks such as CORS security, rudimentary authentication, or rate limiting.

This approach suffers from a critical architectural flaw: by the time the execution flow reaches a controller component, CakePHP has already spent significant CPU cycles on:

  • Parsing the URL and executing the routing engine.
  • Initializing the corresponding controller.
  • Instanciar los subcomponentes necesarios.

If the logic determines that the request must be rejected (for example, for exceeding a request limit), valuable server resources will have been wasted unnecessarily. The golden rule of backend development is categorical:

If the business logic affects or evaluates the HTTP request before knowing precisely which controller and action will handle it, that logic strictly belongs in the Middleware layer.

2. The PSR-7 Standard and Immutability

CakePHP natively implements the industry standard PSR-7 (HTTP Message Interfaces) and the PSR-15 standard for server-side components. This means that the framework exposes and manipulates the request through Psr\Http\Message\ServerRequestInterface and the response via Psr\Http\Message\ResponseInterface.

The fundamental pillar of PSR-7 objects is immutability. These objects cannot be modified internally once created; any operation intended to alter their state (such as adding an HTTP header, modifying the body, or attaching an attribute) does not change the current instance, but instead returns a new cloned copy of the object with the modification applied.

The Common Trap for Junior Developers

A recurring erroneous pattern when writing custom middlewares consists of modifying the request directly and assuming that the changes will persist in the subsequent flow:

// INCORRECT CODE: The mutation is lost
$request->withAttribute('user_id', $userId);
return $handler->handle($request);

Due to immutability, the withAttribute() method generates a clone. If that new instance is not captured, the original $request object remains unchanged. The correct approach always requires reassigning or directly passing the return value of the method:

// CORRECT CODE: The modified copy is passed along
$request = $request->withAttribute('user_id', $userId);
return $handler->handle($request);

3. Practical Case: Implementing a Real-World RateLimitMiddleware

The abuse of critical endpoints, such as API authentication routes or login forms, represents a latent risk of Denial of Service (DoS) or brute-force attacks. To mitigate this efficiently at the application level, a Rate Limiter is implemented.

For persisting request counters per IP address, using a relational database (such as MySQL or PostgreSQL) is strictly ruled out. Writing to and querying a hard drive or executing complex ACID transactions for every HTTP hit would create the exact bottleneck we are trying to prevent, crashing the server under heavy traffic. Instead, it is mandatory to use the Cache layer, ideally backed by high-performance, low-latency in-memory systems like Redis or Memcached.

The implementation of the middleware designed for this task is detailed below:

<?php

declare(strict_types=1);

namespace App\Middleware;

use App\Cache\Cache;
use App\Http\Exception\TooManyRequestsException;
use Cake\Core\Configure;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * RateLimit middleware
 */
class RateLimitMiddleware implements MiddlewareInterface
{

    /**
     * Max limit of requests per period.
     *
     * @var integer
     */
    protected int $limit;

    /**
     * Time period in seconds for the rate limit.
     *
     * @var integer
     */
    protected int $period;

    /**
     * @param array $options
     */
    public function __construct(array $options = [])
    {
        $config = Configure::read('RateLimit');
        $this->limit = $options['limit'] ?? $config['limit'] ?? 1000;
        $this->period = $options['period'] ?? $config['period'] ?? 3600; // Default to 1 hour
    }

    /**
     * Process method.
     *
     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
     * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
     * @return \Psr\Http\Message\ResponseInterface A response.
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        /** @var \Cake\Http\ServerRequest $request */
        $request->trustProxy = true;
        $clientIp = $request->clientIp();
        $cacheKey = "rate_limit_{$clientIp}";

        $config = Configure::read('RateLimit');
        $rateData = Cache::read($cacheKey, $config['cache'] ?? 'default');

        if (empty($rateData)) {
            $rateData = [
                'count' => 1,
                'timestamp' => time(),
            ];
        } else {
            if ((time() - $rateData['timestamp']) > $this->period) {
                $rateData['count'] = 1;
                $rateData['timestamp'] = time();
            } else {
                $rateData['count']++;
            }
        }

        Cache::write($cacheKey, $rateData, $config['cache'] ?? 'default');

        if ($rateData['count'] > $this->limit) {
            throw new TooManyRequestsException();
        }

        return $handler->handle($request);
    }
}

To support the default values mapped in the constructor, we define the required configuration structure in the general application configuration file, commonly located in config/app.php or config/app_local.php:

    'RateLimit' => [
        'limit' => 1000,
        'period' => 3600,
        'cache' => 'default',
    ],

Technical Code Analysis

  • Reliable Identification Behind Proxies ($request->trustProxy = true): In modern production architectures, requests often transit through load balancers, reverse proxies, or content delivery networks (Cloudflare). By enabling trustProxy, we force CakePHP to inspect standard HTTP headers (such as X-Forwarded-For) to extract the real external client IP instead of registering the proxy's internal IP.
  • Fixed Window Time Algorithm: The logical flow retrieves the structure associated with the client's IP from the cache. It subtracts the saved start timestamp from the current time. If this difference is greater than the defined period, the window has expired, so it resets the request counter to 1 and updates the timestamp. Otherwise, it simply increments the request counter. Finally, if the counter exceeds the pre-established limit, the request lifecycle is immediately interrupted by throwing a controlled exception.

4. Application Registration: Order Matters

To integrate the custom middleware into the global flow of CakePHP, we must formally register it within the core initialization class. This is done in the src/Application.php file (Docs), extending the execution queue provided in the middleware method:

<?php

declare(strict_types=1);

namespace App;

use App\Middleware\RateLimitMiddleware;
use Cake\Http\BaseApplication;
use Cake\Http\MiddlewareQueue;

class Application extends BaseApplication
{
    /**
     * Setup the middleware queue your application will use.
     *
     * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to setup.
     * @return \Cake\Http\MiddlewareQueue The updated middleware queue.
     */
    public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
    {
        $middlewareQueue
            // The rate limiter is positioned at the beginning of the queue
            ->add(new RateLimitMiddleware())

            // Native and subsequent CakePHP middlewares
            ->add(new \Cake\Error\Middleware\ErrorHandlerMiddleware())
            ->add(new \Cake\Http\Middleware\AssetMiddleware())
            ->add(new \Cake\Routing\Middleware\RoutingMiddleware());

        return $middlewareQueue;
    }
}

Positional Injection Principle

The processing queue represented by MiddlewareQueue acts as a FIFO (First In, First Out) data structure. The physical order in which middlewares are chained (->add()) dictates their exact order of execution.

If we position our RateLimitMiddleware at the end of the queue, any malicious client will force the application to execute expensive operations such as route parsing or session initialization before being blocked. By injecting it at the very top of the queue, we guarantee an efficient firewall mechanism that intercepts and discards abusive traffic in the earliest lines of HTTP cycle execution.

5. Verification Guide: How to Verify It Works

5.1. Adjusting Local Parameters

To verify correct operation without forcing the manual generation of thousands of requests, we will temporarily modify the configuration environment in our local configuration file:

    'RateLimit' => [
        'limit' => 3,
        'period' => 10,
        'cache' => 'default',
    ],

This restricts access to a strict maximum of 3 requests every 10 seconds per IP address.

5.2. Console Automation with cURL

To simulate a continuous burst of traffic to our application, we run a classic loop in our Bash terminal. This command dispatches 5 immediate sequential requests, printing the HTTP status code returned by the server:

for i in {1..5}; do curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8765/api/users; done

The expected output in the console should validate the dynamic blocking starting from the fourth attempt:

200
200
200
429
429

6. The Official CakePHP RateLimit Middleware

Writing our own code to understand the HTTP flow under PSR-15 is an indispensable exercise for any backend engineer. However, in real production environments, it is always a good practice to delegate the maintenance of critical infrastructure to the framework itself when it offers a first-class solution.

Starting from CakePHP 5.3, the ecosystem natively introduces its own official request rate limiting component. If you are building or migrating your architecture to modern versions, it is highly recommended to replace manual implementations with the official solution.

Why is it better to use the native solution?

  • More advanced algorithms: Instead of a rigid fixed time window, it implements variants of the Token Bucket algorithm, which handles legitimate traffic peaks (bursts) without immediately blocking the user, penalizing only sustained abuse over time.
  • HTTP standards support: Natively adds industry-standard headers to the HTTP response (X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset), allowing API clients to gracefully manage their consumption rate.
  • Reduced technical debt: Fewer lines of proprietary code mean fewer tests to maintain and direct integration with future optimizations of the framework's core.

To learn in depth how to configure cache-based storage strategies and register this official component, consult the documentation directly in the Official CakePHP Manual - Rate Limit Middleware.

7. Conclusion

Delegating the tasks of infrastructure control and peripheral security to the global Middleware layer provides our CakePHP applications with a clean, decoupled, and high-performance design. By intercepting and filtering anomalous HTTP requests at the outer boundaries of the system—whether through custom code or by leveraging the official framework tools—we guarantee that controllers and models process only legitimate data, respecting the principle of single responsibility at each layer of our architecture.

Back to all articles
We Bake with CakePHP