Skip to content

Conversation

@henriquemoody
Copy link
Member

While basic template interpolation is useful, we know that a one-size-fits-all stringification approach isn't always desirable. Sometimes you need to bypass the standard formatting logic or transform a value before it hits the final string. This commit introduces a flexible modifier system to solve that.

I’ve adopted the familiar {{value|modifier}} syntax found in many popular template engines. To handle this transformation, I implemented a Chain of Responsibility pattern. This allows us to pipe values through specific logic, such as the new |raw modifier, which provides clean scalar output by bypassing the default stringifier formatting.

The StringifyModifier remains the default fallback, ensuring that we don't lose the robust type-handling we previously built. By separating these transformations into an extensible modifier interface, we’ve made the system future-proof—users can now plug in custom transformations without touching the core formatting logic.

This change turns our simple placeholder replacement into a proper interpolation engine, giving developers fine-grained control over how their data is presented.

Assisted-by: OpenCode (GLM-4.6)

@codecov-commenter
Copy link

codecov-commenter commented Jan 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.22%. Comparing base (f5f216a) to head (7641c33).

Additional details and impacted files
@@             Coverage Diff              @@
##               main       #2      +/-   ##
============================================
+ Coverage     99.04%   99.22%   +0.18%     
- Complexity       97      127      +30     
============================================
  Files             4       10       +6     
  Lines           210      259      +49     
============================================
+ Hits            208      257      +49     
  Misses            2        2              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request introduces a modifier system to PlaceholderFormatter, enabling fine-grained control over value transformations through a {{value|modifier}} syntax. The implementation uses a Chain of Responsibility pattern where modifiers can either handle transformations or pass values to the next modifier in the chain.

Changes:

  • Introduced Modifier interface and two implementations: RawModifier (for clean scalar output) and StringifyModifier (default fallback)
  • Updated PlaceholderFormatter to parse and apply modifiers using regex pattern /{{(\w+)(\|([^}]+))?}}/
  • Added comprehensive documentation for modifiers including usage examples and custom modifier creation guide

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
src/Modifier.php New interface defining the modifier contract with modify() method
src/Modifier/RawModifier.php Implements raw modifier for scalar values, bypassing stringifier formatting
src/Modifier/StringifyModifier.php Wrapper modifier delegating to stringifier for value conversion
src/PlaceholderFormatter.php Updated to parse modifier syntax and apply modifier chain instead of direct stringifier
tests/Unit/PlaceholderFormatterTest.php Updated test to verify custom modifier support
tests/Unit/Formatter/RawModifierTest.php Comprehensive test suite for RawModifier behavior
tests/Helper/TestingModifier.php Test helper implementing Modifier interface for testing
docs/PlaceholderFormatter.md Updated with modifier documentation and corrected examples
docs/modifiers/Modifiers.md New overview documentation for modifier system
docs/modifiers/RawModifier.md Detailed documentation for RawModifier
docs/modifiers/StringifyModifier.md Detailed documentation for StringifyModifier
docs/modifiers/CreatingCustomModifiers.md Guide for implementing custom modifiers

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +52 to +56

// Create the modifier chain
$modifierChain = new UppercaseModifier(
new RawModifier(
new StringifyModifier()
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The StringifyModifier is instantiated without passing the required stringifier parameter. According to the constructor in src/Modifier/StringifyModifier.php (line 12-14), it requires a Stringifier instance. These examples should either pass a stringifier (e.g., new StringifyModifier(HandlerStringifier::create())) or explain that this is pseudo-code.

Suggested change
// Create the modifier chain
$modifierChain = new UppercaseModifier(
new RawModifier(
new StringifyModifier()
use Respect\StringFormatter\Stringifier\HandlerStringifier;
// Create the modifier chain
$modifierChain = new UppercaseModifier(
new RawModifier(
new StringifyModifier(HandlerStringifier::create())

Copilot uses AI. Check for mistakes.
// Usage with parameter
$formatter = new PlaceholderFormatter(
['longText' => 'This is a very long piece of text'],
new TruncateModifier(new StringifyModifier())
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The StringifyModifier is instantiated without passing the required stringifier parameter. According to the constructor in src/Modifier/StringifyModifier.php (line 12-14), it requires a Stringifier instance. This should be new TruncateModifier(new StringifyModifier(HandlerStringifier::create())).

Copilot uses AI. Check for mistakes.
]);

echo $formatter->format('{{name}} is {{age}} years old and {{active}}');
// Outputs: "John is "30" years old and "true""
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected output shows "John is "30" years old and "true"" but according to the code logic in PlaceholderFormatter.php (lines 63-64), string values without a pipe modifier are returned as-is. Since 'John' is a string and {{name}} has no pipe modifier, it should output as John without quotes. The expected output should be "John is "30" years old and "true"" (no quotes around John).

Suggested change
// Outputs: "John is "30" years old and "true""
// Outputs: John is "30" years old and "true"

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +21
final readonly class StringifyModifier implements Modifier
{
public function __construct(
private Stringifier $stringifier,
) {
}

public function modify(mixed $value, string|null $pipe): string
{
return $this->stringifier->stringify($value);
}
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no test coverage for StringifyModifier. Given that this is a new class being introduced and RawModifier has comprehensive tests, StringifyModifier should also have test coverage to ensure its behavior is properly validated, especially its interaction with the stringifier.

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +70
| Parameters | Template | Output |
| ------------------------- | ------------------------------- | ------------------------- |
| `['count' => 42]` | `"Count: {{count|raw}}"` | `"Count: 42"` |
| `['name' => 'John']` | `"Name: {{name|raw}}"` | `"Name: John"` |
| `['active' => true]` | `"Active: {{active|raw}}"` | `"Active: 1"` |
| `['items' => ['a', 'b']]` | `"Items: {{items|raw}}"` | `"Items: [\"a\", \"b\"]"` |
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table has a formatting error with misaligned column separators. The pipe character in the template column (which is part of the placeholder syntax {{count|raw}}) is being interpreted as a table separator, creating an extra column. The template examples should use HTML entity encoding (|) for the pipe character, similar to how it's done in RawModifier.md lines 52-56.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +33
echo $formatter->format('Hello {{name}}, you are {{age}} years old.');
// Outputs: "Hello --John--, you are --30-- years old."
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example uses the wrong parameter in the format call. The formatter is constructed with ['count' => 42, 'name' => 'John'], but the format string attempts to use {{age}} which doesn't exist in the parameters. This should likely be {{count}} to match the parameters array.

Suggested change
echo $formatter->format('Hello {{name}}, you are {{age}} years old.');
// Outputs: "Hello --John--, you are --30-- years old."
echo $formatter->format('Hello {{name}}, you are {{count}} years old.');
// Outputs: "Hello --John--, you are --42-- years old."

Copilot uses AI. Check for mistakes.

| Type | Without `raw` | With `raw` |
| ------ | ------------------- | ------------------- |
| string | `hello``"hello"` | `hello``hello` |
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Without raw" column shows string values getting quotes (hello"hello"), but according to PlaceholderFormatter.php (lines 63-64), string values without a pipe modifier are returned as-is. The table is misleading about default behavior for strings.

Suggested change
| string | `hello``"hello"` | `hello``hello` |
| string | `hello``hello` | `hello``hello` |

Copilot uses AI. Check for mistakes.
| int | `"42"` | `"42"` |
| bool | `"true"` | `"1"` |
| array | `["a"]``"["a"]"` | Passes through |
| null | `"`null`"` | `"`null`"` |
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comparison table entry for null shows "null" for both StringifyModifier and RawModifier, which is technically correct but potentially misleading. It would be clearer to indicate that RawModifier passes null through to StringifyModifier (since null is not scalar), rather than suggesting they handle it identically by showing the same output for both. Consider changing RawModifier column for null to "Passes through" to match the pattern used for arrays.

Suggested change
| null | `"`null`"` | `"`null`"` |
| null | `"`null`"` | Passes through |

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +19
| Type | Without `raw` | With `raw` |
| ------ | ------------------- | ------------------- |
| string | `hello``"hello"` | `hello``hello` |
| int | `42``"42"` | `42``"42"` |
| float | `19.99``"19.99"` | `19.99``"19.99"` |
| bool | `true``"true"` | `true``"1"` |

Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Without raw" column shows hello"hello" for strings, but according to the PlaceholderFormatter.php logic (lines 63-64), string values without a pipe modifier are returned as-is without going through the modifier chain. So {{name}} where name is a string would output the string directly without quotes. The table should clarify that "Without raw" means using no modifier at all (which for strings returns them as-is), not that it forces them through StringifyModifier.

Suggested change
| Type | Without `raw` | With `raw` |
| ------ | ------------------- | ------------------- |
| string | `hello``"hello"` | `hello``hello` |
| int | `42``"42"` | `42``"42"` |
| float | `19.99``"19.99"` | `19.99``"19.99"` |
| bool | `true``"true"` | `true``"1"` |
| Type | Without `raw` | With `raw` |
| ------ | ----------------- | ------------------- |
| string | `hello``hello` | `hello``hello` |
| int | `42``"42"` | `42``"42"` |
| float | `19.99``"19.99"` | `19.99``"19.99"` |
| bool | `true``"true"` | `true``"1"` |
**Note:** In this table, “Without `raw`” means using no modifier at all (e.g. `{{name}}`). For strings, this returns the value directly without sending it through the modifier chain, so no extra quotes are added. Non-scalar values still follow the default modifier chain.

Copilot uses AI. Check for mistakes.
use Respect\StringFormatter\Modifier\RawModifier;
use Respect\StringFormatter\Test\Helper\TestingModifier;

final class RawModifierTest extends TestCase
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test class is missing the #[CoversClass(RawModifier::class)] attribute that's used in other test classes like PlaceholderFormatterTest. This attribute should be added for consistency and proper code coverage reporting.

Copilot uses AI. Check for mistakes.
@henriquemoody
Copy link
Member Author

@copilot open a new pull request to apply changes based on the comments in this thread

@henriquemoody henriquemoody marked this pull request as draft January 19, 2026 15:45
henriquemoody and others added 4 commits January 19, 2026 19:03
While basic template interpolation is useful, we know that a one-size-fits-all
stringification approach isn't always desirable. Sometimes you need to bypass the
standard formatting logic or transform a value before it hits the final string.
This commit introduces a flexible modifier system to solve that.

I’ve adopted the familiar `{{value|modifier}}` syntax found in many popular
template engines. To handle this transformation, I implemented a Chain of
Responsibility pattern. This allows us to pipe values through specific logic,
such as the new `|raw` modifier, which provides clean scalar output by
bypassing the default stringifier formatting.

The `StringifyModifier` remains the default fallback, ensuring that we don't
lose the robust type-handling we previously built. By separating these
transformations into an extensible modifier interface, we’ve made the system
future-proof—users can now plug in custom transformations without touching
the core formatting logic.

This change turns our simple placeholder replacement into a proper
interpolation engine, giving developers fine-grained control over how their
data is presented.

Assisted-by: OpenCode (GLM-4.6)
Implement flexible ListModifier that formats arrays into human-readable
lists with configurable conjunctions (and/or), replacing the limited
ListAndModifier approach.
Port QuoteModifier from Validation project to StringFormatter following
the modifier pattern. Adapts validation logic to transformation logic
using Stringifier Quoter instead of Quoted placeholder.

- Implements Chain of Responsibility pattern with proper delegation
- Uses CodeQuoter for consistent string escaping behavior
- Includes comprehensive test suite with TestingQuoter helper
- Updates AGENTS.md with modifier development guidelines
Implement translation support using symfony/translation with
TranslatorInterface dependency injection. Supports simple 'trans'
pipe modifier for string translation following established
modifier patterns.

- Create TransModifier implementing Modifier interface
- Add TestingTranslator for isolated test dependencies
- Follow Chain of Responsibility pattern with proper delegation
- Support symfony/translation-contracts for interface compliance
- Maintain PSR-12 compliance and comprehensive test coverage

Co-authored-by: AI Assistant <ai@example.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants