Aspect-Oriented Programming
Most frameworks handle cross-cutting concerns — authentication, caching, rate limiting — in a centralized middleware pipeline. It works, but the connection between a route and what applies to it is implicit. AOP makes it explicit: concerns are declared directly on the methods that need them.
How It Works
Canvas uses Aspect-Oriented Programming (AOP). When a request arrives, Canvas matches it to a controller method via
@Route, then reads its @InterceptWith annotations to build an execution pipeline —
inserting cross-cutting concerns like authentication, caching, or rate limiting around the method automatically.
The controller itself contains only business logic; the surrounding pipeline is constructed from metadata by the
framework.
Aspect Types
Each aspect type gives you different control over the execution flow:
Before Aspects
Execute before the target method. Return a Response to prevent method execution, or null to continue:
<?php
namespace App\Aspects;
use Quellabs\Canvas\AOP\Contracts\BeforeAspect;
use Quellabs\Canvas\Routing\Contracts\MethodContextInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
class RequireAuthAspect implements BeforeAspect {
public function __construct(private AuthService $auth) {}
public function before(MethodContextInterface $context): ?Response {
if (!$this->auth->isAuthenticated()) {
return new RedirectResponse('/login');
}
return null; // Proceed to method execution
}
}
After Aspects
Execute after the target method completes. Receive both the method context and the response for logging or modification:
<?php
namespace App\Aspects;
use Quellabs\Canvas\AOP\Contracts\AfterAspect;
use Quellabs\Canvas\Routing\Contracts\MethodContextInterface;
use Symfony\Component\HttpFoundation\Response;
class AuditLogAspect implements AfterAspect {
public function __construct(private LoggerInterface $logger) {}
public function after(MethodContextInterface $context, Response $result): void {
$this->logger->info('Method executed', [
'controller' => $context->getClassName(),
'method' => $context->getMethodName(),
'user' => $this->auth->getCurrentUser()?->id
]);
}
}
Around Aspects
Wrap the entire method execution. Control when and if the method executes by calling the $proceed callable:
<?php
namespace App\Aspects;
use Quellabs\Canvas\AOP\Contracts\AroundAspect;
use Quellabs\Canvas\Routing\Contracts\MethodContextInterface;
class CacheAspect implements AroundAspect {
public function __construct(
private CacheInterface $cache,
private int $ttl = 300
) {}
public function around(MethodContextInterface $context, callable $proceed): mixed {
$key = $this->generateCacheKey($context);
if ($cached = $this->cache->get($key)) {
return $cached;
}
$result = $proceed(); // Execute the wrapped method
$this->cache->set($key, $result, $this->ttl);
return $result;
}
}
Basic Usage
Method-Level Application
Apply aspects to individual methods using @InterceptWith:
class UserController extends BaseController {
/**
* @Route("/users")
* @InterceptWith(RequireAuthAspect::class)
*/
public function index() {
$users = $this->em()->findBy(User::class, ['active' => true]);
return $this->render('users/index.tpl', compact('users'));
}
}
Class-Level Application
Apply aspects to all methods in a controller:
/**
* @InterceptWith(RequireAuthAspect::class)
*/
class AdminController extends BaseController {
/**
* @Route("/admin/users")
*/
public function users() {
// Authentication required automatically
}
}
Annotation Inheritance
Child controllers automatically inherit all class-level annotations from parent controllers. This enables controller hierarchies with shared cross-cutting concerns.
/**
* @InterceptWith(RequireAuthAspect::class)
* @InterceptWith(AuditLogAspect::class)
*/
abstract class AuthenticatedController extends BaseController {}
class UserController extends AuthenticatedController {
/**
* @Route("/users")
* @InterceptWith(CacheAspect::class, ttl=300)
*/
public function index() {
// Execution order: RequireAuth → AuditLog (inherited from AuthenticatedController) → Cache (method-level)
}
}
Aspect Parameters
Configure aspects through annotation parameters, which map to constructor parameters:
/**
* @Route("/heavy-operation")
* @InterceptWith(CacheAspect::class, ttl=3600)
* @InterceptWith(RateLimitAspect::class, limit=10, window=60)
*/
public function heavyOperation() {
// Cached for 1 hour, rate limited to 10 requests per minute
}
Parameters become constructor arguments alongside any dependencies resolved by the DI container. DI-resolved dependencies (like CacheInterface) come first; annotation parameters fill the remaining arguments by name:
class CacheAspect implements AroundAspect {
public function __construct(
private CacheInterface $cache,
private int $ttl = 300,
private array $tags = []
) {}
}
Aspect Priority
Control execution order when multiple aspects apply to the same inheritance level using the priority parameter. Higher priority values execute first (default is 0):
class UserController extends BaseController {
/**
* @Route("/profile/update")
* @InterceptWith(AuthAspect::class, priority=100) // Runs first
* @InterceptWith(PermissionAspect::class, priority=90) // Runs second
* @InterceptWith(ValidationAspect::class, priority=50) // Runs third
*/
public function updateProfile() {
// Order ensures authentication → permission checks → validation
}
}
The priority parameter is not passed to the aspect constructor - it only affects execution order.
Execution Order
When multiple aspects are present, the dispatcher runs four phases in sequence: Request → Before → Around → After. All aspects are collected first, sorted by inheritance hierarchy and priority, then each phase filters for its type and runs in that order.
Inheritance hierarchy determines the order aspects are collected:
- Grandparent class - aspects sorted by priority
- Parent class - aspects sorted by priority
- Current class - aspects sorted by priority
- Method - aspects sorted by priority
Priority ensures execution order within each level. Higher values execute first (default is 0).
/**
* @InterceptWith(ParentAspect::class, priority=100)
* @InterceptWith(ParentLogAspect::class, priority=50)
*/
abstract class ParentController extends BaseController {}
/**
* @InterceptWith(ChildAspect::class, priority=100)
* @InterceptWith(ChildCacheAspect::class, priority=50)
*/
class ChildController extends ParentController {
/**
* @Route("/example")
* @InterceptWith(MethodAspect::class, priority=100)
* @InterceptWith(ValidationAspect::class, priority=50)
*/
public function example() {
// Execution order:
// ParentController: ParentAspect (100), ParentLogAspect (50)
// ChildController: ChildAspect (100), ChildCacheAspect (50)
// Method: MethodAspect (100), ValidationAspect (50)
}
}
The MethodContext Object
Every aspect receives a MethodContextInterface object that provides access to information about the intercepted method call. Services can also inject MethodContextInterface via dependency injection to access request execution context.
Available Methods
getClass(): object- Returns the controller instancegetClassName(): string- Returns the fully qualified class namegetMethodName(): string- Returns the method name being calledgetArguments(): array- Returns method arguments (including route parameters)getRequest(): Request- Returns the Symfony Request objectsetRequest(Request $request): void- Replaces the request object
Common Usage in Aspects
Route parameters are accessed via getArguments():
// @Route("/users/{userId}/orders/{orderId}")
// public function showOrder($userId, $orderId) { ... }
public function before(MethodContextInterface $context): ?Response {
[$userId, $orderId] = $context->getArguments();
// ...
}
Request data is accessed via getRequest():
public function before(MethodContextInterface $context): ?Response {
$request = $context->getRequest();
$ip = $request->getClientIp();
$apiKey = $request->headers->get('X-Api-Key');
$data = $request->request->all();
// ...
}
Dependency Injection
Services can inject MethodContextInterface to access request execution context:
use Quellabs\Canvas\Routing\Contracts\MethodContextInterface;
class MyService {
public function __construct(
private ?MethodContextInterface $context = null
) {}
public function doSomething() {
if ($this->context) {
$controller = $this->context->getClassName();
$method = $this->context->getMethodName();
// Use context data...
}
}
}
Context is automatically injected during request/response cycle and available to any service that requests it.