Skip to content

2.0.0

Latest

Choose a tag to compare

@Chemaclass Chemaclass released this 07 Aug 06:45
· 5 commits to main since this release
2.0.0
3e4265c

A foundation major. It is also the "one container" release, after all.

The runtime change is two lines. PHP >=8.3, and gacela-project/container
^2.0.2 up from a 0.x. Most of what follows comes from the second one:
#[Lazy], #[Inject] on properties, PSR-11-correct has(), and container
exceptions where 0.x emitted raw PHP errors.

#539 was sequenced into 2.1. It landed here once it could ship alone. Module
containers are scopes of one app container now, so gacela.php is walked once
per bootstrap instead of once per Factory class. 79 containers against 300
app-wide entries go 18.0ms → 0.07ms.

The three perf spikes on the roadmap measured sub-millisecond, so they were
closed rather than shipped. Writing compiled plans to disk measured a net
loss
: 1.008ms to load a 300-class plans file, against 0.233ms saved.

Migration is three mechanical renames. See UPGRADE.md. Run
vendor/bin/gacela doctor on 1.21 first, because one of the three fails
silently.

Added

  • GacelaConfig::loadDefinitions() registers a definition set from an array,
    or from a .php/.json file. Use it for wiring that is generated, shared
    between environments, or reviewed as a diff:
    $config->loadDefinitions([
        LoggerInterface::class => FileLogger::class,
        Database::class => ['singleton' => DatabasePool::class],
        'db.dsn' => ['value' => 'pgsql://localhost/app'],
    ]);
    $config->loadDefinitions(__DIR__ . '/config/services.json');
    App-wide, like addBinding(). Container::load()/loadFile() are forwarded,
    so a Provider can scope definitions to one module. Sources apply in order, and
    after the imperative registrations, so a file overrides addBinding().
    Tags accumulate instead. Paths are used as given, so write them with
    __DIR__. No YAML: pass Yaml::parseFile(...).
  • GacelaConfig::afterResolving() runs a callback on a resolved instance:
    $config->afterResolving(
        LoggerAwareInterface::class,
        static fn (LoggerAwareInterface $s) => $s->setLogger($logger),
    );
    The id may name an interface, so one registration covers every
    implementation. It fires on get(), getOrFail() and make(), in
    registration order — once per resolution, not once per instance, so a
    shared instance fetched three times runs the callback three times on the same
    object. Write callbacks that are safe to repeat; the setLogger() above is,
    and appending to a collection or bumping a counter is not. It costs nothing
    when unused. It does not fire for a nested constructor dependency. A callback
    that throws evicts the instance.
  • GacelaConfig::tag() groups services under a label, reaching every
    module's container:
    $config->tag([NotEmptyValidator::class, EmailValidator::class], 'validators');
    A module adding to a tag in its own Provider stays local to it. Use tag()
    for an unkeyed set you iterate. Use addHandlerRegistry() for a keyed lookup
    that throws on a miss.
  • The dependency tree is an actual tree. It comes from the container, so
    bindings and contextual bindings are already applied:
    ├── ✓ $cacheWarmService: …\CacheWarmService (autowired)
    └── ✓ $formatter: …\CacheWarmOutputFormatter (autowired)
        └── ✗ $output: Symfony\…\OutputInterface (unresolvable)
    
    A missing dependency now says whose it is. A cycle is marked (cycle) and
    cut. Counts are of distinct classes, so one pulled in by three parents counts
    once and is drawn three times. debug:dependencies --tree, debug:module and
    debug:container all draw this same tree. The latter two printed a flat list
    under a heading that said "tree".
  • A Psalm plugin types the pillar accessors from #[ServiceMap]. It is the
    counterpart of the PHPStan extension. Before, psalm-gacela.xml only
    suppressed the error, so the accessor evaluated to mixed:
    <plugins>
        <pluginClass class="Gacela\Psalm\Plugin"/>
    </plugins>
  • One constructor-plan cache for every container. Gacela's containers are
    sibling roots configured from the same gacela.php, so each used to reflect
    the same classes again. Ten containers resolving one four-level chain drop
    ~36%, measured on CI at 90.3μs → 58.0μs. Only reflection output is shared.
    Bindings, aliases, tags, singletons and stored instances stay private to each
    container. Pass your own PlanCache as the container's fourth argument to opt
    one out.
  • Gacela\Framework\Attribute\Inject. Application code no longer imports a
    vendor namespace for the one attribute-first surface that required it. It
    subclasses the container's attribute. That works because attributes are read
    with ReflectionAttribute::IS_INSTANCEOF, so both imports work side by side.
    Not a class_alias(), as first planned: an exact-FQN read follows neither an
    alias nor a subclass, and the failure is silent.
  • #[Inject] targets properties and setters, not only constructor
    parameters. That covers classes whose constructor is not yours to change.
  • #[Lazy] joins #[Inject], #[Singleton] and #[Factory] as an attribute
    honoured by AbstractFactory::make().
  • cache:clear also clears the container's in-process memos. Those are
    reflection output, held in statics that outlive every container, and no file
    holds them.
  • Container::provides(), taggedByKey() and taggedKeys() are forwarded. So
    are lazy(), writeCompiledCache(), writeCompiledFactories(),
    useCompiledFactories() and compileReport(), and writeCompiledCache()
    gained a build-stamp argument. Nothing here calls the compilation methods,
    since writing plans to disk measured a net loss. They are reachable for an
    application that has measured its own case.

Changed

  • Module containers are scopes of one app container. AbstractFactory built
    one container per Factory class through Container::withConfig(). That walks
    the whole of gacela.php: every binding, factory, alias, contextual binding,
    tag and hook. It did it again for each one. 79 walks in this repository alone.
    The cost grows with an application's wiring, not with its number of modules.
    Each module now gets a scope of one shared app container, carrying only
    its own Provider.

    Module isolation is unchanged. Registration is not copied, and a miss falls
    through, so a module still cannot see a sibling's provider keys. That is what
    keeps two providers using the same un-namespaced key from colliding. Two
    modules resolving the same app-wide binding still get one instance each.

    extendService() is app-wide configuration. It often decorates a service that
    a module's Provider registers, into that module's scope, where a
    parent-held extension cannot reach it. Each scope now schedules those itself,
    skipping ids the parent owns.

    The saving is on container construction. 79 containers against 25 app-wide
    entries go 1.55ms → 0.06ms. Against 300 entries, 18.0ms → 0.07ms. The scope
    column is flat. An application with little configuration sees none of that,
    and pays ~2.5% on warm class resolution for the fall-through.

  • gacela-project/container ^2.0.2 (was ^0.10.0). Container is
    final, so Gacela decorates it by composition. Two of its fixes land in
    Gacela's own path. A class-string sharing a name with a function was invoked
    instead of instantiated. And has() remembered a negative, so a class
    declared after the first probe stayed invisible.

    2.0 puts the whole surface on ContainerInterface. An unforwarded method is
    now a compile error, not something silently unreachable. Two of its additions
    replace code here outright:

    • withSelfReference() removes the closure-wrapping layer. Every user
      closure used to be re-wrapped, so it received the decorator instead of the
      inner container. factory() and protect() mark closures by identity, so
      each wrapper needed tracking in a WeakMap. That is roughly 50 lines and 29
      touchpoints. The WeakMap goes with it, and so does the leak it was fixed
      for.
    • createScope() is forwarded. That was impossible while a scope could
      not be decorated, because the raw child would have handed its closures the
      inner container. A scope is now a decorator like its parent, so it keeps the
      Locator and the lifecycle events.

    load()/loadFile() return the ids they registered, and take an optional
    per-id callback. That closes a gap loadDefinitions() shipped with:
    definitions now emit BindingRegisteredEvent like every other registration.
    Naming them used to mean reconstructing the ids, and missing the aliases.

    One caveat, reported rather than absorbed quietly, and since fixed. Container
    2.0.0 resolved 11-28% slower cold than 1.5.0
    (container#181). The
    per-class argument builder was composed on a class's first resolution, so a
    container that builds a class once paid for a shortcut it never takes.
    Container 2.0.1 defers that composition to the second construction. The four
    benchmarks that show it now measure +2.1 to +3.5% against 1.5.0, over 20
    paired samples, and they gate again. Gacela's own benchmarks never moved: all
    stayed within ±8%, and bootstrap came out ~3% faster.

  • ContainerStats::memoryUsageFormatted() is processMemoryFormatted().
    debug:container labels it Process Memory, which is what it always
    measured.

  • ConsoleFacade::getContainerStats() and ConsoleFactory::getContainerStats()
    return ContainerStats
    instead of an array.

  • PHP floor raised to >=8.3 (was >=8.1). 8.1 is end of life, and 8.2's
    security window closes in December 2026.

  • symfony/* widened to ^7.0 || ^8.0 (was ^6.4). Gacela no longer
    decides a consumer's Symfony major.

  • The PHPStan suppression for undeclared pillar accessors is gone. An
    accessor you have not declared is reported, not silently typed mixed.

  • Class constants on AbstractSetupGacela and ConfigInterface declare types,
    so a subclass redeclaring one is checked at compile time.

Fixed

  • symfony-bridge was outside every static analysis tool and the coverage
    gate.
    It ships from this repo as its own package, and phpstan, psalm,
    php-cs-fixer, rector and phpunit's <source> all scoped to src/ and
    tests/, so only its 7 tests ran and nothing ever looked at the file. That is
    how the #[Inject] subclass bug reached mainGacelaInjectCompilerPass
    read the attribute by exact FQN, silently dropping every
    Gacela\Framework\Attribute\Inject — and how the disjoint
    gacela-project/container: ^1.4.0 constraint survived. Both are now under all
    five tools plus coverage and the mutation gate. The first run put the bridge at
    87.8% MSI, under the 90 floor; the five escaped mutants are killed by tests
    covering behaviour nobody asserted — that the generated argument definition is
    private, that skipping an abstract definition does not stop the scan, and that
    a builtin-typed #[Inject] parameter is left for Symfony. The bridge is at
    100% MSI now.
  • doctor's filename check could not see the mismatch it exists to catch.
    FilenameMismatchCheck drove off resolved pillar classes, and a class whose
    file basename does not match its short name does not autoload under PSR-4. So
    the pillar came back null, pillarsOf() filtered it out, and doctor
    reported "every pillar class matches its filename" and exited 0 on exactly the
    module whose provider silently never runs. It only had teeth under classmap
    autoloading. It now also reads the module directory — located from the facade,
    which is what made the module discoverable — and compares each file's declared
    class against its basename with a token scan, so the mismatch is found
    precisely when the class does not resolve. Both rename directions are covered.
  • Gacela::resetCache() flushed a #[Cacheable] backend the application
    registered.
    It reached CacheableTrait::clearMethodCache() transitively
    through AbstractFacade::resetCache(), and that calls clear() on whatever
    storage is configured. CacheableConfig::setStorage() is the documented way
    to wire APCu or Redis, and docs/caching.md recommends it — so on any
    application that followed the advice, resetting Gacela's caches emptied the
    whole store. It bit hardest in test suites: GacelaTestCase::bootstrapGacela()
    resets unconditionally, so a suite pointed at a real Redis DB cleared it once
    per test. resetCache() now clears only the framework's own in-memory default
    and leaves a registered backend alone. Calling clearMethodCache() yourself
    is unchanged and still clears everything, as its docs say.
  • A second Gacela::bootstrap() served the first one's config.
    ConfigFactory memoized the merged config in a private static and returned
    it before looking at the setup it was constructed with, so re-bootstrapping in
    one process silently kept the first bootstrap's merged config, bindings
    included. The only escape was resetInMemoryCache(), which
    docs/production-performance.md tells you not to use in production — while
    UPGRADE.md names long-running workers that re-bootstrap (RoadRunner, Swoole,
    queue consumers) as a target scenario. The memo is now keyed on the app root
    and the setup instance it was built from, so a different config rebuilds and an
    identical one still hits the memo. Present since 1.0.1.
  • The container retained every closure it was ever handed. The mark that
    stops a wrapper being wrapped twice held its keys strongly, and was never
    cleaned. So set(), bind(), extend(), factory() and protect() leaked
    their closures, and everything each one captured. Overwriting one id 5000
    times held 6.1 MB for a single live binding. It is 1.8 KB now. Bounded in a
    normal bootstrap, but it bit anything re-registering on a long-lived
    container.
  • Two applications sharing the default cache directory served each other's
    resolved class names.
    The cache dir defaults to the system temp dir. So
    gacela-class-names.php and gacela-custom-services.php were written under
    that one name, whatever project they belonged to. The merged-config cache
    already hashed the app root for exactly this reason. The other two did not.
    Both filenames now carry that hash, and cache:clear removes the unscoped
    spelling too, so a file written before the upgrade cannot keep answering.
  • CacheWarmedEvent reported skipped modules as failed. A listener alerting
    on failedCount() > 0 fired on a successful deploy. A missing pillar class
    and one whose autoloading threw now count separately, and skippedCount()
    reports the healthy one. cache:warm gains a Classes failed: line.
  • make:module and make:file failed on a brand-new project.
    FileContentIo::mkdir() omitted the recursive flag.
  • Container emitted PHP 8.5 deprecation notices on a core path.
  • The in-memory copy of the file-backed caches survived Gacela::resetCache().
    Entries read from disk kept answering after a reset.
  • extendService() on an id naming an autowirable class threw, instead of
    scheduling the extension.
  • Gacela::resetCache() drops the memoized "this class does not exist" answers.

Deprecated

  • Resolving a pillar from a @method docblock, or by scanning the caller's
    use statements, raises E_USER_DEPRECATED. Both are removed in 3.0. Declare
    it with #[ServiceMap].

Removed (BREAKING)

  • AbstractDependencyProvider. Extend AbstractProvider instead.
  • GacelaConfig::addMappingInterface(). Use addBinding().
  • DocBlockResolverAwareTrait. Use ServiceResolverAwareTrait.
  • Internal DependencyProviderResolver, and the AbstractFactory dual-resolver
    path. *DependencyProvider classes are no longer auto-resolved. Rename them.

Documentation

  • New UPGRADE.md: the 1.21 → 2.0 migration, ordered by how likely each change
    is to hit you. The PHP floor, the three renames, then static analysis.
  • New docs/getting-a-dependency.md: one primary path per intent, with the rest
    listed alongside the situation where each one is right.

Internal

  • phpunit/phpunit is ^12.5, and rector/rector moved out of the main
    autoloader.
    The two are one change: rector ships a composer files autoload
    that requires its own bundled nikic/php-parser once PHPUnit 12 is present,
    and this package requires nikic/php-parser directly for its PHPStan rules,
    so whichever loaded first the other redeclared PhpParser\NodeVisitor — a
    fatal thrown from the autoloader, before the first test ran. Rector now
    installs into vendor-bin/rector/ via bamarni/composer-bin-plugin, with its
    own autoloader, so the two copies never meet. composer install still fetches
    it (forward-command), and the rector/rectorrun scripts and the Code style
    workflow point at the new path. The bump also unpins ~22 transitive packages
    that phpunit 10.5 was holding back (sebastian/*, phpunit/php-*,
    theseer/tokenizer). psalm/plugin-phpunit stays on ^0.19, which is
    waiting on upstream and unrelated.

  • The PHPUnit 12 migration surfaced three things worth fixing rather than
    silencing. Two SetupGacelaTest cases compared closures with assertEquals,
    which only ever passed because PHPUnit 10's comparator looked no further than
    the type; they now run the extensions and assert the result, which also pins
    the merge order the old comparison could not see. Twenty-four
    createMock() calls with no configured expectations became createStub().
    And ClassNameFinderTest::test_rule_but_no_resolvable_types carried a
    with()/willReturn() pair describing a call that never happens — with no
    resolvable types there is no candidate to validate — so it now asserts the
    validator is never consulted.

  • Two coverage tests guard cache resets from both ends. ResetCacheCoverageTest
    checks that a declared reset is reached by Gacela::resetCache().
    StaticStateCoverageTest checks that every static property under src/ is
    back at its declared default afterwards, or listed with a reason.

  • ContainerForwardingCoverageTest requires every public method of
    Gacela\Container\Container to be forwarded, or listed with its reason.
    Implementing ContainerInterface never gave this for free, since 1.x promised
    never to extend the interface. Every capability since 1.0 landed on the
    concrete class, where an unforwarded method compiles fine and stays
    unreachable. The exemption list is empty now. createScope() was its last
    entry.

  • AttributeReadCoverageTest requires every read of a non-final attribute to
    pass ReflectionAttribute::IS_INSTANCEOF. An attribute is left non-final
    precisely so it can be subclassed, and a reader naming the parent without the
    flag matches the parent and nothing else. debug:dependencies, debug:module
    and debug:container read that way, and so did the Symfony bridge, so
    Gacela\Framework\Attribute\Inject was honoured by the container and by
    nothing else: the parameter showed as plain autowiring, and Symfony autowired
    it. Both attribute docblocks name this failure and call it silent. What hid it
    is that the container's own three reads were already correct, and every test
    went through Container::make(). Both readers pass the flag now, with a test
    each that reads through the surface rather than the container.

  • Gacela::resetCache() clears the shared plan cache, so plans are shared
    within a bootstrap rather than across one. It deliberately does not call
    Container::resetStaticCaches(). That was tried, and cost FileCacheBench
    11-17%, for memory rather than correctness.

  • DocBlockResolverCache is ServiceResolverCache. It caches resolved custom
    services, and decides whether a PHP file or an in-memory map backs them.
    Nothing about it is docblock-specific, and every symbol it touches said so
    already: CustomServicesPhpCache, and the three CustomServices* events. The
    name now matches its counterpart, ClassResolverCache. Internal, so no
    migration.

  • The container's constructor-plan type aliases moved to PlanRegistry, which
    holds them, from DependencyResolver, which builds them. The decorator's
    @psalm-import-type CompiledPlans follows them. That symbol only exists from
    container 2.0.2, which is why the floor is ^2.0.2 and not ^2.0.1.

  • debug:container reads stats(): ContainerStats, instead of the untyped
    getStats() array whose shape upstream excludes from BC.

  • Infection 0.34's ReturnRemoval mutator reads every early return as
    behaviour. Both jobs run --show-mutations=max, since the default caps the
    list at 20 and hid 13 of 33 escapes.

  • Raised the nikic/php-parser floor to ^5.4. Psalm 6.16 reads
    Property::$hooks, which only exists from 5.4.

  • Refreshed the toolchain: infection ^0.29^0.34, plus phpstan and
    rector. Two majors are held, each re-verified against the current lockfile:

    • phpunit stays on ^10.5. Rector's composer files autoload preloads
      its own bundled nikic/php-parser under PHPUnit 12+. This package requires
      nikic/php-parser directly for its PHPStan rules. So whichever loads first,
      the other redeclares: Cannot redeclare interface PhpParser\NodeVisitor,
      fatal before the first test. Re-reproduced on phpunit/phpunit:12.5.33
      against rector/rector:2.6.1. It is a coexistence problem, not an
      incompatibility. With rector removed, 12.5.33 installs and runs, integration
      and feature pass whole, and unit is three tests short.
    • psalm/plugin-phpunit stays on ^0.19. 0.20 requires
      psalm/psalm-plugin-api ^0.1, which conflicts with vimeo/psalm <7.0.0.
      Psalm 7 is still at 7.0.0-beta19.
  • That rector refresh left rector.php naming SetList::STRICT_BOOLEANS, which
    2.x removed, so composer rector and composer fix both died on an undefined
    constant and the entire ruleset went unapplied. Nothing caught it: rector was
    in neither composer quality nor any workflow. It is in both now, as
    composer rectorrun, alongside phpstan-tests, which was also configured and
    also never run in CI. Re-applying the ruleset touched 60 files, all internal;
    the only src/ signature changes are nine private static methods becoming
    private.

    Six dead-code rules and ReadOnlyPropertyRector are now bounded to src/.
    Fixtures are shaped on purpose, and dead-code removal reads that intent as
    waste: it stripped stream_close() and stream_open()'s by-ref $openedPath
    from a stream wrapper PHP calls by contract, emptied the fixtures named
    EmptyConstructorService and UntypedAndUnionService, and dropped the
    assignments keeping a benchmark's subject from being optimised away.
    StringClassNameToClassConstantRector is off for CacheClearCommandTest,
    whose docblock already said why it names an @internal class as a string.

    LevelSetList deliberately stays at UP_TO_PHP_81, under the >=8.3 floor.
    Going to 8.3 turns 60 changed files into 223, and what it adds is BC-hostile:
    ReadOnlyClassRector alone marks 103 classes readonly, which a non-readonly
    child may not extend, and that is every downstream AbstractFacade,
    AbstractFactory, AbstractConfig and AbstractProvider.

    composer fix ran csfix before rector, so rector's own output went
    unformatted and left the tree failing csrun. Reordered.

  • A root gacela.php scoping the console to src. Without it, doctor walked
    the whole repository and reported two errors on a clean checkout. tests/
    holds fixtures that are deliberately separate applications, several declaring
    their own pillar suffixes, which look misnamed under the root config.

  • symfony-bridge/composer.json matches the root package it ships from.

  • Removed Scrutinizer. It duplicated PHPStan, Psalm and php-cs-fixer, which
    already gate every pull request.