Gacela
Documentation version: 2.2.0
Colour theme

Docs Integrations

On this page

Framework integration

Gacela runs beside Laravel or Symfony rather than replacing them. The host framework owns HTTP, console, and lifecycle integration; Gacela owns module boundaries. Bridge only the services that cross between those responsibilities.

Both bridges ship inside the framework package, so there is nothing extra to require: composer require gacela-project/gacela brings them along. They are versioned with the framework and marked experimental, so their API may still change between minors.

Symfony: the GacelaBundle Since 2.2

config/bundles.phpphp
return [
    Gacela\SymfonyBridge\GacelaBundle::class => ['all' => true],
];

That alone gives you four things:

  1. Gacela bootstrapped from the kernel, with the project dir as the application root, honouring gacela.php. Every boot bootstraps again, so a kernel rebooted inside one process (functional tests do it constantly) runs on its own configuration rather than the previous boot's.
  2. Symfony services reachable from Gacela: the ones you list, and only those.
  3. Gacela's console commands in bin/console, under a gacela: prefix.
  4. cache:warmup warms Gacela's caches too, so a deploy has one warmup step instead of two.

Plus the #[Inject] compiler pass, described below.

config/packages/gacela.yamlyaml
gacela:
    app_root_dir: '%kernel.project_dir%'   # where gacela.php lives
    cache_dir: '%kernel.cache_dir%/gacela'
    file_cache: true
    project_namespaces: ['App']
    external_services:
        logger: 'monolog.logger'
        entity_manager: 'doctrine.orm.entity_manager'
    register_commands: true
    command_prefix: 'gacela:'

Every key is validated at compile time: a mistyped one fails the build instead of quietly configuring nothing. cache_dir and file_cache left unset leave Gacela's own defaults in place.

External services

external_services maps a key to a Symfony service id. What the key is decides how far the service travels:

gacela:
    external_services:
        Psr\Log\LoggerInterface: 'monolog.logger'   # a type: also bound
        report_mailer: 'app.mailer'                 # a plain key: external service only

A key that names a class or interface additionally becomes a Gacela binding, so it resolves on its own: through Gacela::get(), through autowiring, through #[Inject].

A key that names no type stays an external service, which is what your own gacela.php reads when it declares bindings, because a binding maps a type to an implementation and report_mailer is not one:

gacela.phpphp
$config->addBinding(MailerInterface::class, $config->getExternalService('report_mailer'));

Either way the service is fetched through a service locator when Gacela asks for it, so listing one does not construct it: booting the kernel stays as cheap as it was.

Commands

The prefix is not decoration: Symfony's MakerBundle owns the whole make:* namespace, so an unprefixed make:module would collide with it.

bin/console gacela:make:module App/Blog
bin/console gacela:doctor
bin/console list gacela

Set register_commands: false to leave bin/console alone and keep using vendor/bin/gacela.

The #[Inject] compiler pass

Symfony autowires constructor parameters through its own container, and Gacela's #[Inject] attribute is recognised by Gacela's container only. On a class managed by Symfony, most often a Command, writing #[Inject] therefore had no effect: Symfony's autowire claimed the parameter first.

GacelaInjectCompilerPass walks every service definition at compile time, looks at each constructor parameter for #[Inject], and rewrites the argument so Symfony resolves that slot through Gacela's container instead. If both containers claim the same parameter, the build fails naming the service and parameter.

The bundle registers the pass for you. To use it without the bundle:

use Gacela\SymfonyBridge\GacelaInjectCompilerPass;

$container->addCompilerPass(new GacelaInjectCompilerPass());
$container->set('gacela.container', Gacela::container());

The Gacela container must be registered as a Symfony service named gacela.container so the rewritten arguments can resolve through it at runtime.

Laravel: the GacelaServiceProvider Since 2.2

bootstrap/providers.phpphp
return [
    Gacela\LaravelBridge\GacelaServiceProvider::class,
];

The same four things, against Laravel's lifecycle:

  1. Gacela bootstrapped when the application boots, with base_path() as the application root, honouring gacela.php. Every boot bootstraps again, so an application rebooted inside one process runs on its own configuration. Note that Octane boots each worker once and reuses it: a request-scoped Laravel service listed in external_services stays whatever the worker's first boot captured.
  2. Laravel services reachable from Gacela: the ones you list, and only those.
  3. Gacela's console commands in artisan, under a gacela: prefix.
  4. artisan optimize warms Gacela's caches too, so a deploy has one optimize step instead of two. optimize:clear clears them again.
php artisan vendor:publish --tag=gacela-config
config/gacela.phpphp
return [
    'enabled' => true,
    'app_root_dir' => null,          // where gacela.php lives; null means base_path()
    'cache_dir' => null,             // null leaves Gacela's own default in place
    'file_cache' => null,            // null leaves Gacela's own default in place
    'project_namespaces' => ['App'],
    'external_services' => [
        'logger' => 'log',
        Psr\Log\LoggerInterface::class => 'log',
    ],
    'register_commands' => true,
    'command_prefix' => 'gacela:',
];

Every key is validated when the provider boots, naming a mistyped one instead of quietly configuring nothing. Laravel has no compile step, so boot is the earliest the check can run. External services follow the same rule as the Symfony bundle: a key that names a type is also bound, a plain key stays an external service, and either way the service is fetched from Laravel's container lazily. Commands carry the gacela: prefix because artisan owns make:*.

#[Inject] on Laravel-resolved services

Laravel autowires constructor parameters through its own container, so Gacela's #[Inject] used to have no effect on a class managed by Laravel: a controller, a job, a command. The bridge closes that gap twice over.

On a constructor parameter, use the bridge's attribute with an explicit class. It implements Laravel's ContextualAttribute contract, so Laravel itself resolves the parameter through Gacela, and because it extends the Gacela attribute, Gacela honours it too on the classes it builds. One attribute, both containers:

use Gacela\LaravelBridge\Attribute\Inject;

public function __construct(
    #[Inject(ProductFacade::class)] private ProductFacade $facade,
) {
}

The class is required there: Laravel hands a contextual attribute no parameter to read a type from. Leaving it off fails with directions, not with a silently autowired substitute.

On a property or a setter, the bare form works, since the type is on the member. The provider listens to afterResolving and injects into every instance Laravel builds, honouring the attribute under either namespace:

use Gacela\Container\Attribute\Inject;

final class SyncStock implements ShouldQueue
{
    #[Inject]
    private ProductFacade $facade;
}

A readonly property is refused by name, since it cannot be written after construction, and so is a static or non-public setter.

Bootstrapping by hand

The bridges are convenience, not a requirement. Bootstrapping directly from your entry point keeps working and stays the right call when you want full control over the boundary:

Bind a host service explicitly so Gacela modules share the same instance. Symfony's Doctrine EntityManager is the classic case, sharing one connection and one transaction scope:

<?php # public/index.php

// ...
$kernel = new \App\Kernel($_SERVER['APP_ENV']);

Gacela::bootstrap($appRootDir, function (GacelaConfig $config) use ($kernel) {
    $config->addBinding(ProductRepositoryInterface::class, ProductRepository::class);

    $config->addBinding(
        EntityManagerInterface::class,
        static fn () => $kernel->getContainer()->get('doctrine.orm.entity_manager'),
    );
});
// ...

Modules that type-hint EntityManagerInterface now receive Symfony's managed instance. Symfony remains responsible for its lifecycle and configuration.

Example projects

Cloneable minimal integrations: