Phalcon's DI Container: Service Location, Lazy Loading, and the Trade-offs of FactoryDefault
Phalcon's DI container offers lazy loading, shared/non-shared services, and PSR-11 support. FactoryDefault pre-registers ~30 services, which can bloat micro-apps and obscure dependencies. Learn registration modes, wiring with named parameters, and why constructor injection belongs in domain code.
13 Oct 2026, 23:21 UTC

The Problem: Implicit Dependencies in a Full-Stack Framework
Phalcon's Dependency Injection (DI) container sits at the center of every application. It holds the router, database connections, session, flash messages, and dozens of other services. For newcomers, the convenience of $this->di->get('db') inside a controller or model feels magical — until a unit test fails because a hidden service locator call can't be mocked, or a micro-service deployment drags in 30 unused services because FactoryDefault was imported by habit.
The thesis is simple: Phalcon's DI is a powerful service locator with lazy loading and PSR-11 compatibility, but treating it as a global bucket for everything creates coupling that hurts testability and obscures architecture. Understanding the registration modes, lazy instantiation, and the difference between FactoryDefault and a minimal Di instance lets you keep the convenience where it belongs — framework integration points — and push explicit constructor injection into your domain code.
Shared vs. Non-Shared Services and Lazy Instantiation
Every service registration in Phalcon's DI is either shared (singleton) or non-shared (new instance per resolution). The default set() and get() methods create non-shared services; setShared() and getShared() create shared ones. Crucially, the factory closure or class instantiation runs only when the service is first requested — not at registration time.
This lazy loading matters for performance. A typical FactoryDefault container registers ~30 services at bootstrap, but only the ones actually accessed incur instantiation cost. For example, if your CLI task never touches the Volt templating engine, its compiler and parser are never loaded.
$di = new Phalcon\\Di\\FactoryDefault();
// 'db' is registered as shared by default in FactoryDefault
$di->setShared('customLogger', function () {
return new \\MyApp\\Logger(new \\Monolog\\Logger('app'));
});
// First access triggers the closure
$logger = $di->getShared('customLogger');
// Subsequent calls return the same instance
$sameLogger = $di->getShared('customLogger');
assert($logger === $sameLogger); // true
Non-shared services behave differently: each get() call invokes the factory again. This is useful for request-scoped objects like form builders or DTO factories.
Wiring Complex Services: Named Parameters and Property Injection
Phalcon's Di\\Service definition syntax goes beyond simple closures. You can declare a class name, constructor arguments (positional or named), property injections, and post-construction method calls — all in the registration array. This keeps wiring in PHP instead of external config files.
A practical pattern: register database connections as shared services with named parameters pulled from a config object. The modelsManager then resolves the default 'db' service automatically for any model extending Phalcon\\Mvc\\Model.
use Phalcon\\Di\\FactoryDefault;
use Phalcon\\Db\\Adapter\\Pdo\\Mysql;
$config = require 'config/database.php';
$di = new FactoryDefault();
$di->setShared('db', [
'className' => Mysql::class,
'arguments' => [
[
'host' => $config['host'],
'username' => $config['username'],
'password' => $config['password'],
'dbname' => $config['dbname'],
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
],
],
],
]);
// In a model — no explicit DI call needed
class Users extends \\Phalcon\\Mvc\\Model {}
// Elsewhere, explicit access still works
$connection = $di->getShared('db');
This approach keeps credentials out of model code and makes the connection swappable for testing (replace the 'db' service with a SQLite in-memory instance).
PSR-11 Interoperability and the FactoryDefault Trap
Since Phalcon 5, FactoryDefault implements Psr\\Container\\ContainerInterface. You can pass the DI to any library expecting a PSR-11 container, and get('router') will return Phalcon's router service. This is a genuine interop win — but it also encourages passing the entire container into domain classes, effectively turning constructor injection into service location.
The FactoryDefault trap is real: it registers ~30 services (router, db, modelsManager, volt, security, crypt, session, cookies, flash, request, response, filter, escaper, annotations, eventsManager, dispatcher, url, assets, tag, helper, etc.). For a REST API or micro-application, most are dead weight. Each registered service consumes memory and adds surface area for accidental coupling. The fix is straightforward: instantiate Phalcon\\Di\\Di and register only what you need.
use Phalcon\\Di\\Di;
use Phalcon\\Mvc\\Router;
use Phalcon\\Db\\Adapter\\Pdo\\Mysql;
$di = new Di();
$di->setShared('router', Router::class);
$di->setShared('db', function () use ($config) {
return new Mysql($config['database']);
});
// No session, no volt, no flash — just what the API uses
Limitations and Trade-offs
- No auto-wiring by type-hint. Phalcon's DI does not inspect constructor signatures. You must explicitly define every parameter or use a third-party wrapper (e.g.,
php-di/php-dibridge) if you want automatic resolution. - Circular references throw at runtime. If service A requires B and B requires A, the container throws when resolving either. Break cycles with lazy factories (
function () use ($di) { return new A($di->getShared('b')); }) or introduce a mediator service. - Closures capture the DI instance at definition time. If you replace the container (rare, but possible in test bootstraps), existing closures still reference the old instance. Re-register services after a DI swap.
- Global DI obscures dependencies. Classes that call
$this->getDI()->get('service')hide their requirements. Reserve DI access for controllers, middleware, and base models; use constructor injection for domain services, repositories, and actions.
Actionable Closing: Verify Your Container Shape
Run this one-liner in your project root to confirm which services are actually registered and that PSR-11 compliance holds:
php -r "
$di = new Phalcon\\Di\\FactoryDefault();
echo 'Services: ' . count($di) . PHP_EOL;
echo 'Has db: ' . ($di->has('db') ? 'yes' : 'no') . PHP_EOL;
echo 'Has volt: ' . ($di->has('volt') ? 'yes' : 'no') . PHP_EOL;
echo 'PSR-11: ' . ($di instanceof Psr\\Container\\ContainerInterface ? 'yes' : 'no') . PHP_EOL;
"
If the list includes services your application never uses, switch to a minimal Di instance and register only what you need. Your test suite, memory profile, and future maintainers will thank you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.