Skip to content
Merged
9 changes: 9 additions & 0 deletions app/Config/Routing.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ class Routing extends BaseRouting
*/
public bool $autoRoute = false;

/**
* If TRUE, the system will look for attributes on controller
* class and methods that can run before and after the
* controller/method.
*
* If FALSE, will ignore any attributes.
*/
public bool $useControllerAttributes = true;

/**
* For Defined Routes.
* If TRUE, will enable the use of the 'prioritize' option
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@
"phpstan:baseline": [
"bash -c \"rm -rf utils/phpstan-baseline/*.neon\"",
"bash -c \"touch utils/phpstan-baseline/loader.neon\"",
"phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon",
"phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon --memory-limit=512M",
"split-phpstan-baseline utils/phpstan-baseline/loader.neon"
],
"phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi",
"phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi --memory-limit=512M",
"sa": "@analyze",
"style": "@cs-fix",
"test": "phpunit"
Expand Down
35 changes: 34 additions & 1 deletion system/CodeIgniter.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use CodeIgniter\HTTP\Method;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponsableInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\HTTP\URI;
Expand Down Expand Up @@ -460,8 +461,12 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache

$returned = $this->startController();

// If startController returned a Response (from an attribute or Closure), use it
if ($returned instanceof ResponseInterface) {
$this->gatherOutput($cacheConfig, $returned);
}
// Closure controller has run in startController().
if (! is_callable($this->controller)) {
elseif (! is_callable($this->controller)) {
$controller = $this->createController();

if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) {
Expand Down Expand Up @@ -497,6 +502,13 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
}
}

// Execute controller attributes' after() methods AFTER framework filters
if ((config('Routing')->useControllerAttributes ?? true) === true) {
$this->benchmark->start('route_attributes_after');
$this->response = $this->router->executeAfterAttributes($this->request, $this->response);
$this->benchmark->stop('route_attributes_after');
}

// Skip unnecessary processing for special Responses.
if (
! $this->response instanceof DownloadResponse
Expand Down Expand Up @@ -855,6 +867,27 @@ protected function startController()
throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
}

// Execute route attributes' before() methods
// This runs after routing/validation but BEFORE expensive controller instantiation
if ((config('Routing')->useControllerAttributes ?? true) === true) {
$this->benchmark->start('route_attributes_before');
$attributeResponse = $this->router->executeBeforeAttributes($this->request);
$this->benchmark->stop('route_attributes_before');

// If attribute returns a Response, short-circuit
if ($attributeResponse instanceof ResponseInterface) {
$this->benchmark->stop('controller_constructor');
$this->benchmark->stop('controller');

return $attributeResponse;
}

// If attribute returns a modified Request, use it
if ($attributeResponse instanceof RequestInterface) {
$this->request = $attributeResponse;
}
}

return null;
}

Expand Down
9 changes: 9 additions & 0 deletions system/Config/Routing.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ class Routing extends BaseConfig
*/
public bool $autoRoute = false;

/**
* If TRUE, the system will look for attributes on controller
* class and methods that can run before and after the
* controller/method.
*
* If FALSE, will ignore any attributes.
*/
public bool $useControllerAttributes = true;

/**
* For Defined Routes.
* If TRUE, will enable the use of the 'prioritize' option
Expand Down
143 changes: 143 additions & 0 deletions system/Router/Attributes/Cache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Router\Attributes;

use Attribute;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;

/**
* Cache Attribute
*
* Caches the response of a controller method at the server level for a specified duration.
* This is server-side caching to avoid expensive operations, not browser-level caching.
*
* Usage:
* ```php
* #[Cache(for: 3600)] // Cache for 1 hour
* #[Cache(for: 300, key: 'custom_key')] // Cache with custom key
* ```
*
* Limitations:
* - Only caches GET requests; POST, PUT, DELETE, and other methods are ignored
* - Streaming responses or file downloads may not cache properly
* - Cache key includes HTTP method, path, query string, and possibly user_id(), but not request headers
* - Does not automatically invalidate related cache entries
* - Cookies set in the response are cached and reused for all subsequent requests
* - Large responses may impact cache storage performance
* - Browser Cache-Control headers do not affect server-side caching behavior
*
* Security Considerations:
* - Ensure cache backend is properly secured and not accessible publicly
* - Be aware that authorization checks happen before cache lookup
*/
#[Attribute(Attribute::TARGET_METHOD)]
class Cache implements RouteAttributeInterface
{
public function __construct(
public int $for = 3600,
public ?string $key = null,
) {
}

public function before(RequestInterface $request): RequestInterface|ResponseInterface|null
{
// Only cache GET requests
if ($request->getMethod() !== 'GET') {
return null;
}

// Check cache before controller execution
$cacheKey = $this->key ?? $this->generateCacheKey($request);

$cached = cache($cacheKey);
// Validate cached data structure
if ($cached !== null && (is_array($cached) && isset($cached['body'], $cached['headers'], $cached['status']))) {
$response = service('response');
$response->setBody($cached['body']);
$response->setStatusCode($cached['status']);
// Mark response as served from cache to prevent re-caching
$response->setHeader('X-Cached-Response', 'true');

// Restore headers from cached array of header name => value strings
foreach ($cached['headers'] as $name => $value) {
$response->setHeader($name, $value);
}
$response->setHeader('Age', (string) (time() - ($cached['timestamp'] ?? time())));

return $response;
}

return null; // Continue to controller
}

public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface
{
// Don't re-cache if response was already served from cache
if ($response->hasHeader('X-Cached-Response')) {
// Remove the marker header before sending response
$response->removeHeader('X-Cached-Response');

return null;
}

// Only cache GET requests
if ($request->getMethod() !== 'GET') {
return null;
}

$cacheKey = $this->key ?? $this->generateCacheKey($request);

// Convert Header objects to strings for caching
$headers = [];

foreach ($response->headers() as $name => $header) {
// Handle both single Header and array of Headers
if (is_array($header)) {
// Multiple headers with same name
$values = [];

foreach ($header as $h) {
$values[] = $h->getValueLine();
}
$headers[$name] = implode(', ', $values);
} else {
// Single header
$headers[$name] = $header->getValueLine();
}
}

$data = [
'body' => $response->getBody(),
'headers' => $headers,
'status' => $response->getStatusCode(),
'timestamp' => time(),
];

cache()->save($cacheKey, $data, $this->for);

return $response;
}

protected function generateCacheKey(RequestInterface $request): string
{
return 'route_cache_' . hash(
'xxh128',
$request->getMethod() .
$request->getUri()->getPath() .
$request->getUri()->getQuery() .
(function_exists('user_id') ? user_id() : ''),
);
}
}
67 changes: 67 additions & 0 deletions system/Router/Attributes/Filter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Router\Attributes;

use Attribute;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;

/**
* Filter Attribute
*
* Applies CodeIgniter filters to controller classes or methods. Filters can perform
* operations before or after controller execution, such as authentication, CSRF protection,
* rate limiting, or request/response manipulation.
*
* Limitations:
* - Filter must be registered in Config\Filters.php or won't be found
* - Does not validate filter existence at attribute definition time
* - Cannot conditionally apply filters based on runtime conditions
* - Class-level filters cannot be overridden or disabled for specific methods
*
* Security Considerations:
* - Filters run in the order specified; authentication should typically come first
* - Don't rely solely on filters for critical security; validate in controllers too
* - Ensure sensitive filters are registered as globals if they should apply site-wide
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Filter implements RouteAttributeInterface
{
public function __construct(
public string $by,
public array $having = [],
) {
}

public function before(RequestInterface $request): RequestInterface|ResponseInterface|null
{
// Filters are handled by the filter system via getFilters()
// No processing needed here
return null;
}

public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface
{
return null;
}

public function getFilters(): array
{
if ($this->having === []) {
return [$this->by];
}

return [$this->by => $this->having];
}
}
Loading
Loading