Search by

goaop / goaop-symfony-bundle

lisachenko

Symfony bundle for the Go! AOP framework: transparent aspect weaving for Symfony applications

Package info

github.com/goaop/goaop-symfony-bundle

Homepage

Type:symfony-bundle

pkg:composer/goaop/goaop-symfony-bundle

Fund package maintenance!

lisachenko

Statistics

Installs: 136 494

Dependents: 2

Suggesters: 0

Stars: 54

Open Issues: 7

4.0.0 2026-11-07 20:22 UTC

This package is auto-updated.

Last update: 2026-11-08 22:37:42 UTC


README

CI PHPStan Badge GitHub release Total Downloads Monthly Downloads Minimum PHP Version Symfony License Sponsor

Aspect-Oriented Programming for Symfony applications, powered by the Go! AOP framework 4.x. Write an aspect, give it a pointcut, and the bundle weaves it into your services as they are loaded — no base classes, no decorators to register, no proxies to maintain, no eval(), no PHP extension.

namespace App\Aspect;

use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Attribute\Around;
use Psr\Log\LoggerInterface;

final class TimingAspect implements Aspect
{
    public function __construct(private readonly LoggerInterface $logger) {}

    #[Around('execution(public App\Service\**->*(*))')]
    public function measure(MethodInvocation $invocation): mixed
    {
        $start = hrtime(true);
        try {
            return $invocation->proceed();
        } finally {
            $this->logger->info('{method} took {ms} ms', [
                'method' => (string) $invocation,
                'ms'     => (hrtime(true) - $start) / 1e6,
            ]);
        }
    }
}

That is the whole integration: the class implements Go\Aop\Aspect, so autoconfiguration registers it as an aspect, autowiring gives it its logger, and every public method of every class below App\Service is timed from the next request on.

Why Aspect-Oriented Programming in Symfony?

Some logic belongs to no single class and still ends up in hundreds of them: logging, metrics, caching, transactions, authorization checks, retries, rate limits, audit trails. Symfony offers event listeners for the HTTP lifecycle and decorators for single services; AOP generalizes both to any method of your own code. You describe where with a pointcut expression and what with an advice, and the engine applies it, so your services stay business logic.

✨ Highlights

  • 🪡 Transparent weaving. Classes are transformed at load time into a proxy backed by the original code as a trait. Constructor injection, autowiring, new, instanceof, final, readonly, enums, static and private methods, property hooks: everything keeps working, and the woven code is plain PHP you can read in the cache directory.
  • 🏷️ PHP 8 attributes. #[Before], #[After], #[Around], #[AfterThrowing], #[Pointcut] and #[DeclareParents], with a rich pointcut language: execution(public App\Service\**->*(*)), @execution(App\Attribute\Cached), access(App\Entity\*->email), initialization(App\Model\*), within(...), &&, ||, !.
  • 🧩 Symfony native. Aspects are ordinary autowired services, discovered by autoconfiguration (or the goaop.aspect tag). The aspect container delegates to the Symfony container, the aspect kernel and container are autowirable services, and the bundle configures Symfony's container dumper so that nothing bypasses the weaver.
  • 🚀 Production ready. cache:warmup and cache:clear weave the whole application ahead of time, also when cache:clear builds the new cache next to the running one. With PREBUILT_CACHE the runtime trusts the deployed cache completely: no file system checks, works on read-only file systems, friendly to opcache and preloading.
  • 🔬 Observable. A web profiler panel lists the aspects and their advisors, the woven classes used by the request and the classes that were loaded before weaving was possible. debug:aspect, debug:advisor and debug:weaving give the same answers on the command line.
  • 🗄️ Doctrine ORM aware. Entities are discovered through their woven classes, so an aspect on an entity method works, and intercepted entity properties are validated against the ORM configuration at metadata load time.
  • ✅ Tested and typed. PHPStan at level max, and an end-to-end test suite that boots a real Symfony application in debug and production mode, runs cache:clear, renders the profiler panel and maps a Doctrine entity.

Requirements

  • PHP >= 8.4
  • Symfony 7.4 or 8.x
  • goaop/framework ^4.0 (attribute-based aspects)

Installation

composer require goaop/goaop-symfony-bundle

Register the bundle as the first entry of config/bundles.php. Weaving only applies to classes loaded after the aspect kernel is initialized, and bundles boot in registration order, so this is enforced: the container build fails with a clear message otherwise.

Symfony Flex registers new bundles at the end of config/bundles.php, so the cache:clear that runs at the end of composer require fails with "GoAopBundle must be the first bundle". Move the line to the top as shown below and run bin/console cache:clear again; this is needed once.

// config/bundles.php
return [
    Go\Symfony\GoAopBundle\GoAopBundle::class => ['all' => true],
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    // ...
];

Optionally create config/packages/go_aop.yaml; the defaults below weave the src/ directory in the mode of the kernel, and enable the prebuilt cache in production:

go_aop:
    include_paths: ['%kernel.project_dir%/src']

when@prod:
    go_aop:
        features: [PREBUILT_CACHE]

Quick start

  1. Write an aspect. A service implementing Go\Aop\Aspect whose public methods carry advice attributes (see the example at the top). Dependencies are injected by the container as usual.
  2. Check what it matches.
    bin/console debug:aspect                                              # aspects with their pointcuts and advisors
    bin/console debug:advisor --advisor='App\Aspect\TimingAspect->measure' # the joinpoints an advisor matches
  3. Use your services. Classes below the include paths are woven on first load in debug mode (and re-woven when their source or an aspect changes). Open the Go! AOP panel of the web profiler to see the woven classes of a request.
  4. Deploy. cache:clear or cache:warmup weaves everything ahead of time; enable PREBUILT_CACHE in production (see Deployment).

The pointcut language, the advice types and the joinpoint API are documented in the framework: pointcuts, advices, configuration.

How it works

  1. Boot. GoAopBundle::boot() runs before any other bundle boots and creates the goaop.aspect_kernel service. The kernel hooks the Go! AOP class loader into Composer's autoloader (lifting Symfony's DebugClassLoader for a moment and putting it back on top) and registers the aspects.
  2. Aspects by class. A compiler pass collects every service tagged goaop.aspect into a service locator keyed by the aspect class. The weaver only needs the classes: an aspect is instantiated by the Symfony container when one of its advices runs for the first time, never during weaving or cache warm-up. The aspect container also delegates to the Symfony container, so $aspectContainer->get(LoggerInterface::class) works where the framework documentation uses it.
  3. Weaving. Every class loaded from an include path goes through the weaver. In debug mode a stream filter transforms the source on the fly and caches the result; in production the woven class map is handed to Composer, so woven classes resolve natively to their cached files.
  4. No shortcuts around the class loader. Symfony's container dumper can include_once the source files of services directly (.container.dumper.inline_class_loader, on by default in debug mode). The bundle turns it off unless your configuration sets it, because such an include bypasses the class loader and therefore the weaving.
  5. A freshness check that loads nothing. In debug mode Symfony checks whether the compiled container is still fresh before the bundles boot. For a service whose source file changed, its reflection based check loads the class to compare signatures, which would declare the class unwoven before the aspect kernel exists; an edit of a method body keeps the signature, so the container would not be rebuilt and every following request would run the class unwoven until cache:clear (#24). The bundle replaces these checks for the classes of the include paths with one that compares the source files token by token, ignoring the bodies of non-static methods, comments and whitespace: editing a method body keeps the container and the weaving, any other change of the file rebuilds the container.

The process that compiles the container has to load the service classes before the bundle boots, so the request (or command) that rebuilds the container (after a change of a configuration file, or of a class outside of a method body) runs those classes unwoven once. The profiler panel lists them as classes loaded before weaving; the next request is fully woven. Deployments are not affected: cache:clear and cache:warmup build the container and the AOP cache together.

Configuration

bin/console config:dump-reference go_aop prints the reference. All options:

go_aop:
    # Development mode: changed sources and aspects are woven again on the next request,
    # breakpoints work in the original files. Keep it off in production.
    debug: '%kernel.debug%'

    # Application root: only files below it can be woven, cache paths mirror source paths relative to it.
    app_dir: '%kernel.project_dir%'

    # Directory for woven sources, proxies and advisor caches. Anchor it on %kernel.cache_dir%
    # or %kernel.build_dir% so that cache:clear and cache:warmup manage it.
    cache_dir: '%kernel.cache_dir%/aop'

    # Permissions of the cache files: an octal literal (0o770), an octal string ("0770") or an integer.
    # Defaults to 0770 minus the umask.
    cache_file_mode: ~

    # Directories that are woven. Keep the list narrow: weaving vendor code is slow and rarely needed.
    include_paths: ['%kernel.project_dir%/src']

    # Directories below the include paths that are never woven (the cache directory and the
    # framework itself are always excluded).
    exclude_paths: []

    # Number of parsed source files the weaver keeps in memory (about 0.2 MB each): more re-parses
    # less, fewer uses less memory. The cache warmer uses it too, as does cache:warmup:aop
    # unless --cached-files is given.
    parsed_files_limit: 100

    # Framework features, by the name of the Go\Aop\Features constant:
    #   PREBUILT_CACHE     trust the deployed cache completely, never check or write it at runtime
    #   INTERCEPT_INCLUDES route include/require in woven files through the weaver (legacy code)
    #   PARTIAL_REFLECTION weave classes whose parent class, interface or trait Composer can not
    #                      locate, with their own members (see "Classes extending generated code")
    features: []

    # Aspect kernel class; extend Go\Symfony\GoAopBundle\Kernel\AspectSymfonyKernel to customize
    # the transformation pipeline.
    kernel_class: Go\Symfony\GoAopBundle\Kernel\AspectSymfonyKernel

    # Weave every file below the include paths during cache:warmup and cache:clear.
    cache_warmer: true

    # Doctrine ORM integration, enabled automatically when DoctrineBundle is registered.
    doctrine: ~

Environment variables work as everywhere in Symfony, e.g. debug: '%env(bool:GOAOP_DEBUG)%'. Function and initialization() pointcuts need no feature flag: the pointcuts enable their machinery themselves.

Console commands

Command Purpose
cache:warmup:aop [--fail-fast] [--cache-dir=DIR] [--cached-files=N] [--preload[=FILE]] Weaves every file below the include paths into the cache. Fails when a file cannot be woven. The same warm-up runs as a cache warmer of cache:warmup and cache:clear. --preload also writes the opcache.preload script of the cache, see Opcache preloading.
cache:warmup:opcache [--file-cache=DIR] [--entry=FILE] Primes the opcache file cache (opcache.file_cache) with the AOP cache and the application, for containers that start with a hot opcache. Run it in the final image, with the settings it prints when they are missing.
debug:aspect [--aspect=FQCN] Lists the aspects with their pointcuts and advisors.
debug:advisor [--advisor=ID] Lists the advisors; with --advisor the joinpoints that advisor matches. The quickest way to check a pointcut expression.
debug:weaving [-v] Weaves the application twice and reports proxies that differ, which points to a non-deterministic aspect.

These are the commands of the framework itself, registered in the console of the application: they work on the running aspect kernel, so the loader argument of vendor/bin/aspect is omitted. cache:warmup:aop --cache-dir=DIR builds the cache in another directory, from scratch, which is what the cache warmer does during cache:clear. cache:warmup:aop --workers=N weaves in one process here: its worker processes would boot the kernel with a loader file, and the console of the application has none. Symfony's DebugClassLoader is lifted while these commands run in debug mode: it hides the Composer autoloader that the preload script and the file cache primer read.

Deployment

Warm up the cache

cache:clear and cache:warmup weave the application through an optional cache warmer. cache:clear builds the new cache next to the running one and renames it afterwards; the warmer builds the AOP cache in that new directory, from scratch, while the running kernel keeps its own, so the usual deployment steps need no change:

composer install --no-dev --optimize-autoloader   # runs cache:clear through the auto-scripts
# or explicitly
bin/console cache:clear
bin/console cache:warmup

The warm-up fails when a file cannot be woven, so a deployment stops instead of shipping an incomplete cache. Use cache:warmup:aop --fail-fast -vvv to see the file and the error.

Trust the cache in production

With features: [PREBUILT_CACHE] the runtime skips every cache-related file system check (directory probes, filemtime comparisons, aspect freshness) and never weaves nor writes to the cache directory: a class below the include paths that the cache does not know fails with a WeavingException ("... is missing from the prebuilt AOP cache ..."). Rebuild the cache on every deployment. This is the recommended production setting, and it is what makes read-only file systems and immutable images work:

when@prod:
    go_aop:
        features: [PREBUILT_CACHE]

The commands that build the cache (cache:clear, cache:warmup, cache:warmup:aop, debug:weaving) boot the application first, and cache:clear compiles the new container before the cache is woven: in their process a class the cache misses is loaded as it is instead of failing, and woven by the warm-up.

If your kernel.build_dir differs from kernel.cache_dir (read-only build directory, writable cache directory), anchor the AOP cache on the directory you build at deploy time: cache_dir: '%kernel.build_dir%/aop'.

Opcache preloading

Symfony's preload file loads the classes of your services through the autoloader, so on its own it would preload the original classes, and a preloaded class can not be woven anymore. In production mode the cache warmer of cache:clear and cache:warmup also writes the opcache.preload script of the AOP cache, _preload.php in the cache directory: it hands the woven class map to Composer, defines the constants the woven files use, and preloads every woven and untransformed class of the include paths. Require it in config/preload.php before the preload file of the container, which then preloads the woven classes of your services together with the classes of Symfony and vendor/:

// config/preload.php
if (is_file($aop = dirname(__DIR__).'/var/cache/prod/aop/_preload.php')) {
    require $aop;
}
if (is_file($container = dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
    require $container;
}

Each script is required on its own, so a missing file only switches off its own part of the preloading. Adapt the path of _preload.php when cache_dir is not %kernel.cache_dir%/aop. The script locates everything relative to its own directory, so it stays valid when cache:clear renames the new cache directory, and it lists the classes of exactly the cache it was built with: it is rebuilt with the cache. bin/console cache:warmup:aop --preload[=FILE] writes it on demand.

Do not boot the kernel in config/preload.php: booting creates services whose files the container preload file includes again, which fails with "Cannot redeclare class".

Doctrine ORM

When DoctrineBundle is registered (or doctrine: true is set), the bundle:

  • replaces the directory scan of every ORM attribute driver with Go\Bridge\Doctrine\WovenEntityClassLocator. Doctrine's own locator includes every entity file directly, which bypasses the class loader: an entity included that way loses its aspects for the rest of the request and, once loaded from the cache, disappears from orm:validate-schema and the migrations. The locator reads the class names from the sources and loads them through the class loader instead;
  • registers Go\Bridge\Doctrine\MetadataLoadInterceptor as a loadClassMetadata listener. An intercepted entity property (access(...) pointcut) is implemented with native property hooks, which the ORM supports with native lazy objects only; the listener reports such a configuration when the metadata loads instead of failing on the first lazy proxy.

Entities must lie below an include path to be woven, and the aspects apply to them whether they are created by your code or hydrated by the ORM.

Web profiler

With symfony/web-profiler-bundle installed, the toolbar shows the number of aspects, and the Go! AOP panel lists:

  • the aspects with their advisors, advice types and pointcut expressions;
  • the woven classes loaded during the request, with their proxy files;
  • the classes below the include paths that were loaded before the aspect kernel was initialized — the diagnostic for a bundle booting before GoAopBundle or for the request that rebuilt the container (the panel turns yellow);
  • the effective configuration of the kernel.

Extending

  • Custom transformers. Point kernel_class at a subclass of Go\Symfony\GoAopBundle\Kernel\AspectSymfonyKernel and register your SourceTransformer or NodeRewriter services in configureAop() (see the framework documentation).
  • Autowirable services. Go\Core\AspectKernel and Go\Core\AspectContainer are services, goaop.aspect_kernel is public. The aspect container is a PSR-11 container that falls back to the Symfony container for unknown ids.
  • Aspects without autoconfiguration. Tag the service with goaop.aspect:
    services:
        Acme\Audit\AuditAspect:
            tags: [goaop.aspect]
    An aspect class is a singleton: registering it as two services is a compile error.

Troubleshooting

  • "GoAopBundle must be the first bundle in config/bundles.php" — move its line above every other bundle (the internal component bundles of Symfony 8.1 are fine before it). Symfony Flex appends new bundles at the end, so this happens once right after composer require.
  • An aspect does not apply. Check debug:advisor --advisor=... first: it lists the joinpoints a pointcut matches. Then make sure the class lies below an include path and is not loaded before the bundle boots (profiler panel, classes loaded before weaving). Vendor code is only woven when its directory is an include path.
  • "Class ... is missing from the prebuilt AOP cache" — with PREBUILT_CACHE the cache must hold every class below the include paths: the deployment shipped code that the cache was not built for. Run bin/console cache:clear (or cache:warmup) as part of the deployment, after the code is in place.
  • Everything works in dev but not in prod. The production cache is built at deploy time: run cache:clear or cache:warmup, and keep PREBUILT_CACHE on only when the deployment rebuilds the cache. Check that nothing defines .container.dumper.inline_class_loader: true, and that config/preload.php requires the _preload.php of the AOP cache before the container preload file when preloading is enabled.
  • "Circular reference detected for the aspect ..." — an aspect is created by the container when one of its advices runs first, after its dependencies. An aspect can depend on a woven service, even on one it advises; but when creating one of its dependencies runs an advice of the same aspect (the aspect advises the constructor of a service it depends on, a method that this constructor calls, or code that another dependency runs while it is created), that advice would run on an aspect that does not exist yet. The message shows the path from the aspect to its advice. Inject the dependency lazily, so that the aspect can be created first:
    public function __construct(
        #[Autowire(lazy: true)] private readonly Wallet $wallet,  // or #[AutowireServiceClosure(Wallet::class)], a service locator
    ) {}
    or narrow the pointcut so that the aspect does not advise the creation of its own dependencies. The framework stops the advice before it runs, also an advice that reads no property of its aspect.
  • Exception handlers. Exclude the classes that must be loadable when everything else failed (error handlers, the kernel) through exclude_paths.
  • "Class ... was not found by locator" — a class below the include paths depends on code Composer can not locate, see below.

Classes extending generated code

The weaver reads a class from its source file without loading it, and it reads the parent classes, interfaces and traits of the class the same way: it asks Composer for their files. An ancestor that is already declared is reflected as it is, but usually the class is woven before its ancestors are loaded. Weaving a class therefore fails when one of its ancestors is:

  • generated at runtime and loaded by an autoloader of its own, like the base classes that admin generators and other code generators write into var/cache;
  • declared by class_alias() in a file that Composer maps to the alias name;
  • defined in a file Composer does not know about (included by hand, or loaded from a PHAR by another autoloader).

It fails as soon as the class part of a pointcut matches the class (App\Admin\*, App\**, ...), even when in the end no advice applies to any of its methods: the weaver reflects every method of the class, inherited ones included. Classes that no pointcut can match are loaded as they are. Unless the PARTIAL_REFLECTION feature is enabled (option 2 below), the class loader throws when it weaves such a class, in debug and in production mode, and the warm-up of cache:warmup and cache:clear fails with the same message for each file and a hint:

Go\Aop\Exception\WeavingException: Go\Instrument\Transformer\WeavingTransformer failed to transform
/app/src/Admin/ReportController.php: Class Acme\Generated\BaseReportController was not found by locator. The weaver
reads the parents, interfaces and traits of a class from their source files, which it finds through Composer: make
Acme\Generated\BaseReportController loadable by Composer (...), or exclude the classes that depend on it from weaving
with the excludePaths option. With the Features::PARTIAL_REFLECTION flag, a class whose parent class, interface or
trait can not be located is woven with its own members.

For a class_alias() file the message ends with Class Acme\OldName was not found in the /path/to/alias.php instead. The failure depends on what the process loaded before: the request (or command) that compiles the container loads the service classes and their ancestors first, so it often works there and fails on the next request, or only in a cache:warmup that finds the container already built.

To fix it, from the best option to the least intrusive one:

  1. Let Composer locate the ancestor, and the class is woven as any other. Register the directory of the generated code with Composer, through the autoload section of composer.json, or at runtime before the kernel boots:
    // src/Kernel.php
    public function boot(): void
    {
        foreach (\Composer\Autoload\ClassLoader::getRegisteredLoaders() as $loader) {
            $loader->addPsr4('Acme\\Generated\\', $this->getCacheDir() . '/generated/');
        }
        parent::boot();
    }
    The generated files must exist when the class is woven: generate them before the cache warm-up.
  2. Weave the class with what can be located, with the PARTIAL_REFLECTION feature:
    go_aop:
        features: [PARTIAL_REFLECTION]
    A class whose parent class, interface or trait can not be located is woven with its own methods and properties and the ones of the ancestors that can be; the members it inherits from a missing ancestor are not intercepted. The woven class still extends the missing parent class (and implements the missing interfaces), so they must be loadable when the class is loaded.
  3. Exclude the classes that depend on it from weaving. They are loaded as they are, without aspects:
    go_aop:
        exclude_paths: ['%kernel.project_dir%/src/Admin']
  4. Keep include_paths narrow: list the directories your aspects are meant for (src/Service, src/Domain, ...) rather than src or the whole project. Only the AOP cache directory and the framework with its runtime dependencies are excluded automatically: an include path that covers the rest of %kernel.cache_dir% or vendor/ weaves the code there too, generated code included.

Upgrading from 2.x

See UPGRADE-4.0.md: configuration keys, service ids, attributes instead of annotations.

License

MIT — see LICENSE.