chillerlan/php-settings-container
A lightweight PHP settings container that decouples configuration logic from your application. It is not a DI container, offering typed settings objects, property hooks for PHP before 8.4, and straightforward access, merging, and serialization.
Install
composer require chillerlan/php-settings-container
Pin a version constraint — ^4.0 for the current line. Be aware 4.x requires PHP 8.4+; if your Laravel app still runs 8.2/8.3, use the ^3.3 line (which also backports the property-hook conflict handling). composer why-not chillerlan/php-settings-container ^4.0 is the fastest way to see if another dependency is holding you back.
Where to look first
src/SettingsContainerAbstract.php — the whole package is one file; read the __get/__set/toArray/fromIterable methods and the protected SET_PREFIX/GET_PREFIX constants.src/SettingsContainerInterface.php — the contract you should type-hint against in your own code.rules-magic-access.neon — the bundled PHPStan ruleset, if you run static analysis.First use case: a typed config object
Stop passing config('my-package') arrays around as array. Wrap the array once and inject the object:
use chillerlan\Settings\SettingsContainerAbstract;
class MailSettings extends SettingsContainerAbstract
{
protected string $host = 'smtp.mailgun.org';
protected int $port = 587;
protected string $driver = 'smtp';
}
// in a service provider
$this->app->singleton(MailSettings::class, fn () => new MailSettings(config('mail')));
Every consumer now gets $settings->port with a default, and a fatal typo ($settings->prot) instead of a silent null from an array lookup — once you enable the attribute below.
Option bags per package, composed with traits
The intended architecture is one trait per concern/library, glued into a single container class. This is how the chillerlan packages themselves do it, and it maps cleanly onto Laravel when you have several subsystems contributing to one settings object:
trait CacheOptions
{
protected int $ttl = 3600;
protected bool $enabled = true;
}
trait LogOptions
{
protected string $channel = 'stack';
// called automatically by construct() after properties are set
protected function LogOptions(): void
{
$this->channel = strtolower($this->channel);
}
}
class AppSettings extends SettingsContainerAbstract
{
use CacheOptions, LogOptions;
}
The trait-named method is a per-trait constructor hook — use it for normalization, validation, or deriving one value from another, not for side effects like I/O.
Custom setters/getters for invariants
Prefix a method with set_/get_ and it intercepts magic access. This is the lightweight alternative to a full property-hook or a mutator trait:
protected function set_port(int $value): void
{
if ($value < 1 || $value > 65535) {
throw new InvalidArgumentException("invalid port: {$value}");
}
$this->port = $value;
}
protected function get_host(): string
{
return rtrim($this->host, '/');
}
Validation belongs here, because it covers both constructor hydration (new MailSettings(['port' => 0])) and later mutation. Note that toArray() in 3.2+ routes through the magic getter, so getters also affect what you serialize.
Hydration paths and which ones bypass what
| Entry point | Runs custom set_*/get_*? |
Runs PHP 8.4 hooks? |
|---|---|---|
Constructor / fromIterable() |
yes (via __set) |
yes |
fromJSON() |
yes | yes |
toArray() / toJSON() / json_encode() |
yes (via __get) |
yes |
serialize() / unserialize() |
no | no |
That last row is the important one: cache-backed settings (Laravel's Cache::remember on a serialized container) will round-trip raw property values, skipping your validation and transforms. If you need the invariants enforced, don't cache the object — cache $settings->toArray() and rehydrate with new AppSettings($cached).
Binding in Laravel
Register the container as a singleton built from config, and type-hint the concrete class or SettingsContainerInterface in constructors. It integrates naturally with constructor injection because it takes an iterable and nothing else:
$this->app->singleton(MyPackageSettings::class, fn ($app) =>
new MyPackageSettings($app['config']->get('my-package', []))
);
Because it accepts any iterable, you can feed it a Collection, a generator of defaults merged with user config, or an ArrayObject without conversion.
Passing settings into a library driver
When a third-party driver wants a flat array, call toArray() at the boundary and keep the container inside your own code:
$client = new SomeApiClient($settings->toArray());
Exposing settings to the frontend / API
jsonSerialize() is implemented, so a settings object can go straight into a response()->json([...]) payload. Combine with toJSON(JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) when you need a stable string for hashing or snapshot tests.
"Immutable" means fixed shape, not read-only. The README's tagline is misleading: properties are settable after construction. What you get is a closed set of keys. If you need true immutability, mark properties readonly (8.4) or expose only a getter-shaped facade.
Invalid properties silently return null by default. $container->typo = 'x' is discarded with no error, and reading it yields null — the classic source of "why is my config empty" bugs. Annotate your container:
use chillerlan\Settings\ThrowOnInvalidProperty;
#[ThrowOnInvalidProperty(true)]
class AppSettings extends SettingsContainerAbstract { /* ... */ }
Do this on every container class in your codebase; the default behavior is a footgun.
It is not a DI container. The README says so bluntly. Do not try to resolve services through it, do not make it your app() replacement, and do not let a settings class acquire dependencies. If you find yourself wanting a factory inside a container class, you want a service provider instead.
Static analysis will complain. Magic __get/__set means PHPStan/Psalm see property access as undefined. Two mitigations: copy rules-magic-access.neon into your PHPStan config to ignore those errors, or (better) declare the properties publicly-typed in docblocks/@property annotations so IDEs and analyzers agree with runtime.
Private properties are excluded from magic calls. isPrivate() is checked, so a private property will not be settable via the constructor and will not appear in toArray(). Always use protected, as every example does — this trips people up when they "tighten" visibility for cleanliness.
Property hooks vs. set_/get_ methods conflict (PHP 8.4). If a property has a set hook, the matching set_* method is ignored during magic access; if it has a get hook, the get_* method is used. Mixing both in one property is a silent behavior change, and public properties never trigger magic access at all while still triggering hooks. Pick one mechanism per property and stay consistent.
toJSON() throws JsonException regardless of options as of 3.2.1. Do not rely on JSON_PARTIAL_OUTPUT_ON_ERROR or a false return; wrap in try/catch if the payload can contain non-UTF-8 (e.g. binary or user-supplied strings).
Unserialization skips everything. unserialize() writes raw properties, bypassing setters, getters, and hooks. Treat any serialize() output as untrusted/short-lived and prefer toArray() + constructor rehydration for anything persisted.
Debugging inside the container is awkward. Because __get intercepts access, var_dump($container) shows raw protected properties but a debugger's "evaluate" pane may invoke your getters. When behavior looks wrong, dump $container->toArray() — it reflects the getter-processed state and is the fastest way to see what the rest of the app actually receives.
Defaults live on the property declaration, not in construct(). Set defaults inline (protected int $ttl = 3600;) so new Container and new Container([]) behave identically; doing it in the trait constructor means an explicitly-passed null can clobber your default.
Version drift. The 3.2.0 change to toArray() (now running values through the getter) and 3.3.0's hook handling are both behavioral, not just cosmetic. If you upgrade across those lines, add a test that asserts toArray() output for containers with custom getters.
How can I help you explore Laravel packages today?