Skip to content

marko/rate-limiting

Cache-backed rate limiter with route middleware --- throttle requests by IP with configurable limits and automatic Retry-After headers. Rate limiting uses the cache layer to track request attempts per key (typically client IP). When limits are exceeded, the middleware returns a 429 response with a Retry-After header. Responses include X-RateLimit-Limit and X-RateLimit-Remaining headers so clients can self-throttle.

Terminal window
composer require marko/rate-limiting

Requires marko/cache for the storage backend and marko/routing for the middleware.

Apply RateLimitMiddleware to routes that need throttling:

use Marko\Routing\Attributes\Get;
use Marko\Routing\Attributes\Middleware;
use Marko\Routing\Http\Response;
use Marko\RateLimiting\Middleware\RateLimitMiddleware;
class ApiController
{
#[Get('/api/data')]
#[Middleware(RateLimitMiddleware::class)]
public function index(): Response
{
return new Response('OK');
}
}

The middleware defaults to 60 requests per 60 seconds. It resolves the client IP from the X-Forwarded-For header, falling back to Remote-Addr. Configure limits via constructor injection:

use Marko\RateLimiting\Contracts\RateLimiterInterface;
use Marko\RateLimiting\Middleware\RateLimitMiddleware;
$middleware = new RateLimitMiddleware(
limiter: $rateLimiter,
maxAttempts: 100,
decaySeconds: 120,
);

For custom throttling logic, inject RateLimiterInterface:

use Marko\RateLimiting\Contracts\RateLimiterInterface;
public function __construct(
private readonly RateLimiterInterface $rateLimiter,
) {}
public function processLogin(
string $email,
): void {
$result = $this->rateLimiter->attempt(
"login:$email",
5,
300,
);
if (!$result->allowed()) {
// Too many attempts, retry after $result->retryAfter() seconds
}
}

Check if a key is rate-limited without consuming an attempt:

if ($this->rateLimiter->tooManyAttempts('api:' . $ip, 60)) {
// Already rate-limited
}

Reset the counter for a key --- for example, after a successful login:

$this->rateLimiter->clear("login:$email");

Replace RateLimiter via Preferences to change the keying strategy or storage:

use Marko\Core\Attributes\Preference;
use Marko\RateLimiting\RateLimiter;
use Marko\RateLimiting\RateLimitResult;
#[Preference(replaces: RateLimiter::class)]
class SlidingWindowRateLimiter extends RateLimiter
{
public function attempt(
string $key,
int $maxAttempts,
int $decaySeconds,
): RateLimitResult {
// Custom sliding window logic
}
}
public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateLimitResult;
public function tooManyAttempts(string $key, int $maxAttempts): bool;
public function clear(string $key): void;
public function allowed(): bool;
public function remaining(): int;
public function retryAfter(): ?int;
public function handle(Request $request, callable $next): Response;

Constructor parameters:

ParameterTypeDefaultDescription
$limiterRateLimiterInterface---The rate limiter instance
$maxAttemptsint60Maximum requests allowed in the window
$decaySecondsint60Time window in seconds before attempts reset