Services
The service locator is a registry of names, each paired with a description of how to build one object. Nothing is built until something asks, and once built it is held for the rest of the request. There is no dependency injection behind it — no constructor signature is read and nothing is auto-wired.
Registering a Service#
Two places register services, and they hold the same kind of value. The services key of a config array is
data, so it belongs in a config file:
<?php
return [
'services' => [
'session' => ['call' => 'Pop\Session\Session::getInstance'],
'notifier' => [
'call' => 'App\Service\Notifier',
'params' => ['channel' => 'email']
]
]
];
setService() does the same thing imperatively, and is what an application class's load() uses when the
definition needs a value only the running application has:
$app = new Pop\Application();
$app->setService('logger', [
'call' => 'Pop\Log\Logger',
'params' => ['writer' => new Pop\Log\Writer\File(__DIR__ . '/app.log')]
]);
Both end up in the same Pop\Service\Locator. Applications & Bootstrap covers where
load() sits in the bootstrap sequence; this page is about what goes into the definition and what comes back
out.
Define a Service#
A service is defined by anything Pop\Utils\CallableObject can call, given either as a bare value or as an array
with a call key and an optional params key. These are what the locator understands:
| Definition | What getService() returns |
|---|---|
'App\Service\Notifier' |
new App\Service\Notifier() |
'new App\Service\Notifier' |
the same — the new prefix is accepted and ignored |
'App\Service\Notifier::build' |
the return value of the static method |
'App\Service\Notifier->send' |
constructs the class, then returns the return value of send() |
'my_factory_function' |
the return value of the named function |
function() { ... } |
the closure's return value |
['call' => <any of the above>, 'params' => [...]] |
the same, with params passed to whichever call it is |
Any other PHP callable — [$object, 'method'], an object with __invoke() — works too, but has to go in the
call key rather than being passed bare. A bare array is read as the call/params shape, so
setService('notifier', [$object, 'send']) throws
Pop\Service\Exception: Error: A callable service was not passed; ['call' => [$object, 'send']] is the
form that works.
params is an array, and its keys matter. A string key becomes a PHP named argument, which is why
['writer' => ...] above lands on Logger::__construct()'s $writer parameter regardless of position; a
numeric-keyed array is passed positionally. The two cannot be mixed — a named parameter followed by a
positional one throws Error: Cannot use positional argument after named argument when the service resolves.
$app = new Pop\Application();
$app->setService('session', ['call' => 'Pop\Session\Session::getInstance']);
$app->setService('cookie', 'Pop\Cookie\Cookie::getInstance');
$app->setService('uploads', [
'call' => 'Pop\Http\Server\Upload',
'params' => ['dir' => __DIR__ . '/uploads', 'maxSize' => 1000000]
]);
echo get_class($app->getService('session')); // Pop\Session\Session
Lazy Construction#
A definition is stored, not run. Nothing in setService() checks that the class exists, that the method is
real or that the parameters fit — the first getService() for a name does all of that, and every later call
for the same name returns the object the first call produced.
$app = new Pop\Application();
$app->setService('now', function() { return new DateTime(); });
var_dump($app->services()->isLoaded('now')); // false — registered, never built
$first = $app->getService('now');
var_dump($app->services()->isLoaded('now')); // true
var_dump($app->getService('now') === $first); // true — the same object
var_dump($app->services()->reload('now') === $first); // false — rebuilt
Laziness is the reason a service is the right home for anything expensive: a database connection, an HTTP client, a mail transport. Register all of them and a request that touches none pays for none.
It's also why a typo in a definition surfaces late and somewhere unhelpful. A name that is not a class, a
method that does not exist on one, a params array that does not fit the constructor — all of it throws out
of the first getService(), not out of setService():
$app = new Pop\Application();
$app->setService('broken', 'App\Service\Notfier'); // typo, accepted silently
$app->getService('broken');
// Pop\Utils\Exception: Error: Unable to prepare the callable object for execution.
A misspelled method is clearer — 'App\Service\Notifier->snd' throws
Error: The method 'snd' does not exist in the class 'App\Service\Notifier'. — but both come from the same
place, on first use.
Two services that each resolve the other throw Pop\Service\Exception naming both. The repeated pair in
the message is the detection depth, not a measure of the problem.
Retrieving a Service#
getService() on the application is the ordinary way in, and it throws for a name that was never registered:
$app = new Pop\Application();
$app->setService('logger', ['call' => 'Pop\Log\Logger']);
$app->getService('logger');
$app->getService('nope');
// Pop\Service\NotFoundException: Error: The service 'nope' has not been added to the service locator
services()->isAvailable('nope') answers the same question without throwing, and is the guard for an optional
service. The locator also implements PSR-11, so has() is a synonym for isAvailable(), get() for the
locator's own get(), and the two exception types satisfy Psr\Container\NotFoundExceptionInterface and
Psr\Container\ContainerExceptionInterface — a Pop\Service\Locator can be handed to any library that
type-hints Psr\Container\ContainerInterface.
$app = new Pop\Application();
$app->setService('logger', ['call' => 'Pop\Log\Logger']);
var_dump($app->services()->isAvailable('logger')); // true
var_dump($app->services()->has('nope')); // false
$app->removeService('logger');
var_dump($app->services()->isAvailable('logger')); // false
The locator supports property and array access as shorthands for the same two calls — $app->services['logger']
reads, $app->services()->logger reads, and both isset($app->services()->logger) and
isset($app->services['logger']) are isAvailable(). Inside a controller the route is
$this->application()->getService('logger'), which Controllers covers along with why
dependencies here are pulled rather than pushed. Outside both, Pop\App::services('logger') reaches the
application registered during bootstrap.
Binding Parameters Yourself#
The locator wraps every definition in a Pop\Utils\CallableObject, and Locator::set() deliberately keeps a
CallableObject you built yourself instead of wrapping it again. That matters when the parameters are easier
to assemble in steps than to write as one array literal:
$app = new Pop\Application();
$notifier = new Pop\Utils\CallableObject('App\Service\Notifier');
$notifier->addNamedParameter('channel', 'email');
$notifier->addNamedParameter('from', 'noreply@example.com');
$app->setService('notifier', $notifier);
The same object is reachable afterward through services()->getCallable('notifier') and
services()->getParameters('notifier'), and setParameters(), addParameter() and removeParameters() on
the locator edit a definition in place:
$app = new Pop\Application();
$app->setService('notifier', [
'call' => 'App\Service\Notifier',
'params' => ['channel' => 'email']
]);
$app->getService('notifier');
$app->services()->setParameters('notifier', ['channel' => 'sms']);
$app->services()->reload('notifier');
A definition edited after the service has been resolved has no effect on the resolved object — reload() is
what picks the change up, which is what the last line is for.
One behavior of CallableObject is worth knowing before you put a closure in params: a parameter that is
itself callable is invoked, and its return value is what gets passed. That's a lazy dependency, and it
runs when the service is first resolved rather than when it is registered:
$app = new Pop\Application();
$app->setService('notifier', [
'call' => 'App\Service\Notifier',
'params' => ['logger' => function() { return new Pop\Log\Logger(); }]
]);
App\Service\Notifier receives a Pop\Log\Logger, not the closure. A parameter that genuinely is meant to
be a callback has to be wrapped — in an object with an __invoke(), or in a single-element array — or it will
be called and its result passed instead.
The Service Container#
Pop\Service\Container is a static registry of locators, for code that has no application object to ask.
The first Pop\Service\Locator constructed registers itself under 'default', which is the one the
application built during bootstrap:
use Pop\Service\Container;
$app = new Pop\Application();
$app->setService('logger', ['call' => 'Pop\Log\Logger']);
var_dump(Container::has('default'));
echo get_class(Container::get('default')->get('logger')); // Pop\Log\Logger
Container::get() with no argument is the same as Container::get('default'), and an unknown name throws
Pop\Service\Exception: Error: The service locator 'reporting' has not been added. Additional locators are
registered by name, which is how a subsystem gets a namespace of its own:
$reporting = new Pop\Service\Locator(['clock' => function() { return new DateTime(); }], false);
Pop\Service\Container::set('reporting', $reporting);
echo get_class(Pop\Service\Container::get('reporting')->get('clock')); // DateTime
The false second argument is what keeps that locator out of the 'default' slot when nothing has claimed it
yet.
Prefer $app->getService() or App::services() wherever an application is in reach, and keep Container
for what it exists for: static code with no handle on one. 'default' is claimed by the first locator
constructed, where Pop\App::get() returns the most recent application.
The locator exposes more than this page covers — per-parameter editing, and the Countable/ArrayAccess
surface it inherits from the manager base class — see the popphp README.
See Also#
- Applications & Bootstrap — where
load()sits and why registration belongs there - Configuration — the
servicesconfig key - Controllers — reaching a service from a dispatched controller
- Models — registering a model so it's built once per request
- Records & the ORM — the
databaseserviceinitDb()registers - popphp README — the full locator API surface