dflydev/placeholder-resolver
A lightweight PHP library for resolving placeholders such as %key% in strings using array or custom data sources. It supports recursive substitution, nested lookups, and circular-reference detection, making it useful for configuration and templating.
Install
composer require dflydev/placeholder-resolver
The two classes that matter
Dflydev\PlaceholderResolver\DataSource\DataSourceInterface — the "where do values come from" abstraction (exists($name) / get($name)).Dflydev\PlaceholderResolver\Resolver\Resolver — walks ${name} tokens, asks the data source, and recurses into the returned value.If you learn only these two, you've learned the package.
Minimal working example
use Dflydev\PlaceholderResolver\DataSource\ArrayDataSource;
use Dflydev\PlaceholderResolver\Resolver\Resolver;
$source = new ArrayDataSource([
'app.host' => 'example.test',
'app.port' => '8080',
'app.url' => 'https://${app.host}:${app.port}',
]);
$resolver = new Resolver($source);
echo $resolver->resolvePlaceholder('${app.url}');
// https://example.test:8080
The magic is the recursion: app.url resolves to a string containing two more placeholders, and the resolver keeps going until the string is fully expanded.
Where to look first
README.md — the API surface is tiny and fully documented there.src/Resolver/Resolver.php — the actual token regex, recursion loop, and circular-reference handling. Read this before you build anything non-trivial.src/DataSource/ — see what data sources ship with the package before writing your own.First use case: a JSON/YAML config file with internal references
This is the sweet spot. A tenant settings file, a feature-flag manifest, or a config/services.json where you don't want to repeat the same host and port five times:
{
"host": "api.example.test",
"port": 443,
"base_url": "https://${host}:${port}",
"webhook_url": "${base_url}/hooks"
}
Decode it, feed it to ArrayDataSource, resolve the keys you care about, then config()->set(...) or hand it to your settings service. No custom string parsing, no accidental infinite loops.
ArrayDataSource uses literal key lookups. If you want dot-nested access to Laravel's config repository, wrap it:
namespace App\Support;
use Dflydev\PlaceholderResolver\DataSource\DataSourceInterface;
class ConfigDataSource implements DataSourceInterface
{
public function exists($name)
{
return config()->has($name);
}
public function get($name)
{
return config($name);
}
}
Now ${app.url}, ${database.connections.mysql.host}, and ${services.stripe.key} all work without flattening anything.
The interface is deliberately trivial, so layering sources is easy:
class ChainDataSource implements DataSourceInterface
{
/** @var DataSourceInterface[] */
private array $sources;
public function __construct(array $sources)
{
$this->sources = $sources;
}
public function exists($name)
{
foreach ($this->sources as $source) {
if ($source->exists($name)) {
return true;
}
}
return false;
}
public function get($name)
{
foreach ($this->sources as $source) {
if ($source->exists($name)) {
return $source->get($name);
}
}
return null;
}
}
Order is precedence: first match wins. This is how you get tenant overrides (tenant settings → app config → env) without touching your templates.
There is no Laravel auto-discovery — register it yourself and inject the interface, not the concrete class:
use Dflydev\PlaceholderResolver\Resolver\PlaceholderResolverInterface;
use Dflydev\PlaceholderResolver\Resolver\Resolver;
public function register()
{
$this->app->singleton(PlaceholderResolverInterface::class, function () {
return new Resolver(new \App\Support\ConfigDataSource());
});
}
Injecting PlaceholderResolverInterface keeps tests honest: in tests, swap in a resolver backed by ArrayDataSource and you never touch config().
If you have a resolved settings tree, resolve it once at boot and cache the result in the container or config():
$raw = json_decode(file_get_contents(base_path('config/tenant.json')), true);
$resolver = new Resolver(new ArrayDataSource($raw));
$settings = $resolver->resolvePlaceholders($raw); // keeps keys
config(['tenant' => $settings]);
Doing this in register() means it participates in php artisan config:cache and you pay the cost once.
Where this genuinely beats str_replace: user- or admin-authored templates stored in a settings table, e.g. email subjects, webhook bodies, invoice footers:
$template = Setting::get('invoice.footer');
// "Thanks for your order, ${customer.name}. Support: ${support.email}"
echo $resolver->resolvePlaceholder($template);
Back the data source with a per-request array built from the Customer model and your support config. You get nested resolution and cycle detection for free.
Because DataSourceInterface is two methods, it's a natural decorator seam. Wrap it to log misses, cache DB lookups, or redact secret keys during local debugging — without touching the resolver or your call sites.
If your application has a "settings" or "template" concept, depend on PlaceholderResolverInterface rather than sprinkling preg_replace calls through the codebase. It gives you one place to change delimiter handling, one place to add observability, and one thing to mock.
Missing keys are silent. If the data source reports exists() === false, the resolver leaves the ${...} token untouched rather than throwing. That means a typo in ${custmer.name} ships to production as a literal. After resolving admin-authored templates, assert the output no longer contains ${ — or scan for it in tests.
Circular references blow up. a => ${b}, b => ${a} (or a => ${a}) raises a CircularReferenceException from the resolver namespace (verify the exact FQCN in src/Resolver for your installed version). Catch it at boot and fail fast — an exception during config load is far better than a half-resolved URL in production. Add a unit test with a deliberate cycle.
ArrayDataSource does not understand dot notation. It is a flat lookup. ['app' => ['url' => ...]] will not satisfy ${app.url}. Either flatten with Arr::dot() or use a config-backed data source like the one above.
Values are assumed to be strings. If get() returns an array or object, the resolver's string replacement will produce "Array to string conversion" notices and garbage. Make your data source cast scalars and explicitly reject/handle non-scalars. true/false/null deserve thought too — decide whether they become "1", "", or stay unresolved.
There is no escape mechanism. Any ${...} in the input is treated as a placeholder. If templates come from users, they can reference config keys they shouldn't see — a real secret-leak vector if the resolver is backed by ConfigDataSource. Allowlist the keys you expose (a dedicated data source with a fixed map is safer than the whole config repository) or strip ${ from user input.
Don't cache mutable resolutions. If you memoize resolved values, remember that the data source is live. A resolver cached on a singleton holding request-specific data (the current customer, the current tenant) will leak across requests under Octane. Build the data source per request, or key your cache on the tenant/user.
Resolution is lazy and order-independent within a data source. ${base_url} can be defined before or after ${host}; the data source is queried on demand. But this only holds for values inside the data source. If you resolve a plain PHP array element by element without the source containing the referenced keys, cross-references won't work. Put the whole tree in the data source first.
Old package, thin maintenance. It's stable and small, but it predates modern PHP: no scalar type hints, no strict types, and the API may predate PHP 8. It works, but check the composer.json PHP constraint before upgrading to a new PHP version, and don't expect typed signatures in your IDE without docblocks.
Performance is per-string recursion. Fine for a few hundred config keys at boot; wrong for resolving every row of a paginated result set. Resolve once, cache, and keep the resolver out of hot loops.
Testing is trivial — exploit it. ArrayDataSource plus Resolver needs no framework, no container, and no database. For any feature that involves templating, write plain PHPUnit tests against the resolver directly; reserve Laravel-level tests for the service-provider wiring.
Extension points: implement DataSourceInterface (DB, Vault, feature flags, request context), decorate an existing source for caching/logging, and subclass Resolver only if you need a different token pattern — check whether the class is final in your version first.
How can I help you explore Laravel packages today?