Aspect-Oriented Programming
Many frameworks use "middleware" or "filters" - functions that wrap around your request/response cycle in a pipeline, where each piece of middleware processes the request before passing it to the next layer. This works, but leads to centralized configuration, coupling between unrelated concerns, and difficulty understanding which middleware applies to which routes.
Meet Aspect-Oriented Programming (AOP)
Canvas uses Aspect-Oriented Programming (AOP). Adding @InterceptWith to a controller method routes it through an AspectDispatcher that wraps it with your specified aspects - "before" aspects (like auth checks) run first, "after" aspects (like logging) run last, and "around" aspects wrap the entire execution with full control. This turns cross-cutting concerns like authentication, caching, logging, and rate limiting into reusable pieces you attach where needed, instead of manually calling checkAuth() or logRequest() in every method.
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() {
// Executes: RequireAuth, AuditLog (inherited), Cache (method)
}
}
Aspect Parameters
Configure aspects through annotation parameters, which map to constructor parameters:
/**
* @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:
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 {
/**
* @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, they execute following the inheritance hierarchy. Each class level maintains its position, with priority controlling order within that level:
- 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.