Aspect-Oriented Programming
AOP is Canvas's middleware layer. Where other frameworks handle cross-cutting concerns — authentication, caching, rate limiting — in a centralized middleware pipeline, Canvas uses Aspect-Oriented Programming instead: concerns are declared directly on the methods that need them, making the relationship between a route and what applies to it explicit rather than implicit.
How It Works
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 executes four lifecycle phases in sequence: Before → Around → After. Aspects are collected from the full controller inheritance chain. Execution order is determined first by controller inheritance level, then by aspect priority within each level.
Inheritance Order
Aspects are collected from the controller hierarchy in this order:
- Grandparent class — aspects sorted by priority
- Parent class — aspects sorted by priority
- Current class — aspects sorted by priority
- Method — aspects sorted by priority
Lifecycle Execution Rules
Within each inheritance level, aspects are sorted by the
priority parameter. Higher priority values execute first
(default is 0).
- Request aspects execute in collection order.
- Before aspects execute in collection order (highest priority first). An aspect that runs first on the way in has the broadest scope — it wraps everything that follows.
- Around aspects The highest-priority aspect wraps all lower-priority aspects, so its code runs first before the controller and last after it returns. Execution unwinds outward after the controller completes.
- After aspects execute in reverse collection order. Because each aspect effectively wraps everything that runs after it, the outermost aspect (highest priority) must also be the last to finish — mirroring how a call stack unwinds. This guarantees that an aspect's after-logic runs within the scope it opened on the way in.
The MethodContext Object
Every aspect receives a MethodContextInterface object that provides access to
the intercepted method call. This is the Canvas extension of the base interface used by
the DI container — it adds request and controller access on top of the resolution context
available to service providers.
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();
// ...
}
For injecting context into services and using it outside of aspects, see The MethodContext Object section on the Containers page.