Member::loggedIn()->language()->addToStack( 'my_key' ) looks like a function that returns a translated string. Sometimes it is. Often it returns a deferred placeholder that is substituted later, when the output is rendered.
What actually happens
If the language words are already loaded, you get real text. If they are not, the key is added to an output stack and a placeholder comes back:
public function addToStack( string $key, ?bool $vle = TRUE, array $options = array() ): string
Rendering later calls parseOutputForDisplay(), which walks the output and swaps placeholders for text. Note its signature — it takes the output by reference:
public function parseOutputForDisplay( mixed &$output ): void
The trap: storing the result
Anything that takes the return value and puts it somewhere that is not rendered through the normal output path stores the placeholder. Written to the database, it stays a placeholder for ever:
// WRONG - may store a placeholder, not a title $title = $lang->addToStack( 'myapp_generated_title', FALSE, array( 'sprintf' => array( $name ) ) ); Post::create( $item, $title ); // RIGHT - force resolution first $title = $lang->addToStack( 'myapp_generated_title', FALSE, array( 'sprintf' => array( $name ) ) ); $lang->parseOutputForDisplay( $title ); Post::create( $item, $title );
This bites hardest in background tasks and queue jobs, where no page is being rendered and nothing calls the parser for you.
The second trap: escaping the placeholder is not escaping the text
If a value is interpolated into a language string, escaping the result of addToStack() achieves nothing — at that moment the result may be a placeholder, and the real value is substituted raw afterwards.
// WRONG - escapes a placeholder; the title is substituted raw later $out = htmlspecialchars( $lang->addToStack( 'x_posted_y', FALSE, array( 'sprintf' => array( $userTitle ) ) ) ); // RIGHT - escape the ARGUMENT going in $out = $lang->addToStack( 'x_posted_y', FALSE, array( 'sprintf' => array( htmlspecialchars( $userTitle, ENT_QUOTES, 'UTF-8' ) ) ) );
Escape what goes into the string, never what comes out.
Verified against
Invision Community 5.0.19, by reading system/Lang/Lang.php.
Recommended Comments