Skip to content

Winter Boot AOP: Aspect-Oriented Programming Guide

Aspect-Oriented Programming (AOP) lets you attach behavior to method calls without changing the method itself. In Winter Boot, AOP is expressed entirely through PHP 8 attributes. When the container finds an AOP attribute on a bean method, it wraps that method in an interceptor chain. The method body only runs if every interceptor allows it, so a single attribute can enforce a security check, manage a transaction, or cache a result. All AOP contracts live in the dev\winterframework\stereotype\aop\ namespace.

Winter Boot ships several ready-to-use AOP attributes. Some of them must first be switched on with an #[Enable...] attribute on your #[WinterBootApplication] class, as noted below.

#[Transactional]

Wraps the annotated method in a database transaction. The transaction commits when the method returns normally, or rolls back if an exception is thrown.

OrderService.php
use dev\winterframework\txn\stereotype\Transactional;
#[Service]
class OrderService {
#[Transactional]
public function createOrder(array $items): Order {
// All DB operations here run inside a single transaction
}
}
#[Cacheable]

Caches the method’s return value. Subsequent calls with the same arguments return the cached result without executing the method body.

ProductCatalog.php
use dev\winterframework\cache\stereotype\Cacheable;
#[Service]
class ProductCatalog {
#[Cacheable(cacheNames: "products")]
public function findProduct(int $id): Product {
// Expensive DB lookup — only runs on cache miss
}
}
#[Lockable]

Acquires a distributed lock before the method body executes and releases it afterwards, preventing concurrent duplicate execution.

InventoryService.php
use dev\winterframework\stereotype\concurrent\Lockable;
#[Service]
class InventoryService {
#[Lockable(name: "inventory-update")]
public function adjustStock(int $productId, int $delta): void {
// Only one thread/process executes this at a time
}
}
#[Async]

Schedules the method to run asynchronously in a worker process. The caller returns immediately without waiting for the result.

EmailService.php
use dev\winterframework\task\async\stereotype\Async;
#[Service]
class EmailService {
#[Async]
public function sendWelcomeEmail(string $address): void {
// Runs in a background worker
}
}
#[Scheduled]

Calls a method on a fixed schedule. Supports fixed delay, fixed rate, and an initial delay, all in seconds.

ReportGenerator.php
use dev\winterframework\task\scheduling\stereotype\Scheduled;
#[Service]
class ReportGenerator {
#[Scheduled(fixedRate: 60, initialDelay: 5)]
public function generateDailyReport(): void {
// Called every 60 seconds, first run after 5 seconds
}
}
#[Traceable]

Wraps the method in a distributed-tracing span that records its duration and any exception.

CheckoutService.php
use dev\winterframework\telemetry\stereotype\Traceable;
#[Service]
class CheckoutService {
#[Traceable]
public function processCheckout(Cart $cart): Receipt {
// A trace span is created around this call
}
}

You can create your own AOP attributes using two components: an attribute class that implements AopStereoType, and an interceptor class that implements WinterAspect. Once both classes live in a scanned namespace, apply the attribute to any managed bean method.

  1. Create the attribute class

    Declare a PHP attribute class, annotate it with #[StereoTyped], and implement the AopStereoType interface. The attribute class must implement two methods:

    • isPerInstance(): bool — return true if a new interceptor instance should be created per bean instance, or false for a shared stateless interceptor.
    • getAspect(): WinterAspect — return the interceptor object (lazily constructed).
    TimedLog.php
    use Attribute;
    use dev\winterframework\stereotype\StereoTyped;
    use dev\winterframework\stereotype\aop\AopStereoType;
    use dev\winterframework\stereotype\aop\WinterAspect;
    use dev\winterframework\reflection\ref\RefMethod;
    use dev\winterframework\reflection\support\StereoTypeValidations;
    use dev\winterframework\type\TypeAssert;
    #[Attribute(Attribute::TARGET_METHOD)]
    #[StereoTyped]
    class TimedLog implements AopStereoType {
    use StereoTypeValidations;
    private ?TimedLogInterceptor $interceptor = null;
    public function __construct(
    public int $thresholdMs = 500
    ) {
    }
    public function isPerInstance(): bool {
    return false; // shared stateless interceptor
    }
    public function getAspect(): WinterAspect {
    if (!isset($this->interceptor)) {
    $this->interceptor = new TimedLogInterceptor();
    }
    return $this->interceptor;
    }
    public function init(object $ref): void {
    /** @var RefMethod $ref */
    TypeAssert::typeOf($ref, RefMethod::class);
    $this->validateAopMethod($ref, 'TimedLog');
    }
    }
  2. Create the interceptor class

    Implement WinterAspect with all five lifecycle methods. AopContext carries method reflection and the application context. AopExecutionContext carries the target object, arguments, and execution control handles.

    TimedLogInterceptor.php
    use dev\winterframework\stereotype\aop\WinterAspect;
    use dev\winterframework\stereotype\aop\AopContext;
    use dev\winterframework\core\aop\AopExecutionContext;
    use dev\winterframework\util\log\Wlf4p;
    use Throwable;
    class TimedLogInterceptor implements WinterAspect {
    use Wlf4p;
    const START_TIME = 'TimedLog_start';
    public function begin(AopContext $ctx, AopExecutionContext $exCtx): void {
    // Runs BEFORE the method body — stash the start time
    $exCtx->setVariable(self::START_TIME, microtime(true));
    }
    public function beginFailed(
    AopContext $ctx,
    AopExecutionContext $exCtx,
    Throwable $ex
    ): void {
    // Runs if begin() threw an exception
    self::logError('TimedLog begin failed for ' . $ctx->getMethod()->getName());
    }
    public function commit(
    AopContext $ctx,
    AopExecutionContext $exCtx,
    mixed $result
    ): void {
    // Runs AFTER the method body succeeds — $result is the return value
    $durationMs = $this->durationMs($exCtx);
    $method = $ctx->getMethod()->getName();
    /** @var TimedLog $attr */
    $attr = $ctx->getStereoType();
    if ($durationMs >= $attr->thresholdMs) {
    self::logWarning($method . ' took ' . round($durationMs, 2) . 'ms');
    } else {
    self::logInfo($method . ' took ' . round($durationMs, 2) . 'ms');
    }
    }
    public function commitFailed(
    AopContext $ctx,
    AopExecutionContext $exCtx,
    mixed $result,
    Throwable $ex
    ): void {
    // Runs if commit() threw an exception
    self::logError('TimedLog commit failed for ' . $ctx->getMethod()->getName());
    }
    public function failed(
    AopContext $ctx,
    AopExecutionContext $exCtx,
    Throwable $ex
    ): void {
    // Runs if the method body threw an exception
    $durationMs = $this->durationMs($exCtx);
    self::logError(
    $ctx->getMethod()->getName() . ' failed after '
    . round($durationMs, 2) . 'ms: ' . $ex->getMessage()
    );
    }
    private function durationMs(AopExecutionContext $exCtx): float {
    $start = $exCtx->getVariable(self::START_TIME);
    if (!is_float($start)) {
    return 0.0;
    }
    return (microtime(true) - $start) * 1000;
    }
    }
  3. Apply your custom attribute

    Once both classes are in a scanned namespace, apply the attribute to any managed bean method:

    OrderService.php
    use dev\winterframework\stereotype\Service;
    #[Service]
    class OrderService {
    #[TimedLog]
    public function createOrder(array $items): Order {
    // Duration is logged on every call; warns when it exceeds 500ms
    return $this->orderRepository->save($items);
    }
    #[TimedLog(thresholdMs: 200)]
    public function exportAllOrders(): array {
    // Warns when the export takes 200ms or longer
    return $this->orderRepository->findAll();
    }
    }

In a #[RestController], put AOP attributes only on endpoint methods — the methods marked with #[GetMapping], #[PostMapping], or the other mapping attributes. Those methods run when someone calls their URL, and your advice runs with them.

Do not put AOP attributes on plain helper methods inside a controller. Helpers run as ordinary method calls, so the advice never runs for them; Winter Boot logs a warning at startup naming each such method. If a helper needs guarding, move that logic into a #[Service] bean, where AOP works on every public method.

On an endpoint, the aspect receives the method’s arguments in declaration order, the same as on a service. A #{...} template such as #[Lockable(name: "order-#{id}")] resolves against the #[PathVariable] and #[RequestParam] values by parameter name.


The five methods of WinterAspect form the interceptor pipeline around every method call. Each method fires at a distinct point in the execution flow.

Method When it runs Key use cases
begin(ctx, exCtx) Before the method body Authentication, acquiring locks, starting transactions
beginFailed(ctx, exCtx, ex) When begin() throws Logging, cleanup after a failed pre-check
commit(ctx, exCtx, result) After the method body succeeds Caching the result, committing transactions
commitFailed(ctx, exCtx, result, ex) When commit() throws Rolling back a partial commit
failed(ctx, exCtx, ex) When the method body throws Rolling back transactions, releasing locks, logging errors

By default, an exception thrown from an aspect’s commit() is passed to commitFailed(), logged, and swallowed — the caller still receives the method’s result. That is the right behavior for side concerns such as caching or tracing.

When a commit failure means the work was not saved (a transaction that could not commit, for example), implement the PropagatesCommitFailure marker interface on your aspect. Every aspect’s commit()/commitFailed() still runs, and then the first failure from a propagating aspect is rethrown to the caller. The built-in transaction aspect works this way.

OutboxAspect.php
use dev\winterframework\stereotype\aop\PropagatesCommitFailure;
use dev\winterframework\stereotype\aop\WinterAspect;
class OutboxAspect implements WinterAspect, PropagatesCommitFailure {
// begin(), commit(), ... as usual
}

AopExecutionContext gives an interceptor full control over whether the method body runs and what value the caller receives.

AopExecutionContext usage
// Stop the method from executing; supply the value the caller will receive
$exCtx->stopExecution(null);
// Retrieve the arguments the original method was called with
$args = $exCtx->getArguments();
// Retrieve the bean instance the method belongs to
$bean = $exCtx->getObject();
// Store and retrieve custom variables shared across the pipeline phases
$exCtx->setVariable('txId', $transactionId);
$txId = $exCtx->getVariable('txId');

AopContext carries the reflection metadata for the intercepted method call, giving you access to the method name, the attribute instance, and the full application context.

AopContext usage
// Full reflection object for the intercepted method
$method = $ctx->getMethod(); // RefMethod
$name = $ctx->getMethod()->getName(); // e.g. "exportAllOrders"
// The AOP attribute instance (your custom attribute class)
$attr = $ctx->getStereoType();
// The application context — access any bean from within the interceptor
$appCtx = $ctx->getApplicationContext();
$repo = $appCtx->beanByClass(UserRepository::class);

See the AOP guard example for a complete runnable app: a custom #[RequireCustomHeader] attribute protecting a REST endpoint with a 403 deny path.