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/.jsonfile. Use it for wiring that is generated, shared
between environments, or reviewed as a diff:App-wide, like$config->loadDefinitions([ LoggerInterface::class => FileLogger::class, Database::class => ['singleton' => DatabasePool::class], 'db.dsn' => ['value' => 'pgsql://localhost/app'], ]); $config->loadDefinitions(__DIR__ . '/config/services.json');
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 overridesaddBinding().
Tags accumulate instead. Paths are used as given, so write them with
__DIR__. No YAML: passYaml::parseFile(...).GacelaConfig::afterResolving()runs a callback on a resolved instance:The id may name an interface, so one registration covers every$config->afterResolving( LoggerAwareInterface::class, static fn (LoggerAwareInterface $s) => $s->setLogger($logger), );
implementation. It fires onget(),getOrFail()andmake(), 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; thesetLogger()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:A module adding to a tag in its own Provider stays local to it. Use$config->tag([NotEmptyValidator::class, EmailValidator::class], 'validators');
tag()
for an unkeyed set you iterate. UseaddHandlerRegistry()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:A missing dependency now says whose it is. A cycle is marked├── ✓ $cacheWarmService: …\CacheWarmService (autowired) └── ✓ $formatter: …\CacheWarmOutputFormatter (autowired) └── ✗ $output: Symfony\…\OutputInterface (unresolvable)(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:moduleand
debug:containerall 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.xmlonly
suppressed the error, so the accessor evaluated tomixed:<plugins> <pluginClass class="Gacela\Psalm\Plugin"/> </plugins>
- One constructor-plan cache for every container. Gacela's containers are
sibling roots configured from the samegacela.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 ownPlanCacheas 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
withReflectionAttribute::IS_INSTANCEOF, so both imports work side by side.
Not aclass_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 byAbstractFactory::make().cache:clearalso 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()andtaggedKeys()are forwarded. So
arelazy(),writeCompiledCache(),writeCompiledFactories(),
useCompiledFactories()andcompileReport(), andwriteCompiledCache()
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.
AbstractFactorybuilt
one container per Factory class throughContainer::withConfig(). That walks
the whole ofgacela.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).Containeris
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. Andhas()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()andprotect()mark closures by identity, so
each wrapper needed tracking in aWeakMap. That is roughly 50 lines and 29
touchpoints. TheWeakMapgoes 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 gaploadDefinitions()shipped with:
definitions now emitBindingRegisteredEventlike 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()isprocessMemoryFormatted().
debug:containerlabels it Process Memory, which is what it always
measured. -
ConsoleFacade::getContainerStats()andConsoleFactory::getContainerStats()
returnContainerStatsinstead 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 typedmixed. -
Class constants on
AbstractSetupGacelaandConfigInterfacedeclare types,
so a subclass redeclaring one is checked at compile time.
Fixed
symfony-bridgewas 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 tosrc/and
tests/, so only its 7 tests ran and nothing ever looked at the file. That is
how the#[Inject]subclass bug reachedmain—GacelaInjectCompilerPass
read the attribute by exact FQN, silently dropping every
Gacela\Framework\Attribute\Inject— and how the disjoint
gacela-project/container: ^1.4.0constraint 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.
FilenameMismatchCheckdrove 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 backnull,pillarsOf()filtered it out, anddoctor
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 reachedCacheableTrait::clearMethodCache()transitively
throughAbstractFacade::resetCache(), and that callsclear()on whatever
storage is configured.CacheableConfig::setStorage()is the documented way
to wire APCu or Redis, anddocs/caching.mdrecommends 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. CallingclearMethodCache()yourself
is unchanged and still clears everything, as its docs say.- A second
Gacela::bootstrap()served the first one's config.
ConfigFactorymemoized the merged config in aprivate staticand 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 wasresetInMemoryCache(), which
docs/production-performance.mdtells you not to use in production — while
UPGRADE.mdnames 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. Soset(),bind(),extend(),factory()andprotect()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.phpandgacela-custom-services.phpwere 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, andcache:clearremoves the unscoped
spelling too, so a file written before the upgrade cannot keep answering. CacheWarmedEventreported skipped modules as failed. A listener alerting
onfailedCount() > 0fired on a successful deploy. A missing pillar class
and one whose autoloading threw now count separately, andskippedCount()
reports the healthy one.cache:warmgains aClasses failed:line.make:moduleandmake:filefailed on a brand-new project.
FileContentIo::mkdir()omitted the recursive flag.Containeremitted 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
@methoddocblock, or by scanning the caller's
usestatements, raisesE_USER_DEPRECATED. Both are removed in 3.0. Declare
it with#[ServiceMap].
Removed (BREAKING)
AbstractDependencyProvider. ExtendAbstractProviderinstead.GacelaConfig::addMappingInterface(). UseaddBinding().DocBlockResolverAwareTrait. UseServiceResolverAwareTrait.- Internal
DependencyProviderResolver, and theAbstractFactorydual-resolver
path.*DependencyProviderclasses 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/phpunitis^12.5, andrector/rectormoved out of the main
autoloader. The two are one change: rector ships a composerfilesautoload
that requires its own bundlednikic/php-parseronce PHPUnit 12 is present,
and this package requiresnikic/php-parserdirectly for its PHPStan rules,
so whichever loaded first the other redeclaredPhpParser\NodeVisitor— a
fatal thrown from the autoloader, before the first test ran. Rector now
installs intovendor-bin/rector/viabamarni/composer-bin-plugin, with its
own autoloader, so the two copies never meet.composer installstill fetches
it (forward-command), and therector/rectorrunscripts and the Code style
workflow point at the new path. The bump also unpins ~22 transitive packages
thatphpunit 10.5was holding back (sebastian/*,phpunit/php-*,
theseer/tokenizer).psalm/plugin-phpunitstays on^0.19, which is
waiting on upstream and unrelated. -
The PHPUnit 12 migration surfaced three things worth fixing rather than
silencing. TwoSetupGacelaTestcases compared closures withassertEquals,
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 becamecreateStub().
AndClassNameFinderTest::test_rule_but_no_resolvable_typescarried 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 byGacela::resetCache().
StaticStateCoverageTestchecks that everystaticproperty undersrc/is
back at its declared default afterwards, or listed with a reason. -
ContainerForwardingCoverageTestrequires every public method of
Gacela\Container\Containerto be forwarded, or listed with its reason.
ImplementingContainerInterfacenever 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. -
AttributeReadCoverageTestrequires every read of a non-finalattribute to
passReflectionAttribute::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
anddebug:containerread that way, and so did the Symfony bridge, so
Gacela\Framework\Attribute\Injectwas 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 throughContainer::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 costFileCacheBench
11-17%, for memory rather than correctness. -
DocBlockResolverCacheisServiceResolverCache. 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 threeCustomServices*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, fromDependencyResolver, which builds them. The decorator's
@psalm-import-type CompiledPlansfollows them. That symbol only exists from
container 2.0.2, which is why the floor is^2.0.2and not^2.0.1. -
debug:containerreadsstats(): ContainerStats, instead of the untyped
getStats()array whose shape upstream excludes from BC. -
Infection 0.34's
ReturnRemovalmutator reads every earlyreturnas
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-parserfloor 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:phpunitstays on^10.5. Rector's composerfilesautoload preloads
its own bundlednikic/php-parserunder PHPUnit 12+. This package requires
nikic/php-parserdirectly for its PHPStan rules. So whichever loads first,
the other redeclares:Cannot redeclare interface PhpParser\NodeVisitor,
fatal before the first test. Re-reproduced onphpunit/phpunit:12.5.33
againstrector/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-phpunitstays on^0.19.0.20requires
psalm/psalm-plugin-api ^0.1, which conflicts withvimeo/psalm <7.0.0.
Psalm 7 is still at7.0.0-beta19.
-
That rector refresh left
rector.phpnamingSetList::STRICT_BOOLEANS, which
2.x removed, socomposer rectorandcomposer fixboth died on an undefined
constant and the entire ruleset went unapplied. Nothing caught it: rector was
in neithercomposer qualitynor any workflow. It is in both now, as
composer rectorrun, alongsidephpstan-tests, which was also configured and
also never run in CI. Re-applying the ruleset touched 60 files, all internal;
the onlysrc/signature changes are nineprivate staticmethods becoming
private.Six dead-code rules and
ReadOnlyPropertyRectorare now bounded tosrc/.
Fixtures are shaped on purpose, and dead-code removal reads that intent as
waste: it strippedstream_close()andstream_open()'s by-ref$openedPath
from a stream wrapper PHP calls by contract, emptied the fixtures named
EmptyConstructorServiceandUntypedAndUnionService, and dropped the
assignments keeping a benchmark's subject from being optimised away.
StringClassNameToClassConstantRectoris off forCacheClearCommandTest,
whose docblock already said why it names an@internalclass as a string.LevelSetListdeliberately stays atUP_TO_PHP_81, under the>=8.3floor.
Going to 8.3 turns 60 changed files into 223, and what it adds is BC-hostile:
ReadOnlyClassRectoralone marks 103 classesreadonly, which a non-readonly
child may not extend, and that is every downstreamAbstractFacade,
AbstractFactory,AbstractConfigandAbstractProvider.composer fixrancsfixbeforerector, so rector's own output went
unformatted and left the tree failingcsrun. Reordered. -
A root
gacela.phpscoping the console tosrc. Without it,doctorwalked
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.jsonmatches the root package it ships from. -
Removed Scrutinizer. It duplicated PHPStan, Psalm and php-cs-fixer, which
already gate every pull request.