aichadigital / laravel-mustache-resolver
Framework-agnostic, fully testable, SOLID-compliant mustache template resolver for PHP applications with first-class Laravel integration
Requires
- php: ^8.2
- dragonmantank/cron-expression: ^3.4
- illuminate/contracts: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
Requires (Dev)
- infection/infection: ^0.31.9
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^3.0||^4.0
- pestphp/pest-plugin-arch: ^3.0||^4.0
- pestphp/pest-plugin-laravel: ^3.0||^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
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.
Features
- 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
Compatibility
| 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.
Requirements
- PHP 8.2+
- Laravel 12.x or 13.x (optional)
Installation
composer require aichadigital/laravel-mustache-resolver
Laravel
The package auto-discovers the service provider. Optionally publish the config:
php artisan vendor:publish --tag="mustache-resolver-config"
Standalone (without Laravel)
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, );
Usage
Basic Usage with Laravel Facade
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." }
Relation Navigation
$template = "Manager: {{User.department.manager.name}}"; $result = Mustache::translate($template, $user);
Collection Access
// 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}}";
With Variables
$template = "Report for {{$period}}: {{User.name}}"; $result = Mustache::translate($template, $user, ['period' => '2024-Q1']);
Batch Processing
$templates = [ "Name: {{User.name}}", "Email: {{User.email}}", "Department: {{User.department.name}}", ]; $results = Mustache::translateBatch($templates, $user);
Non-strict Mode
// Missing fields return empty string instead of failing $result = Mustache::translate($template, $user, [], strict: false);
Configuration
// 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, ], ], ];
Security
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.
Custom Resolvers
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, ],
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The AGPL-3.0-or-later License. Please see License File for more information.