AOP Sample Application
Build an app that guards a REST endpoint with a custom AOP attribute. You annotate the endpoint method with #[RequireCustomHeader] and the framework runs your interceptor before the method body: requests carrying X-Custom-Foo-Bar: foo-bar go through, everything else is denied with 403 without the method executing. This example applies the concepts from the main AOP documentation — attributes, interceptors, and the advice lifecycle — to a complete runnable app.
Prerequisites
Section titled “Prerequisites”You need PHP 8.5 or later with the swoole and pcntl extensions. No external services are needed.
Project structure
Section titled “Project structure”The sample uses this layout:
aop-guard/├── bin/│ └── application.php # Application entry point├── config/│ └── application.yml # Server and app identity├── src/│ ├── AopSampleApplication.php # Main application class│ ├── aop/│ │ ├── RequireCustomHeader.php # The AOP attribute│ │ └── RequireCustomHeaderInterceptor.php # The advice│ └── rest/│ └── AopDemoController.php # Guarded endpoint└── composer.json # DependenciesInstall dependencies
Section titled “Install dependencies”Require the framework package:
composer require suvera/winter-bootSource files
Section titled “Source files”Switch between the source files. Each tab shows the exact file from the sample.
The single entry point. No extra #[Enable*] attribute is needed — AOP support is always on.
<?php
namespace dev\example;
use dev\winterframework\stereotype\WinterBootApplication;
#[WinterBootApplication( configDirectory: [__DIR__ . "/../config"], scanNamespaces: [ ['dev\\example', __DIR__ . ''] ])]class AopSampleApplication {
public static function main(): void { $winterApp = new \dev\winterframework\core\app\WinterWebSwooleApplication(); $winterApp->run(self::class); }}The attribute. It declares which header and value to require (with defaults), hands out one shared interceptor instance, and validates at boot that it sits on a suitable public method.
<?phpdeclare(strict_types=1);
namespace dev\example\aop;
use Attribute;use dev\winterframework\reflection\ref\RefMethod;use dev\winterframework\reflection\support\StereoTypeValidations;use dev\winterframework\stereotype\StereoTyped;use dev\winterframework\stereotype\aop\AopStereoType;use dev\winterframework\stereotype\aop\WinterAspect;use dev\winterframework\type\TypeAssert;
#[Attribute(Attribute::TARGET_METHOD)]#[StereoTyped]class RequireCustomHeader implements AopStereoType { use StereoTypeValidations;
private ?RequireCustomHeaderInterceptor $interceptor = null;
public function __construct( public string $headerName = 'X-Custom-Foo-Bar', public string $expectedValue = 'foo-bar' ) { }
public function isPerInstance(): bool { return false; // shared stateless interceptor }
public function getAspect(): WinterAspect { if (!isset($this->interceptor)) { $this->interceptor = new RequireCustomHeaderInterceptor(); } return $this->interceptor; }
public function init(object $ref): void { /** @var RefMethod $ref */ TypeAssert::typeOf($ref, RefMethod::class); $this->validateAopMethod($ref, 'RequireCustomHeader'); }}#[StereoTyped] is what makes the scanner discover your attribute class. isPerInstance() => false shares one interceptor across all guarded methods.
The advice. begin() runs before the endpoint body: it reads the header from the request and calls stopExecution() with a 403 response on mismatch, so the body never runs. Throwing from begin() would surface as a 500 — denying via stopExecution() is what produces the 403.
<?phpdeclare(strict_types=1);
namespace dev\example\aop;
use dev\winterframework\core\aop\AopExecutionContext;use dev\winterframework\exception\WinterException;use dev\winterframework\stereotype\aop\AopContext;use dev\winterframework\stereotype\aop\WinterAspect;use dev\winterframework\util\log\Wlf4p;use dev\winterframework\web\http\HttpRequest;use dev\winterframework\web\http\HttpStatus;use dev\winterframework\web\http\ResponseEntity;use Throwable;
class RequireCustomHeaderInterceptor implements WinterAspect { use Wlf4p;
public function begin(AopContext $ctx, AopExecutionContext $exCtx): void { /** @var RequireCustomHeader $stereo */ $stereo = $ctx->getStereoType();
$request = $ctx->getApplicationContext()->getCurrentHttpRequest(); if (!isset($request)) { throw new WinterException( '#[RequireCustomHeader] needs an HttpRequest argument on method ' . $ctx->getMethod()->getName() ); }
if ($request->getFirstHeader($stereo->headerName) !== $stereo->expectedValue) { self::logWarning('Forbidden call to ' . $ctx->getMethod()->getName()); $exCtx->stopExecution( ResponseEntity::status(HttpStatus::$FORBIDDEN)->withJson([ 'success' => false, 'data' => null, 'error' => 'Forbidden' ]) ); } }
public function beginFailed(AopContext $ctx, AopExecutionContext $exCtx, Throwable $ex): void { self::logError('RequireCustomHeader begin failed: ' . $ex->getMessage()); }
public function commit(AopContext $ctx, AopExecutionContext $exCtx, mixed $result): void { self::logInfo('RequireCustomHeader check passed for ' . $ctx->getMethod()->getName()); }
public function commitFailed(AopContext $ctx, AopExecutionContext $exCtx, mixed $result, Throwable $ex): void { self::logError('RequireCustomHeader commit failed: ' . $ex->getMessage()); }
public function failed(AopContext $ctx, AopExecutionContext $exCtx, Throwable $ex): void { self::logError($ctx->getMethod()->getName() . ' failed: ' . $ex->getMessage()); }}The guarded endpoint. It guared by #[RequireCustomHeader] AOP advice.
<?phpdeclare(strict_types=1);
namespace dev\example\rest;
use dev\example\aop\RequireCustomHeader;use dev\winterframework\stereotype\RestController;use dev\winterframework\stereotype\web\GetMapping;use dev\winterframework\stereotype\web\RequestMapping;use dev\winterframework\web\http\HttpRequest;
#[RestController]#[RequestMapping(path: "aop-demo")]class AopDemoController {
#[GetMapping(path: "secure-greeting")] #[RequireCustomHeader] public function secureGreeting(): array { return [ 'success' => true, 'data' => 'Hello from the AOP-guarded endpoint!' ]; }}To guard another endpoint, add an HttpRequest argument and annotate it — custom header name and value are optional parameters: #[RequireCustomHeader(headerName: "X-Api-Key", expectedValue: "secret")].
The launch script. It loads the Composer autoloader and starts the application.
<?php
use dev\example\AopSampleApplication;
require_once(dirname(__DIR__) . '/vendor/autoload.php');
AopSampleApplication::main();The sample declares the framework dependency with PSR-4 autoloading for its own namespace.
{ "name": "suvera/winter-boot-aop-sample", "require": { "ext-pcntl": "*", "ext-swoole": "*", "suvera/winter-boot": "@dev" }, "autoload": { "psr-4": { "dev\\example\\": "src/" } }}The runnable sample in winter-boot-samples adds a local path repository for winter-boot so it resolves from a sibling checkout. You do not need that entry when you install the released package from Packagist.
Configuration
Section titled “Configuration”AOP needs no module config. The full sample application.yml sets the server and app identity:
server: port: 8080 address: 0.0.0.0 context-path: /winter: application: name: AOP Guard Sample Application id: aop-guard-sample-app version: 1.0.0See Configuration for every application.yml key.
Run the app
Section titled “Run the app”Start the application, then call the endpoint with and without the header.
1. Start the application:
composer installphp bin/application.php2. Call without the header — expect 403:
curl -i http://127.0.0.1:8080/aop-demo/secure-greetingHTTP/1.1 403 Forbidden{"success": false, "data": null, "error": "Forbidden"}The interceptor stopped execution, so the endpoint body never ran.
3. Call with a wrong value — expect 403:
curl -i -H "X-Custom-Foo-Bar: wrong" http://127.0.0.1:8080/aop-demo/secure-greeting4. Call with the right value — expect 200:
curl -i -H "X-Custom-Foo-Bar: foo-bar" http://127.0.0.1:8080/aop-demo/secure-greetingHTTP/1.1 200 OK{"success": true, "data": "Hello from the AOP-guarded endpoint!"}Next steps
Section titled “Next steps”- Read AOP for the advice lifecycle (
begin/commit/failed),stopExecution, and execution variables. - Put reusable advice on
#[Service]beans the same way — bean-to-bean calls run through proxies, so the same attribute works there unchanged.