Development happens on gitlab.castris.com. The GitHub repository is a read-only distribution mirror: issues and pull requests opened there are not seen.
A framework-agnostic, fully testable, SOLID-compliant mustache template resolver for PHP applications with first-class Laravel integration.
- Simple field resolution:
{{User.name}} - Relation navigation:
{{User.department.manager.name}} - Dynamic fields:
{{Device.$manufacturer.field_parameter}} - Collection access:
{{User.posts.0.title}},{{User.addresses.*.city}} - Built-in functions:
{{now()}},{{format(User.date, 'Y-m-d')}} - Null coalescing:
{{User.nickname ?? 'Anonymous'}} - Framework-agnostic core with optional Laravel integration
- 100% testable without database
| Package version | PHP | Laravel | Security default | Status |
|---|---|---|---|---|
| 3.x | 8.2, 8.3, 8.4 | 12.x, 13.x | enforce |
Active development |
| 2.x | 8.2, 8.3, 8.4 | 12.x, 13.x | report |
New vulnerabilities (high, or any severity on template data exposure) + Laravel compatibility until 2027-02-03 (see UPGRADE-3.md) |
| 1.x | 8.2, 8.3, 8.4 | 10.x, 11.x, 12.x | none | End of life |
Upgrading from 2.x? Read UPGRADE-3.md first — v3 enforces by default, blocks whole-container serialization, and changes the types in getResolvedValues(). A config published under v2 keeps its own mode (you must flip it to enforce yourself); absent v3 keys are filled with safe defaults at runtime, with a boot warning naming them.
- PHP 8.2+
- Laravel 12.x or 13.x (optional)
composer require aichadigital/laravel-mustache-resolverThe package auto-discovers the service provider. Optionally publish the config:
php artisan vendor:publish --tag="mustache-resolver-config"use AichaDigital\MustacheResolver\Core\MustacheResolver;
use AichaDigital\MustacheResolver\Core\Parser\MustacheParser;
use AichaDigital\MustacheResolver\Core\Pipeline\PipelineBuilder;
use AichaDigital\MustacheResolver\Cache\NullCache;
// Secure by default (v3): building without a validator applies the DEFAULT
// POLICY — enforce mode, the default blacklists and patterns, containers
// blocked, parse ceilings on. It carries no reporter, so it blocks silently.
$resolver = new MustacheResolver(
new MustacheParser(),
PipelineBuilder::create()->build(),
new NullCache()
);
// To see what the policy does, pass a validator with a reporter:
use AichaDigital\MustacheResolver\Core\Security\SecurityValidator;
$reported = SecurityValidator::defaultPolicy(
reporter: fn (string $message, array $context) => error_log($message)
);
$resolver = new MustacheResolver(
new MustacheParser(),
PipelineBuilder::create()->build(),
new NullCache(),
$reported,
);
// Opting out is explicit — null no longer means "no policy":
$off = new SecurityValidator(mode: SecurityValidator::MODE_OFF);
$unprotected = new MustacheResolver(
new MustacheParser(),
PipelineBuilder::create()->build(),
new NullCache(),
$off,
);use AichaDigital\MustacheResolver\Laravel\Facades\Mustache;
$template = "Hello, {{User.name}}! Your email is {{User.email}}.";
$user = User::find(1);
$result = Mustache::translate($template, $user);
if ($result->isSuccess()) {
echo $result->getTranslated();
// "Hello, John! Your email is john@example.com."
}$template = "Manager: {{User.department.manager.name}}";
$result = Mustache::translate($template, $user);// Access by index
$template = "First post: {{User.posts.0.title}}";
// Access first/last
$template = "Latest: {{User.posts.last.title}}";
// Wildcard (returns array)
$template = "All cities: {{User.addresses.*.city}}";$template = "Report for {{$period}}: {{User.name}}";
$result = Mustache::translate($template, $user, ['period' => '2024-Q1']);$templates = [
"Name: {{User.name}}",
"Email: {{User.email}}",
"Department: {{User.department.name}}",
];
$results = Mustache::translateBatch($templates, $user);// Missing fields return empty string instead of failing
$result = Mustache::translate($template, $user, [], strict: false);// config/mustache-resolver.php
return [
'strict' => true, // Throw on unresolvable mustaches
'keep_unresolved' => false, // Keep mustaches if not resolved (non-strict)
'cache' => [
'enabled' => false,
'ttl' => 3600,
],
'security' => [
'mode' => 'enforce', // 'off' | 'report' | 'enforce' (v3 default: enforce)
'allowed_root_models' => [], // FQCN-only, root model only; [] = all
'max_depth' => 10,
'allow_container_serialization' => false, // arrays/Collections only, never Models
'blacklisted_attributes' => ['password', 'remember_token', 'api_token', 'secret'],
'blacklisted_patterns' => ['*_token', '*_secret', '*_key', '*password*', '*_hash', 'otp', 'pin', 'cvv'],
'limits' => [
'max_template_length' => 100000, // bytes; null/empty = unlimited
'max_tokens' => 1000,
],
],
];v3 enforces by default. Every resolved value passes two barriers: the accessors
validate each path before data is touched (barrier 1 — it also prevents the lazy
relation query), and an OutputSanitizer downstream of every resolver decides what
reaches both the rendered text and getResolvedValues() (barrier 2 — a resolver
cannot bypass it). Building any entry point without a validator applies the
default policy; opting out requires an explicit mode: 'off'.
The security.mode setting controls what happens on a violation:
enforce(default): blacklisted paths resolve to empty/null, whole containers are blocked unless escaped, over-limit templates throwSecurityException, disallowed root models throwModelNotAllowedException. Blocked attempts are logged as an audit trail.report: every violation is logged viaLog::warning()and resolution proceeds unchanged — output, identity and types stay exactly as with security off. Use it as the measuring tool before flipping toenforce.off: no checks are applied, and objects you hand in are returned untouched.
What the policy covers:
- Paths: every segment of a dot-notation path is checked against
blacklisted_attributes(exact, case-insensitive) andblacklisted_patterns(glob, case-insensitive) — a blacklisted attribute is also blocked behind a relation ({{User.relationship.password}}) and inside collection tokens ({{User.posts.*.author.password}}). Paths deeper thanmax_depthare rejected. - Containers: a token resolving to a whole
Model,Collection, array orArrayable/Traversable/JsonSerializableis blocked by default. Escapes:allow_container_serialization(plain arrays/Collections only) or theSafeForTemplateSerializationinterface (the only way aModelopts in). Authorised containers are still filtered recursively and depth-pruned. - Objects: only
DateTimeInterface(Carbon), enums and classes markedSafeForTemplateSerializationcount as atomic scalars. Any other object —Stringableincluded — takes the container gate: an opaque__toString()is not trust. - Ceilings:
security.limitsbounds template length (bytes) and token count at parse time, inenforceonly, for the main path and compoundUSEexpressions alike. - Your own accessors/contexts: a
DataAccessorInterfaceorContextInterfacehanded straight totranslate()is decorated with the resolver's policy (never weakening the accessor's own), because two token types (??defaults and$dynamicfields) carry no static path for barrier 2 to re-check.
Consumer-registered resolvers are trusted code, outside the threat model —
the policy defends against data exposure through templates, not against code you
installed. Repeated violations of the same path are logged once per request/job
cycle. An invalid security.mode value fails closed (enforce) with a warning,
and the boot warning always states the effective mode.
Upgrading from 2.x: the full break-by-break list, the observation procedure and the rollback path live in UPGRADE-3.md.
use AichaDigital\MustacheResolver\Contracts\ResolverInterface;
class CustomResolver implements ResolverInterface
{
public function supports(TokenInterface $token, ContextInterface $context): bool
{
return $token->getPrefix() === 'Custom';
}
public function resolve(TokenInterface $token, ContextInterface $context): mixed
{
// Your resolution logic
}
public function priority(): int
{
return 150; // Higher than built-in resolvers
}
public function name(): string
{
return 'custom';
}
}Register in config:
'resolvers' => [
\App\Resolvers\CustomResolver::class,
],composer testPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The AGPL-3.0-or-later License. Please see License File for more information.