Recetas de Twig

Mostrar Avisos de Obsolescencia

Las características obsoletas generan avisos de obsolescencia (mediante una llamada a la función PHP trigger_error()). Por defecto, se silencian y nunca se muestran ni registran.

Para eliminar todos los usos de características obsoletas de tus plantillas, escribe y ejecuta un script similar al siguiente:

require_once __DIR__.'/vendor/autoload.php';

$twig = create_your_twig_env();

$deprecations = new \Twig\Util\DeprecationCollector($twig);

print_r($deprecations->collectDir(__DIR__.'/templates'));

El método collectDir() compila todas las plantillas encontradas en un directorio, captura los avisos de obsolescencia y los devuelve.

Tip

Si tus plantillas no se almacenan en el sistema de archivos, usa el método collect() en su lugar. collect() toma un Traversable que debe devolver nombres de plantilla como claves y contenidos de plantilla como valores (como lo hace \Twig\Util\TemplateDirIterator).

Sin embargo, este código no encontrará todas las obsolescencias (como usar algunas clases de Twig obsoletas). Para capturar todos los avisos, registra un manejador de errores personalizado como el siguiente:

$deprecations = [];
set_error_handler(function ($type, $msg) use (&$deprecations) {
    if (E_USER_DEPRECATED === $type) {
        $deprecations[] = $msg;
    }
});

// ejecuta tu aplicación

print_r($deprecations);

Ten en cuenta que la mayoría de los avisos de obsolescencia se activan durante la compilación, por lo que no se generarán cuando las plantillas ya estén en caché.

Tip

Si quieres gestionar los avisos de obsolescencia desde tus tests de PHPUnit, echa un vistazo al paquete symfony/phpunit-bridge, que facilita el proceso.

Hacer un Layout Condicional

Trabajar con Ajax significa que el mismo contenido a veces se muestra tal cual, y a veces decorado con un layout. Como los nombres de plantillas de layout de Twig pueden ser cualquier expresión válida, puedes pasar una variable que evalúe a true cuando la solicitud se realice mediante Ajax y elegir el layout en consecuencia:

{% extends request.ajax ? "base_ajax.html.twig" : "base.html.twig" %}

{% block content %}
    This is the content to be displayed.
{% endblock %}

Hacer una Inclusión Dinámica

Al incluir una plantilla, su nombre no necesita ser una cadena. Por ejemplo, el nombre puede depender del valor de una variable:

{% include var ~ '_foo.html.twig' %}

Si var se evalúa como index, se renderizará la plantilla index_foo.html.twig.

De hecho, el nombre de la plantilla puede ser cualquier expresión válida, como la siguiente:

{% include var|default('index') ~ '_foo.html.twig' %}

Anular una Plantilla que También se Extiende a Sí Misma

Una plantilla se puede personalizar de dos maneras diferentes:

  • Herencia: Una plantilla extiende una plantilla principal y anula algunos bloques;
  • Reemplazo: Si usas el cargador del sistema de archivos, Twig carga la primera plantilla que encuentra en una lista de directorios configurados; una plantilla encontrada en un directorio reemplaza a otra de un directorio más adelante en la lista.

Pero, ¿cómo combinas ambas: reemplazar una plantilla que también se extiende a sí misma (también conocida como una plantilla en un directorio más adelante en la lista)?

Supongamos que tus plantillas se cargan tanto desde .../templates/mysite como desde .../templates/default en este orden. La plantilla page.html.twig, almacenada en .../templates/default se lee como sigue:

{# page.html.twig #}
{% extends "layout.html.twig" %}

{% block content %}
{% endblock %}

Puedes reemplazar esta plantilla poniendo un archivo con el mismo nombre en .../templates/mysite. Y si quieres extender la plantilla original, podrías sentirte tentado a escribir lo siguiente:

{# page.html.twig en .../templates/mysite #}
{% extends "page.html.twig" %} {# desde .../templates/default #}

Sin embargo, esto no funcionará ya que Twig siempre cargará la plantilla desde .../templates/mysite.

Resulta que es posible hacer que esto funcione, agregando un directorio justo al final de tus directorios de plantillas, que sea el padre de todos los demás directorios: .../templates en nuestro caso. Esto tiene el efecto de hacer que cada archivo de plantilla dentro de nuestro sistema sea direccionable de forma única. La mayoría de las veces usarás las rutas "normales", pero en el caso especial de querer extender una plantilla con una versión que la anula, podemos referenciar la ruta de plantilla completa y sin ambigüedades de su padre en la etiqueta extends:

{# page.html.twig en .../templates/mysite #}
{% extends "default/page.html.twig" %} {# desde .../templates #}

Note

Esta receta fue inspirada por la siguiente página del wiki de Django: https://code.djangoproject.com/wiki/ExtendingTemplates

Personalizar la Sintaxis

Twig permite cierta personalización de sintaxis para los delimitadores de bloques. No se recomienda usar esta característica ya que las plantillas quedarán vinculadas con tu sintaxis personalizada. Pero para proyectos específicos, puede tener sentido cambiar los valores predeterminados.

Para cambiar los delimitadores de bloques, necesitas crear tu propio objeto lexer:

$twig = new \Twig\Environment(...);

$lexer = new \Twig\Lexer($twig, [
    'tag_comment'   => ['{#', '#}'],
    'tag_block'     => ['{%', '%}'],
    'tag_variable'  => ['{{', '}}'],
    'interpolation' => ['#{', '}'],
]);
$twig->setLexer($lexer);

Aquí hay algunos ejemplos de configuración que simulan la sintaxis de otros motores de plantillas:

// Sintaxis Ruby erb
$lexer = new \Twig\Lexer($twig, [
    'tag_comment'  => ['<%#', '%>'],
    'tag_block'    => ['<%', '%>'],
    'tag_variable' => ['<%=', '%>'],
]);

// Sintaxis de Comentario SGML
$lexer = new \Twig\Lexer($twig, [
    'tag_comment'  => ['<!--#', '-->'],
    'tag_block'    => ['<!--', '-->'],
    'tag_variable' => ['${', '}'],
]);

// Estilo Smarty
$lexer = new \Twig\Lexer($twig, [
    'tag_comment'  => ['{*', '*}'],
    'tag_block'    => ['{', '}'],
    'tag_variable' => ['{$', '}'],
]);

Usar Propiedades de Objeto Dinámicas

Cuando Twig encuentra una variable como article.title, intenta encontrar una propiedad pública title en el objeto article.

También funciona si la propiedad no existe sino que se define dinámicamente gracias al método mágico __get(); necesitas implementar también el método mágico __isset() como se muestra en el siguiente fragmento de código:

class Article
{
    public function __get($name)
    {
        if ('title' == $name) {
            return 'The title';
        }

        // lanzar algún tipo de error
    }

    public function __isset($name)
    {
        if ('title' == $name) {
            return true;
        }

        return false;
    }
}

Acceder al Contexto Principal en Bucles Anidados

A veces, cuando usas bucles anidados, necesitas acceder al contexto principal. El contexto principal siempre es accesible a través de la variable loop.parent. Por ejemplo, si tienes los siguientes datos de plantilla:

$data = [
    'topics' => [
        'topic1' => ['Message 1 of topic 1', 'Message 2 of topic 1'],
        'topic2' => ['Message 1 of topic 2', 'Message 2 of topic 2'],
    ],
];

Y la siguiente plantilla para mostrar todos los mensajes en todos los temas:

{% for topic, messages in topics %}
    * {{ loop.index }}: {{ topic }}
  {% for message in messages %}
      - {{ loop.parent.loop.index }}.{{ loop.index }}: {{ message }}
  {% endfor %}
{% endfor %}

La salida será similar a:

* 1: topic1
  - 1.1: The message 1 of topic 1
  - 1.2: The message 2 of topic 1
* 2: topic2
  - 2.1: The message 1 of topic 2
  - 2.2: The message 2 of topic 2

En el bucle interno, la variable loop.parent se usa para acceder al contexto externo. Por lo tanto, el índice del topic actual definido en el bucle for externo es accesible a través de la variable loop.parent.loop.index.

Definir Funciones, Filtros y Etiquetas No Definidas sobre la Marcha

Note

El método registerUndefinedTokenParserCallback() se agregó en Twig 3.2.

Note

El método registerUndefinedTestCallback() se agregó en Twig 3.22.

Cuando una función/filtro/test/etiqueta no está definida, Twig por defecto lanza una excepción \Twig\Error\SyntaxError. Sin embargo, también puede llamar a un callback (cualquier elemento invocable de PHP válido) que debe devolver una función/filtro/test/etiqueta.

Para etiquetas, registra callbacks con registerUndefinedTokenParserCallback(). Para filtros, registra callbacks con registerUndefinedFilterCallback(). Para funciones, usa registerUndefinedFunctionCallback(). Para tests, usa registerUndefinedTestCallback():

// auto-registrar todas las funciones nativas de PHP como funciones de Twig
// NUNCA hagas esto en un proyecto ya que NO es seguro
$twig->registerUndefinedFunctionCallback(function ($name) {
    if (function_exists($name)) {
        return new \Twig\TwigFunction($name, $name);
    }

    return false;
});

Si el elemento invocable no puede devolver una función/filtro/test/etiqueta válida, debe devolver false.

Si registras más de un callback, Twig los llamará por turnos hasta que uno no devuelva false.

Tip

Como la resolución de funciones/filtros/tests/etiquetas se realiza durante la compilación, no hay sobrecarga al registrar estos callbacks.

Warning

Como el análisis de una etiqueta es específico para cada etiqueta (la sintaxis es de forma libre), registerUndefinedTokenParserCallback() no se puede usar para definir una implementación predeterminada para todas las etiquetas desconocidas. Es principalmente útil para anular la excepción predeterminada o para registrar instancias de TokenParser sobre la marcha para etiquetas conocidas específicas.

Validar la Sintaxis de la Plantilla

Cuando el código de la plantilla es proporcionado por un tercero (a través de una interfaz web, por ejemplo), puede ser interesante validar la sintaxis de la plantilla antes de guardarla. Si el código de la plantilla se almacena en una variable $template, así es como puedes hacerlo:

try {
    $twig->parse($twig->tokenize(new \Twig\Source($template)));

    // el $template es válido
} catch (\Twig\Error\SyntaxError $e) {
    // $template contiene uno o más errores de sintaxis
}

Si iteras sobre un conjunto de archivos, puedes pasar el nombre del archivo al método tokenize() para obtener el nombre del archivo en el mensaje de excepción:

foreach ($files as $file) {
    try {
        $twig->parse($twig->tokenize(new \Twig\Source($template, $file->getFilename(), $file)));

        // el $template es válido
    } catch (\Twig\Error\SyntaxError $e) {
        // $template contiene uno o más errores de sintaxis
    }
}

Note

Este método no capturará ninguna violación de la política de sandbox porque la política se aplica durante el renderizado de la plantilla (ya que Twig necesita el contexto para algunas comprobaciones como métodos permitidos en objetos).

Actualizar Plantillas Modificadas cuando OPcache está Habilitado

Cuando se usa OPcache con opcache.validate_timestamps establecido en 0, el caché de Twig habilitado y la recarga automática deshabilitada, borrar el caché de plantillas no actualizará el caché.

Para solucionar esto, fuerza a Twig a invalidar el caché de bytecode:

$twig = new \Twig\Environment($loader, [
    'cache' => new \Twig\Cache\FilesystemCache('/some/cache/path', \Twig\Cache\FilesystemCache::FORCE_BYTECODE_INVALIDATION),
    // ...
]);

Reutilizar un Visitante de Nodos con Estado

Al adjuntar un visitante a una instancia de \Twig\Environment, Twig lo usa para visitar todas las plantillas que compila. Si necesitas mantener alguna información de estado, probablemente quieras restablecerla al visitar una nueva plantilla.

Esto se puede lograr con el siguiente código:

protected $someTemplateState = [];

public function enterNode(\Twig\Node\Node $node, \Twig\Environment $env)
{
    if ($node instanceof \Twig\Node\ModuleNode) {
        // restablecer el estado ya que estamos entrando en una nueva plantilla
        $this->someTemplateState = [];
    }

    // ...

    return $node;
}

Usar una Base de Datos para Almacenar Plantillas

Si estás desarrollando un CMS, las plantillas generalmente se almacenan en una base de datos. Esta receta te proporciona un cargador de plantillas PDO simple que puedes usar como punto de partida para el tuyo propio.

Primero, creemos una base de datos SQLite3 temporal en memoria para trabajar:

$dbh = new PDO('sqlite::memory:');
$dbh->exec('CREATE TABLE templates (name STRING, source STRING, last_modified INTEGER)');
$base = '{% block content %}{% endblock %}';
$index = '
{% extends "base.html.twig" %}
{% block content %}Hello {{ name }}{% endblock %}
';
$now = time();
$dbh->prepare('INSERT INTO templates (name, source, last_modified) VALUES (?, ?, ?)')->execute(['base.html.twig', $base, $now]);
$dbh->prepare('INSERT INTO templates (name, source, last_modified) VALUES (?, ?, ?)')->execute(['index.html.twig', $index, $now]);

Hemos creado una tabla simple templates que alberga dos plantillas: base.html.twig e index.html.twig.

Ahora, definamos un cargador capaz de usar esta base de datos:

class DatabaseTwigLoader implements \Twig\Loader\LoaderInterface
{
    protected $dbh;

    public function __construct(PDO $dbh)
    {
        $this->dbh = $dbh;
    }

    public function getSourceContext(string $name): Source
    {
        if (false === $source = $this->getValue('source', $name)) {
            throw new \Twig\Error\LoaderError(sprintf('Template "%s" does not exist.', $name));
        }

        return new \Twig\Source($source, $name);
    }

    public function exists(string $name)
    {
        return $name === $this->getValue('name', $name);
    }

    public function getCacheKey(string $name): string
    {
        return $name;
    }

    public function isFresh(string $name, int $time): bool
    {
        if (false === $lastModified = $this->getValue('last_modified', $name)) {
            return false;
        }

        return $lastModified <= $time;
    }

    protected function getValue($column, $name)
    {
        $sth = $this->dbh->prepare('SELECT '.$column.' FROM templates WHERE name = :name');
        $sth->execute([':name' => (string) $name]);

        return $sth->fetchColumn();
    }
}

Finalmente, aquí hay un ejemplo de cómo puedes usarlo:

$loader = new DatabaseTwigLoader($dbh);
$twig = new \Twig\Environment($loader);

echo $twig->render('index.html.twig', ['name' => 'Fabien']);

Usar Diferentes Fuentes de Plantillas

Esta receta es la continuación de la anterior. Incluso si almacenas las plantillas contribuidas en una base de datos, es posible que quieras mantener las plantillas originales/base en el sistema de archivos. Cuando las plantillas se pueden cargar desde diferentes fuentes, necesitas usar el cargador \Twig\Loader\ChainLoader.

Como puedes ver en la receta anterior, referenciamos la plantilla exactamente de la misma manera que lo habríamos hecho con un cargador del sistema de archivos regular. Esta es la clave para poder mezclar y combinar plantillas provenientes de la base de datos, el sistema de archivos, o cualquier otro cargador: el nombre de la plantilla debe ser un nombre lógico, y no la ruta del sistema de archivos:

$loader1 = new DatabaseTwigLoader($dbh);
$loader2 = new \Twig\Loader\ArrayLoader([
    'base.html.twig' => '{% block content %}{% endblock %}',
]);
$loader = new \Twig\Loader\ChainLoader([$loader1, $loader2]);

$twig = new \Twig\Environment($loader);

echo $twig->render('index.html.twig', ['name' => 'Fabien']);

Ahora que la plantilla base.html.twig está definida en un cargador de array, puedes eliminarla de la base de datos, y todo lo demás seguirá funcionando como antes.

Cargar una Plantilla desde una Cadena

Desde una plantilla, puedes cargar una plantilla almacenada en una cadena a través de la función template_from_string (mediante la extensión \Twig\Extension\StringLoaderExtension):

{{ include(template_from_string("Hello {{ name }}")) }}

Desde PHP, también es posible cargar una plantilla almacenada en una cadena mediante \Twig\Environment::createTemplate():

$template = $twig->createTemplate('hello {{ name }}');
echo $template->render(['name' => 'Fabien']);

Usar Twig y AngularJS en las Mismas Plantillas

Mezclar diferentes sintaxis de plantillas en el mismo archivo no es una práctica recomendada ya que tanto AngularJS como Twig usan los mismos delimitadores en su sintaxis: {{ y }}.

Aún así, si quieres usar AngularJS y Twig en la misma plantilla, hay dos formas de hacerlo funcionar dependiendo de la cantidad de AngularJS que necesites incluir en tus plantillas:

  • Escapar los delimitadores de AngularJS envolviendo las secciones de AngularJS con la etiqueta {% verbatim %} o escapando cada delimitador mediante {{ '{{' }} y {{ '}}' }};
  • Cambiar los delimitadores de uno de los motores de plantillas (dependiendo de qué motor introdujiste último):

    • Para AngularJS, cambia las etiquetas de interpolación usando el servicio interpolateProvider, por ejemplo en el momento de inicialización del módulo:
    angular.module('myApp', []).config(function($interpolateProvider) {
        $interpolateProvider.startSymbol('{[').endSymbol(']}');
    });
    • Para Twig, cambia los delimitadores mediante la opción tag_variable del Lexer:
    $env->setLexer(new \Twig\Lexer($env, [
        'tag_variable' => ['{[', ']}'],
    ]));

Marcar un Nodo como Seguro

Cuando usas la extensión de escape, es posible que quieras marcar algunos nodos como seguros para evitar cualquier escape. Puedes hacerlo envolviendo tu expresión con un nodo RawFilter:

use Twig\Node\Expression\Filter\RawFilter;

$safeExpr = new RawFilter(new YourSafeNode());