sirix/container-resolver

Strict PSR-11 container service resolver and typed configuration reader

Maintainers

Package info

github.com/sirix777/container-resolver

pkg:composer/sirix/container-resolver

Transparency log

Fund package maintenance!

sirix777

buymeacoffee.com/sirix

Statistics

Installs: 933

Dependents: 7

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-08-13 08:22 UTC

This package is auto-updated.

Last update: 2026-08-13 08:25:44 UTC


README

Latest Stable Version Total Downloads Latest Unstable Version License PHP Version Require

Strict PSR-11 container service resolver and typed configuration reader for reusable PHP packages.

Stability and requirements

1.0.0 is the first stable release. It supports PHP 8.2-8.5 and psr/container 1.x or 2.x.

Stable public contract

Starting with 1.0.0, the public API follows Semantic Versioning: backward-incompatible changes to the documented public contract require a new major version.

The stable contract comprises:

  • the public classes and exception interfaces in the Sirix\ContainerResolver namespace;
  • public method names, parameter order, types, return types, and named parameters (including default:);
  • the service-resolution, configuration-validation, normalization, and exception behavior documented below;
  • support for the PHP and psr/container versions stated above.

Within 1.x, new optional capabilities may be added in minor releases and bug fixes may be released in patch versions. Applications should catch the documented package exception interfaces rather than depend on exception-message text.

Installation

composer require sirix/container-resolver

Why

Package factories often need to read services and configuration from framework containers. Manual checks tend to repeat the same code, silently coerce invalid values, or fail with messages that do not explain which factory needs which value.

ContainerResolver and ConfigReader keep factory code small while preserving explicit failures:

  • missing container services throw package-level not-found exceptions;
  • invalid service types throw package-level container exceptions;
  • missing required config values throw package-level config exceptions;
  • existing invalid config values always throw;
  • optional missing config values can use defaults;
  • scalar config values are not coerced.

The package is framework-agnostic and only requires PHP and psr/container at runtime.

Container services

Use ContainerResolver when a service id is the expected class or interface:

use Psr\Container\ContainerInterface;
use Sirix\ContainerResolver\ContainerResolver;

final class AuthManagerFactory
{
    public function __invoke(ContainerInterface $container): AuthManagerInterface
    {
        $resolver = ContainerResolver::forFactory($container, self::class);

        return new AuthenticationManager(
            $resolver->get(TokenStorageProviderInterface::class),
            $resolver->get(TokenTransportInterface::class),
        );
    }
}

Use getAs() when the service id is a custom string but the expected type is known:

$storage = $resolver->getAs('app.storage.redis', TokenStorageInterface::class);

Use getExisting() when you want the raw service value and will validate it yourself:

$value = $resolver->getExisting('config');

optional() is the typed optional counterpart to get(): the service id must be a class or interface name. It returns null only when the service is absent; a present service with the wrong type throws InvalidContainerServiceException.

$logger = $resolver->optional(LoggerInterface::class);

Use optionalAs() when the service id is custom but its expected type is known, and optionalExisting() only when intentionally handling an untyped raw service yourself:

$cache = $resolver->optionalAs('app.cache', CacheInterface::class);
$value = $resolver->optionalExisting('app.feature-flags', default: []);

optionalArray() accepts any PHP array, including lists. optionalMap() is stricter: it accepts only arrays whose keys are strings, which makes it appropriate for the root config service. Both return [] for a missing service and throw when a present service violates their contract.

$items = $resolver->optionalArray('app.middleware');
$config = $resolver->optionalMap('config');

ConfigReader::fromContainer() reads its root configuration through optionalMap(), so list-shaped root configuration is rejected.

Typed configuration

ConfigReader reads nested arrays with dot paths:

use Sirix\ContainerResolver\ConfigReader;
use Sirix\ContainerResolver\ContainerResolver;

$resolver = ContainerResolver::forFactory($container, self::class);
$config = ConfigReader::fromContainer($resolver);

$driver = $config->stringEnum(
    'authentication.transport.driver',
    ['bearer', 'cookie'],
    default: 'bearer',
);

Available methods:

$config->has('app.name');
$config->get('app.name', default: 'demo');
$config->required('app.name');

$config->string('app.name', default: 'demo');
$config->requiredString('app.name');
$config->nonEmptyString('app.name', default: 'demo');
$config->requiredNonEmptyString('app.name');
$config->optionalString('app.name');
$config->optionalNonEmptyString('app.name');

$config->bool('debug', default: false);
$config->requiredBool('debug');

$config->int('port', default: 8080);
$config->requiredInt('port');

$config->array('items', default: []);
$config->requiredArray('items');

$config->list('entities', default: []);
$config->requiredList('entities');
$config->stringList('entities', default: []);
$config->requiredStringList('entities');
$config->nonEmptyStringList('entities', default: []);
$config->requiredNonEmptyStringList('entities');

$config->map('storages', default: []);
$config->requiredMap('storages');

$config->enum('log.level', LogLevel::class, default: LogLevel::Info);
$config->requiredEnum('log.level', LogLevel::class);

$config->stringEnum('driver', ['bearer', 'cookie'], default: 'bearer');
$config->requiredStringEnum('driver', ['bearer', 'cookie']);

Strictness rules

Missing optional values return the supplied default, after the default has been validated for the reader's declared contract:

$config->string('app.name', default: 'demo');

Existing invalid values always throw InvalidConfigValueException:

['app' => ['name' => 123]]; // invalid for string()
['debug' => 'false'];       // invalid for bool()
['port' => '8080'];         // invalid for int()

Valid scalar values must already have the expected type:

['app' => ['name' => 'api']];
['debug' => false];
['port' => 8080];

String readers trim leading and trailing whitespace from configured string values:

['app' => ['name' => ' api ']]; // returned as 'api'

This applies to configured string values, string lists, string enums, and PHP enum case names/string-backed values. String defaults are normalized and validated by the same reader contract, so a blank default is not accepted where a non-empty string is required. Trimming is normalization of strings only; scalar coercion is never performed.

Paths and null

Paths use dot-separated segments such as app.name. A path cannot be empty and cannot contain an empty segment (app..name). Dots are not escapable, so a configuration key containing . cannot be addressed as one key. Numeric list indexes can be addressed as segments (servers.0.host).

null is an existing value, not a missing path. has('app.name') is therefore true for ['app' => ['name' => null]], and get() returns null; typed readers reject it with InvalidConfigValueException. A null at an intermediate path makes a deeper path absent.

optionalNonEmptyString() is useful for optional values where an empty string should be treated as not configured:

$config->optionalNonEmptyString('cookie.domain');

Behavior:

[];                              // null
['cookie' => ['domain' => '']];  // null
['cookie' => ['domain' => '   ']]; // null
['cookie' => ['domain' => ' example.com ']]; // 'example.com'
['cookie' => ['domain' => 123]]; // invalid

list() requires a sequential list array:

$config = ConfigReader::fromArray([
    'entities' => [
        'src/App/src/Entity',
    ],
]);

$entities = $config->nonEmptyStringList('entities', default: []);

entities may be absent; in that case the default [] is returned. If entities exists, it must be a list of non-empty strings.

enum() returns real PHP enum instances. Its default: must be an instance of the requested enum class:

enum LogLevel: int
{
    case Debug = 100;
    case Info = 200;
    case Warning = 300;
}

$level = $config->enum(
    'logging.level',
    LogLevel::class,
    default: LogLevel::Info,
);

Accepted configured values:

['logging' => ['level' => LogLevel::Debug]]; // enum instance
['logging' => ['level' => 'Debug']];         // case name
['logging' => ['level' => ' debug ']];       // trimmed case name, case-insensitive
['logging' => ['level' => 100]];             // backed value for int-backed enum

For backed enums, a matching backed value is resolved before a case name. Case names are trimmed and matched case-insensitively. String-backed values are trimmed; int-backed values must be integers.

Numeric strings are not coerced for int-backed enums:

['logging' => ['level' => '100']]; // invalid for LogLevel: int

For string-backed enums, the string backed value is accepted:

enum Driver: string
{
    case Bearer = 'bearer';
    case Cookie = 'cookie';
}

$driver = $config->requiredEnum('driver', Driver::class);

map() requires all keys to be strings:

$config = ConfigReader::fromArray([
    'storages' => [
        'redis' => 'app.storage.redis',
        'db' => 'app.storage.db',
    ],
]);

$storages = $config->map('storages', default: []);

Lists are rejected by map() because they do not describe named configuration entries. String-keyed maps are rejected by list() because they are not sequential lists.

Long-running process safety

The package is safe to use in long-running processes such as RoadRunner, Swoole, ReactPHP, queue workers, and persistent Mezzio/Laminas applications.

It does not use:

  • global mutable state;
  • static runtime caches;
  • request-specific static properties;
  • singleton resolver instances.

Resolver and reader instances are cheap to create per factory invocation:

public function __invoke(ContainerInterface $container): SomeService
{
    $resolver = ContainerResolver::forFactory($container, self::class);
    $config = ConfigReader::fromContainer($resolver);

    return new SomeService(
        dependency: $resolver->get(DependencyInterface::class),
        enabled: $config->bool('some.enabled', default: true),
    );
}

Exceptions do not retain service instances or full config arrays.

Exceptions

All package exceptions implement:

Sirix\ContainerResolver\Exception\ResolverException

Container-related exceptions also implement:

Sirix\ContainerResolver\Exception\ContainerResolverException

Config-related exceptions also implement:

Sirix\ContainerResolver\Exception\ConfigReaderException

Concrete exceptions:

Sirix\ContainerResolver\Exception\MissingContainerServiceException
Sirix\ContainerResolver\Exception\InvalidContainerServiceException
Sirix\ContainerResolver\Exception\MissingConfigValueException
Sirix\ContainerResolver\Exception\InvalidConfigValueException

MissingContainerServiceException implements Psr\Container\NotFoundExceptionInterface. InvalidContainerServiceException implements Psr\Container\ContainerExceptionInterface.

ContainerResolver wraps missing-service and invalid-type failures into package exceptions. Other container resolution failures from the underlying PSR-11 container are propagated unchanged.

Diagnostic messages identify configuration paths, expected contracts, and actual types, but never include raw configured values. This keeps error reporting safe for configuration that may contain credentials or other secrets.

That means:

  • missing service or NotFoundExceptionInterface from the container becomes MissingContainerServiceException;
  • wrong resolved service type becomes InvalidContainerServiceException;
  • other ContainerExceptionInterface failures from the container remain the original container exception.

Public methods document their expected failure modes with @throws annotations.

Consumers can catch and wrap package exceptions:

use Sirix\ContainerResolver\Exception\ResolverException;

try {
    // factory logic
} catch (ResolverException $exception) {
    throw MyPackageConfigurationException::fromPrevious($exception);
}

Examples

Factory with configured service ids:

use Psr\Container\ContainerInterface;
use Sirix\ContainerResolver\ConfigReader;
use Sirix\ContainerResolver\ContainerResolver;
use Sirix\ContainerResolver\Exception\InvalidConfigValueException;

final class TokenStorageProviderFactory
{
    public function __invoke(ContainerInterface $container): TokenStorageProviderInterface
    {
        $resolver = ContainerResolver::forFactory($container, self::class);
        $config = ConfigReader::fromContainer($resolver);

        $defaultStorage = $config->nonEmptyString('authentication.default_storage', default: 'null');

        $storages = [
            'null' => $resolver->get(NullTokenStorage::class),
        ];

        foreach ($config->map('authentication.storages', default: []) as $name => $serviceId) {
            if (! is_string($serviceId) || '' === $serviceId) {
                throw InvalidConfigValueException::forType(
                    "authentication.storages.{$name}",
                    'non-empty-string',
                    $serviceId,
                    self::class,
                );
            }

            $storages[$name] = $resolver->getAs($serviceId, TokenStorageInterface::class);
        }

        if (! isset($storages[$defaultStorage])) {
            throw InvalidConfigValueException::forAllowedValues(
                'authentication.default_storage',
                array_keys($storages),
                $defaultStorage,
                self::class,
            );
        }

        return new TokenStorageProvider($defaultStorage, $storages);
    }
}

Design notes

This package intentionally does not provide:

  • a DI container;
  • autowiring;
  • service definitions;
  • framework adapters;
  • config schema compilation;
  • deep config merging;
  • environment variable processors;
  • secret resolution;
  • runtime caching;
  • scalar coercion.