Skip to content

feat(tempest): add ecotone/tempest framework integration package#673

Open
dgafka wants to merge 23 commits into
mainfrom
tempest-support
Open

feat(tempest): add ecotone/tempest framework integration package#673
dgafka wants to merge 23 commits into
mainfrom
tempest-support

Conversation

@dgafka
Copy link
Copy Markdown
Member

@dgafka dgafka commented Jun 6, 2026

Why is this change proposed?

Tempest is a modern PHP framework (v3.x, PHP 8.4+) gaining adoption, and Ecotone had no first-class integration for it. Developers using Tempest had no way to use CQRS, Event Sourcing, Sagas, or messaging patterns without hand-wiring everything from scratch. This PR adds ecotone/tempest — a full framework integration that makes Ecotone available in a Tempest app with zero config, the same way ecotone/laravel does for Laravel and ecotone/symfony-bundle for Symfony.

Description of Changes

New package: packages/Tempest (ecotone/tempest)

  • Auto-discovery bootstrapMessagingSystemInitializer (Tempest Initializer) and EcotoneServiceInitializer (Tempest DynamicInitializer) compile Ecotone lazily on first gateway request. All gateways (CommandBus, QueryBus, EventBus, custom business interfaces) are injectable from Tempest's container with no setup.
  • Zero-config handler discovery — When loadAppNamespaces: true (default), Ecotone auto-derives scan namespaces from Tempest's Composer->namespaces (the app's PSR-4 roots), mirroring Laravel's app/ catalog scan. An ecotone.config.php is optional, not required.
  • DBAL connection bridgeTempestConnectionReference wraps the app's Tempest DatabaseConfig (Postgres/MySQL/SQLite) as Ecotone's DbalConnectionFactory, enabling outbox, event sourcing, dead-letter queues, and DBAL business interfaces off the app's existing DB config.
  • Active-record aggregate repositoryTempestRepository lets Tempest IsDatabaseModel models be used directly as Ecotone aggregates (canHandle / findBy / save), with a recursive trait check for detection.
  • Multi-tenant DB switchingTempestTenantDatabaseSwitcher + TempestConnectionModule integrate HeaderBasedMultiTenantConnectionFactory so commands/queries route to the correct tenant database via a message header.
  • Console commandsEcotoneConsoleCommandDiscovery generates #[ConsoleCommand] proxy classes for every registered Ecotone command (ecotone:list, ecotone:run, ecotone:cache:clear, etc.) so they appear natively in ./tempest with no manual wiring.
  • Production hardening — hash-gated proxy regeneration, PSR-3 logger wiring via Tempest's DynamicInitializer, ecotone:cache:clear command, real-boot test proving zero-config works end-to-end.

Also: ModulePackageList::TEMPEST_PACKAGE added to Ecotone core (mirroring LARAVEL_PACKAGE/SYMFONY_PACKAGE, class_exists-guarded).


Usage

Install:

composer require ecotone/tempest

Zero-config — handlers in App\ are auto-discovered, gateways are injectable:

final class OrderController
{
    public function __construct(private CommandBus $commandBus) {}
}

Optional ecotone.config.php (service name, licence key, explicit namespaces):

// app/ecotone.config.php — auto-discovered by Tempest
return new EcotoneConfig(
    serviceName: 'order-service',
    licenceKey: env('ECOTONE_LICENCE_KEY'),
);

DBAL connection — wire the app's DB config as Ecotone's default connection:

#[ServiceContext]
public function databaseConnection(): TempestConnectionReference
{
    return TempestConnectionReference::defaultConnection();
}

Active-record aggregate — use a Tempest model as an Ecotone aggregate:

#[Aggregate]
final class Order
{
    use IsDatabaseModel;

    public PrimaryKey $id;
    public string $status;

    #[CommandHandler]
    public static function place(PlaceOrder $command): self
    {
        $order = new self();
        $order->status = 'placed';
        $order->save();
        return $order;
    }

    #[IdentifierMethod('id')]
    public function getId(): int { return $this->id->value; }
}

Multi-tenant routing — route commands to the correct tenant DB via header:

#[ServiceContext]
public function multiTenant(): MultiTenantConfiguration
{
    return MultiTenantConfiguration::create(
        tenantHeaderName: 'tenant',
        tenantToConnectionMapping: [
            'tenant_a' => TempestConnectionReference::create('tenant_a'),
            'tenant_b' => TempestConnectionReference::create('tenant_b'),
        ],
    );
}

$commandBus->send(new RegisterCustomer(1, 'Alice'), metadata: ['tenant' => 'tenant_a']);

Integration architecture

flowchart TD
    A[Tempest boot\nFrameworkKernel] -->|InitializerDiscovery| B[EcotoneServiceInitializer\nDynamicInitializer]
    A -->|DiscoveryDiscovery| C[EcotoneConsoleCommandDiscovery\nDiscovery]
    B -->|first get CommandBus| D[MessagingSystemInitializer\ncompile Ecotone]
    D -->|derives from Composer.namespaces| E[App PSR-4 roots\nApp handler scan]
    D -->|LazyInMemoryContainer| F[ConfiguredMessagingSystem]
    F -->|markCompiled| B
    B -->|DynamicInitializer| G[CommandBus / QueryBus\nEventBus / BusinessInterface\nresolvable from container]
    C -->|ConsoleCommandProxyGenerator| H[ConsoleCommand proxies\necotone:list / ecotone:run]
    D -->|TempestConnectionModule| I[TempestConnectionReference\nDbalConnectionFactory]
    D -->|TempestRepositoryBuilder| J[TempestRepository\nIsDatabaseModel as Aggregate]
Loading

Use-case scenarios

  1. CQRS + Event Sourcing in a Tempest app — Install ecotone/tempest + ecotone/pdo-event-sourcing, define #[EventSourcingAggregate] classes and projections, wire the DB via TempestConnectionReference. See the included quickstart (quickstart-examples/Tempest/Projection/DatabaseReadModel).

  2. Tempest active-record model as a DDD aggregate — Use IsDatabaseModel + #[Aggregate] so commands mutate the model through the Command Bus and TempestRepository handles persistence automatically. See quickstart-examples/Tempest/Model.

  3. Multi-tenant SaaS — Use MultiTenantConfiguration with TempestConnectionReference per tenant; a tenant message header routes every command/query to the correct database automatically (proven across Postgres + MySQL in tests).

Pull Request Contribution Terms

  • I have read and agree to the contribution terms outlined in CONTRIBUTING.

dgafka added 23 commits May 30, 2026 17:20
Scaffolds the packages/Tempest package and wires Ecotone into the
Tempest PHP framework via Tempest's Discovery and Initializer system
so that CommandBus/QueryBus/EventBus and all Ecotone gateways become
resolvable from the Tempest container with no manual registration.

Stage 1 components:
- EcotoneConfig: Tempest config object (ecotone.config.php) mirroring
  config/ecotone.php knobs; defaults from env()
- TempestConfigurationVariableService: ConfigurationVariableService over
  Tempest env()
- MessagingSystemInitializer: Tempest Singleton Initializer that compiles
  Ecotone's DefinitionHolder via LazyInMemoryContainer + TempestPsrContainerAdapter
  as the external container for user services
- EcotoneServiceInitializer: DynamicInitializer for CommandBus/QueryBus/EventBus
  and all compiled service IDs; triggers MessagingSystemInitializer on first resolution
- TempestPsrContainerAdapter: PSR-11 wrapper over Tempest's container
- EcotoneIntegrationTest: custom test base that boots FrameworkKernel with
  explicit discovery locations, bypassing problematic monorepo paths
- Root composer.json: adds Ecotone\Tempest\ autoload, Test\Ecotone\Tempest\
  autoload-dev, ecotone/tempest in replace map, tempest/framework in require-dev,
  phpstan updated to ^1.8|^2.0 for Tempest compatibility

Stage 1 tests (all green):
- TempestApplicationTest: CommandBus resolves, command→handler flow, expression language
- TempestConfigurationVariableServiceTest: env reads/has

Stages 2–4 (DBAL, aggregate repository, multi-tenant) are NOT started.
… core gateways

EcotoneServiceInitializer::canInitialize() previously matched only the three
core gateways (CommandBus/QueryBus/EventBus) until the messaging system was
first built. A custom business interface (#[BusinessMethod] gateway interface)
resolved as the very first Ecotone touch from Tempest's container was missed
and caused DependencyCouldNotBeInstantiated.

Fix: when $compiledServiceIds is null and EcotoneConfig is already registered
in the container (i.e., after test setup / app boot), eagerly trigger
MessagingSystemInitializer via GenericContainer::instance()->get(ConfiguredMessagingSystem)
using a $compiling guard to prevent re-entrancy. Once compiled, the full set of
service IDs is used to answer canInitialize() order-independently.

Uses GenericContainer::instance() (static accessor) instead of injecting
Container in the constructor to avoid the circular-dependency chain that Tempest
detects when DynamicInitializer is autowired during boot.
Implements spec item 7 (Stage 1, Gap 2): Ecotone registered commands are now
exposed to Tempest's console system so `./tempest` can list and run them.

## Components

**ConsoleCommandProxyGenerator** — takes ConsoleCommandConfiguration[] and
writes one PHP proxy class per command to a directory. Each proxy carries
#[ConsoleCommand(name: '<ecotone name>', allowDynamicArguments: true)] on its
__invoke() method, resolves ConfiguredMessagingSystem and ConsoleArgumentBag via
#[Inject], and forwards all console arguments to ConsoleCommandRunner::execute().

**EcotoneConsoleCommandDiscovery implements Discovery** — discovered automatically
by Tempest's DiscoveryDiscovery since it is on the Ecotone\Tempest\ discovery
location. Its apply() hook: (a) guards on EcotoneConfig being present (skips
early if kernel not yet configured); (b) triggers MessagingSystemInitializer
directly to compile the definition holder (avoids container initializer ordering
issue where InitializerDiscovery may not have run yet); (c) generates proxy files
to sys_get_temp_dir()/ecotone_tempest_console_proxies/; (d) requires them;
(e) registers each proxy's __invoke method with ConsoleConfig::addCommand() via
MethodReflector, making the commands available to Tempest's console application.

**MessagingSystemInitializer** — now stores the ContainerDefinitionsHolder in a
static property (getDefinitionHolder/clearDefinitionHolder) so EcotoneConsoleCommandDiscovery
can access the registered command configs after compilation.

## Lifecycle hook decision

Tempest's Discovery.apply() hook is chosen over KernelEvent::BOOTED because:
- apply() runs during bootDiscovery() BEFORE the BOOTED event fires
- Commands need to be in ConsoleConfig before the console application starts
- The Discovery interface is auto-discovered with no additional dependencies
- BOOTED would be too late if ConsoleApplication queries commands before BOOTED

Limitation: proxy files live in sys_get_temp_dir() across requests; they are
regenerated each boot (no content hash check). A future improvement could cache
using the Ecotone config hash, but the current approach is correct and tested.

## EcotoneIntegrationTest change

EcotoneConfig is now registered in the container BEFORE bootDiscovery() (moved
from after the full kernel boot) so that EcotoneConsoleCommandDiscovery::apply()
can check container->has(EcotoneConfig::class) and trigger compilation.
ConsoleCommandProxyGenerator.generate() now accepts an optional
configHash; when provided it writes a .ecotone_hash marker file in the
output directory and skips all file writing on subsequent calls when the
hash is unchanged. MessagingSystemInitializer stores and exposes the
computed cacheHash (from getCacheMessagingFileNameBasedOnConfig) via
getConfigHash(), and EcotoneConsoleCommandDiscovery passes that hash to
the generator so proxy files are only rewritten when the Ecotone config
changes. ConsoleConfig registration still happens on every boot.
Add a behavioral test asserting that the Ecotone-generated console proxy
class is resolvable from the Tempest container and its __invoke() returns
ExitCode::SUCCESS by forwarding execution through the real
ConsoleCommandRunner gateway. Complements the registration test with
end-to-end execution coverage for the proxy forwarding path.
Add a test proving that two sequential kernel boots in a single PHP
process produce independent messaging systems: after clearing static
state (EcotoneServiceInitializer::clearCache,
MessagingSystemInitializer::clearDefinitionHolder) and calling
setupKernel() again, the second boot builds a fresh LazyInMemoryContainer
and sees no state from the first boot's command handlers.

Assessment (Item 3): the static fields in MessagingSystemInitializer
($definitionHolder, $configHash) and EcotoneServiceInitializer
($compiledServiceIds) are per-process memoizations that are correct for
production (one kernel per process). Test isolation is fully covered by
the clearCache/clearDefinitionHolder calls in EcotoneIntegrationTest
tearDown. No correctness bug exists, so no structural change is made.
The only optimization gap noted: when the production cache is warm,
$configHash is set to null, so proxy files are rewritten on every
process start rather than being gated by hash — this is safe but
suboptimal; addressing it is out of scope for Stage 1 hardening.
…d ecotone:run

- Update ConsoleCommandProxyGenerator to inject Console and print
  ConsoleCommandResultSet as column-separated lines via writeln, mirroring
  the Symfony/Laravel integration behaviour
- Add proxy-cache teardown to EcotoneIntegrationTest so each test
  process starts with a fresh generated-proxy directory
- Add test_ecotone_list_command_runs_through_tempest_console_runner_by_name_and_prints_column_headers
  and test_generator_produces_proxy_code_that_prints_console_command_result_set
  to ConsoleCommandProxyTest
- Add ConsoleCommandEndToEndTest with async-queue fixture
  (AsyncQueueChannelConfiguration + NotifyCommandHandler) to cover
  ecotone:list showing the registered consumer name and ecotone:run
  running that consumer to completion via finishWhenNoMessages
Adds TempestConnectionReference, TempestConnectionResolver, and TempestConnectionModule
so Ecotone DBAL features can run off Tempest's DatabaseConfig. Ports the
LaravelConnectionReference/LaravelConnectionResolver/PDO-driver pattern onto Tempest:
builds a PDO from the Tempest DatabaseConfig (dsn/username/password/options), wraps it
in a per-dialect Doctrine DBAL Driver+Connection, and registers an
AlreadyConnectedDbalConnectionFactory under the configured reference name.

Registers TEMPEST_PACKAGE in ModulePackageList/ModuleClassList so the module is
discovered via the same class-filtered package mechanism as the Laravel/Symfony modules.

Three Stage 2 tests (WithConnection positive, WithoutConnection negative,
Connectivity live-query against docker postgres) all pass alongside the 17 Stage 1 tests.
… Stage 3

Implements the aggregate repository contract for Tempest active-record
models (IsDatabaseModel), mirroring the Laravel EloquentRepository
pattern. Wires TempestRepositoryBuilder into MessagingSystemInitializer
so any class using IsDatabaseModel is automatically handled as an
Ecotone aggregate repository.
…stgres schema setup

Adds TempestRepositoryIntegrationTest that places an order through the
command bus (creating and persisting a Tempest IsDatabaseModel aggregate),
asserts it is findable via findById, then round-trips a cancel+query
cycle through the messaging system. Includes the Order fixture class
and PlaceOrder command. Schema is created/dropped around each test using
Tempest's Database interface.
…tiTenantConnectionFactory

- Extend TempestConnectionModule to wire MultiTenantConfiguration: registers
  per-tenant TempestConnectionReference connection factories and the
  TempestTenantDatabaseSwitcher service with handlers on the Ecotone activate/
  deactivate tenant channels. Guard on class_exists(HeaderBasedMultiTenantConnectionFactory).
  canHandle() now also accepts MultiTenantConfiguration.

- TempestTenantDatabaseSwitcher: on switchOn, re-registers the tenant DatabaseConfig
  as the Tempest default and invalidates the Connection/Database singletons so the next
  resolution rebuilds from the tenant config. switchOff restores the original default.
  Uses GenericContainer::instance() to avoid circular autowiring.

- TempestConnectionReference: carries an optional DatabaseConfig (for multi-tenant
  per-tenant configs). getDefinition() serialises the config via base64+serialize so it
  survives Ecotone definition caching. Single-connection path unchanged (falls back to
  container-resolved DatabaseConfig::class). Adds clearRegistry() stub for test compat.

- TempestConnectionResolver: takes only TempestConnectionReference (one arg). Reads
  DatabaseConfig from the reference (inline or via GenericContainer::instance() fallback).

- Test fixtures: RegisterCustomer, Customer, CustomerInterface (#[DbalWrite]),
  CustomerRepository (#[DbalQuery] FIRST_COLUMN), CustomerService (command +
  query handlers with #[Reference] injection), MultiTenantEcotoneConfiguration
  (#[ServiceContext] returning MultiTenantConfiguration mapping tenant_a→postgres
  and tenant_b→mysql).

- MultiTenantTest: two green tests — test_run_message_handlers_for_multi_tenant_connection
  (ORM-side DBAL isolation via CustomerInterface) and
  test_using_dbal_based_business_interfaces (DBAL business interface write path).

  Skipped (queues excluded from scope): test_sending_events_using_laravel_db_queue,
  test_transactions_rollbacks_model_changes_and_published_events,
  test_optimize_clear_triggers_ecotone_cache_clear_via_event.

All 26 tests green (24 prior + 2 new Stage 4).
…es from Tempest Composer PSR-4 roots

When EcotoneConfig::loadAppNamespaces=true (default) and no explicit
namespaces are set, MessagingSystemInitializer now resolves the Tempest
Composer singleton from the container and uses its app PSR-4 namespace
prefixes for Ecotone handler discovery. This means a fresh Tempest app
with an App\ root and no ecotone.config.php discovers its handlers
automatically.

Adds AppNamespaceAutoDiscoveryTest proving zero-config discovery works.
Registers App\Tempest\ in the monorepo autoload-dev for the test fixture.
…nd set 'logger' key

LoggerInterface is provided by Tempest's DynamicInitializer so
container->has() always returns false; use get() in a try/catch instead.
Also set the logger under both 'logger' (the key Ecotone's LoggingService
resolves at runtime) and LoggerInterface::class so log messages from
command/query/event handlers flow through the Tempest logger.

Adds LoggerWiringTest proving Ecotone query handler logs appear in the
configured Tempest log channel.
…oots skip proxy regeneration

On cold boot the computed config hash is now written to
messaging_system_hash alongside messaging_system. On the warm-cache
fast-path the persisted hash is read back so getConfigHash() returns
a stable non-null value, enabling ConsoleCommandProxyGenerator to
correctly skip proxy file rewrites.

Also moves console proxy output directory under the Ecotone cache dir
(ecotone_tempest/console_proxies) instead of sys_get_temp_dir() root.

Adds ProdCacheHashTest proving warm-cache boot returns the same hash as
the cold boot.
…tone cache directory

Mirrors Laravel's optimize:clear and Symfony's CacheClearer. The command
removes the full ecotone_tempest directory including the compiled
messaging_system cache and generated console proxy files.

Adds CacheClearCommandTest covering both the messaging-system cache file
and the console proxy directory removal.
…ation from app PSR-4

Item A: boots FrameworkKernel with tests/app as root so loadComposer()
loads tests/app/composer.json (App\Tempest\ => src/), exercises the
deriveNamespacesFromComposer path in MessagingSystemInitializer without
explicit EcotoneConfig::namespaces. Handler resolved via derived namespace.

Monorepo-only shim: injectDiscoveryConfig uses AutoloadDiscoveryLocations
from /data/app root (not tests/app) to get vendor package discovery
locations — a real install would have a full vendor/ in the app root.
…cence key, and prod-cache

Item D: parameter() function with hardcoded and env-variable-backed values
via TempestConfigurationVariableService; licence key boot test; warm-cache
handler execution parity test (Laravel/Symfony-mirrored coverage).

Error-channel test skipped: requires async queue infrastructure and Enterprise
licence key — not feasible synchronously without the queue transport.
Logger-during-execution already covered by LoggerWiringTest (committed earlier).
…ence credential guard tests

Item C1: add suggest block to composer.json describing ecotone/dbal and
enqueue/dbal as optional deps enabling DBAL/multi-tenant features.

Item C2: add ConnectionReferenceCredentialsTest as a guard capturing the
current credential serialization behavior. Decision: kept existing
base64(serialize(DatabaseConfig)) approach since the prior attempt to
change it broke per-tenant connection routing. Credentials are written
to the on-disk cache when cacheConfiguration=true; documented in README
with a security note. MultiTenantTest stays green (verified).
…tempest docs

Item B: covers composer require, zero-config handler auto-discovery from
PSR-4 roots, EcotoneConfig reference table, console commands, DBAL
single/multi-tenant setup via #[ServiceContext], production caching,
expression language parameter() function, and a security note about
DatabaseConfig credentials in the on-disk cache.
…o-config canInitialize)

EcotoneServiceInitializer::canInitialize() was guarded by has(EcotoneConfig::class),
so in a zero-config app (no ecotone.config.php discovered) every gateway get() failed
with "not an instantiable class". MessagingSystemInitializer already has a correct
fallback (resolveEcotoneConfig returns new EcotoneConfig()), so the guard is wrong.

Remove the has(EcotoneConfig) guard from EcotoneServiceInitializer so compile is
triggered unconditionally on first gateway request. Keep the guard in
EcotoneConsoleCommandDiscovery::apply() (which runs at discovery time, before handlers
are scanned) so it does not eagerly compile all modules during boot when no config exists.

Add a real-boot test: CommandBus resolves and handles a command without any prior
ConfiguredMessagingSystem touch, proving EcotoneServiceInitializer triggers the compile.
…oll back Tempest model writes

TempestConnectionResolver was creating a new PDO connection independent of Tempest's
own singleton PDOConnection. This meant Ecotone's DbalTransactionInterceptor opened a
transaction on one connection while IsDatabaseModel::save() wrote on another — so
rollback on exception did not undo model inserts.

Fix: when no per-tenant DatabaseConfig is serialized in the reference (the default
single-connection path), resolve Tempest's singleton Connection from the container,
extract its PDO via reflection, and pass it to Doctrine's Connection. Ecotone and
Tempest's ORM now share one PDO resource, so transactions wrap both.

Add SharedConnectionTransactionTest: command handler inserts via IsDatabaseModel,
throws RuntimeException, asserts the row count is 0 (rollback succeeded).
…onfig (no serialized credentials)

TempestConnectionReference::create('tag') now stores only the tag name. At runtime
TempestConnectionResolver resolves the DatabaseConfig from Tempest's container by tag
(no credentials serialized into Ecotone's compiled cache). Users register configs in
standard Tempest *.config.php files:

  return new PostgresConfig(host: '...', tag: 'tenant_a');

  // In #[ServiceContext]:
  TempestConnectionReference::create('tenant_a')

Per-tenant Connection singletons are registered in Tempest's container on first use
so they are shared across Ecotone and Tempest ORM within the same message.

TempestConnectionReference::defaultConnection() uses TempestDynamicDriver /
TempestDynamicDriverConnection — a Doctrine Driver that re-resolves Tempest's default
Connection singleton on every DBAL call. Combined with TempestTenantDatabaseSwitcher
closing the Doctrine Connection on tenant switch, the DbalTransactionInterceptor follows
tenant connection promotions transparently.

TempestTenantDatabaseSwitcher::switchOn now resolves the tagged DatabaseConfig from the
container (instead of the previously embedded/deserialized config) and ensures the tagged
Connection singleton exists before promoting it as the default.

ConnectionReferenceCredentialsTest updated to reflect the new tag-based API.
MultiTenantTest::setUp now registers tagged DatabaseConfig objects in the container.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant