Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/twig-hooks/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ sylius_twig_hooks:
enabled: true # whether the hookable is enabled
context: [] # key-value pair that will be passed to the context bag
configuration: [] # key-value pair that will be passed to the configuration bag
condition: '@=user != null' # expression evaluated at runtime; the hookable is rendered only when it returns true
priority: 0 # priority, the higher the number, the earlier the hookable will be hooked
```
{% endcode %}
Expand All @@ -111,8 +112,24 @@ sylius_twig_hooks:
context: [] # key-value pair that will be passed to the context bag
props: [] # key-value pair that will be passed to our component as props
configuration: [] # key-value pair that will be passed to the configuration bag
condition: '@=user != null' # expression evaluated at runtime; the hookable is rendered only when it returns true
priority: 0 # priority, the higher the number, the earlier the hookable will be hooked
```
{% endcode %}
{% endtab %}
{% endtabs %}

**Conditions in hookables**

Unlike the `enabled` flag — which is evaluated statically during the application boot — the `condition` option is an [Expression Language](https://symfony.com/doc/current/expression_language.html) expression that is evaluated at runtime, each time the hook is rendered. The hookable is rendered only when the expression returns `true`. Both the whole hook context and the `_context` variable are available within the expression, so both of these work:

```yaml
condition: '@=user != null'
condition: '@=_context.user != null'
```

Because the expression is evaluated with the whole hook context and the `_context` variable, this behaves like [Twig's `defined`](https://twig.symfony.com/doc/3.x/tests/defined.html)-style checks: `@=_context.product` returns `null` when the variable is absent from the context, so the hookable is simply not rendered. Referencing a missing variable directly (e.g. `@=product ...`) throws a clear `InvalidExpressionException` instead, which makes invalid conditions fail fast during development.

{% hint style="info" %}
On Symfony 7.1 and newer, conditions are validated at the application boot: an invalid expression is reported as soon as the container is compiled. On older versions, errors are only reported at runtime, when the hook is rendered.
{% endhint %}
5 changes: 5 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,8 @@ parameters:
identifier: function.alreadyNarrowedType
count: 2
path: src/TwigHooks/src/Twig/TokenParser/HookTokenParser.php

-
message: '#^Method Symfony\\Component\\ExpressionLanguage\\ExpressionLanguage\:\:lint\(\) invoked with 3 parameters, 2 required\.$#'
count: 1
path: src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php
9 changes: 9 additions & 0 deletions src/TwigHooks/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
use Sylius\TwigHooks\Hook\Normalizer\Prefix\CompositePrefixNormalizer;
use Sylius\TwigHooks\Hook\Normalizer\Prefix\PrefixNormalizerInterface;
use Sylius\TwigHooks\Hook\Normalizer\Prefix\RemoveSectionPartNormalizer;
use Sylius\TwigHooks\Hookable\Checker\HookableConditionChecker;
use Sylius\TwigHooks\Hookable\Checker\HookableConditionCheckerInterface;
use Sylius\TwigHooks\Hookable\Metadata\HookableMetadataFactory;
use Sylius\TwigHooks\Provider\ComponentPropsProvider;
use Sylius\TwigHooks\Provider\DefaultConfigurationProvider;
Expand Down Expand Up @@ -53,6 +55,13 @@
;
$services->alias(TemplateConfigurationProviderInterface::class, 'sylius_twig_hooks.provider.template_configuration');

$services->set('sylius_twig_hooks.checker.hookable_condition', HookableConditionChecker::class)
->args([
inline_service(ExpressionLanguage::class),
])
;
$services->alias(HookableConditionCheckerInterface::class, 'sylius_twig_hooks.checker.hookable_condition');

$services->set('sylius_twig_hooks.registry.hookables', HookablesRegistry::class)
->args([
tagged_iterator('sylius_twig_hooks.hookable'),
Expand Down
1 change: 1 addition & 0 deletions src/TwigHooks/config/services/hook_renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
service('sylius_twig_hooks.provider.default_context'),
service('sylius_twig_hooks.provider.default_configuration'),
service('sylius_twig_hooks.factory.hookable_metadata'),
service('sylius_twig_hooks.checker.hookable_condition'),
])
->alias(HookRendererInterface::class, 'sylius_twig_hooks.renderer.hook')
;
Expand Down
18 changes: 18 additions & 0 deletions src/TwigHooks/src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,24 @@ private function addHooksConfiguration(ArrayNodeDefinition $rootNode): void
->prototype('variable')->end()
->end()
->integerNode('priority')->defaultNull()->end()
->scalarNode('condition')
->defaultNull()
->validate()
->always(function ($value) {
if (null === $value) {
return $value;
}

if (!is_string($value) || !str_starts_with($value, '@=')) {
throw new \InvalidArgumentException(
sprintf('The "condition" value must be an expression prefixed with "@=". Got "%s".', get_debug_type($value)),
);
}

return $value;
})
->end()
->end()
->end()
->end()
->end()
Expand Down
38 changes: 36 additions & 2 deletions src/TwigHooks/src/DependencyInjection/SyliusTwigHooksExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
use Sylius\TwigHooks\Hookable\DisabledHookable;
use Sylius\TwigHooks\Hookable\HookableComponent;
use Sylius\TwigHooks\Hookable\HookableTemplate;
use Sylius\TwigHooks\Provider\Exception\InvalidExpressionException;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\ExpressionLanguage\Parser;

final class SyliusTwigHooksExtension extends Extension
{
Expand All @@ -46,12 +49,16 @@ public function load(array $configs, ContainerBuilder $container): void
*/
private function registerHooks(ContainerBuilder $container, array $hooks, array $supportedHookableTypes): void
{
$expressionLanguage = new ExpressionLanguage();

foreach ($hooks as $hookName => $hookables) {
foreach ($hookables as $hookableName => $hookable) {
if (!array_key_exists($hookable['type'], $supportedHookableTypes)) {
throw new \InvalidArgumentException(sprintf('Hookable type "%s" is not supported.', $hookable['type']));
}

$this->lintCondition($expressionLanguage, $hookName, $hookableName, $hookable);

$this->registerHookable(
$container,
$supportedHookableTypes[$hookable['type']],
Expand All @@ -63,6 +70,33 @@ private function registerHooks(ContainerBuilder $container, array $hooks, array
}
}

/**
* @param array<string, mixed> $hookable
*/
private function lintCondition(ExpressionLanguage $expressionLanguage, string $hookName, string $hookableName, array $hookable): void
{
$condition = $hookable['condition'] ?? null;

if (null === $condition || !defined(Parser::class . '::IGNORE_UNKNOWN_VARIABLES')) {

@diimpp diimpp Sep 25, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This defined early return essentially disables lint for older than SF 7.1.

::IGNORE_UNKNOWN_VARIABLES was introduced in 7.1 and only changes method signature, not behaviour. Previosly we should have supplied null for the same result.

So something like this instead of early return.

-            $expressionLanguage->lint(substr($condition, 2), [], Parser::IGNORE_UNKNOWN_VARIABLES);
+            $expression = substr($condition, 2);
+
+            if (defined(Parser::class . '::IGNORE_UNKNOWN_VARIABLES')) {
+                $expressionLanguage->lint(
+                    $expression,
+                    [],
+                    Parser::IGNORE_UNKNOWN_VARIABLES
+                );
+            } else {
+                $expressionLanguage->lint(
+                    $expression,
+                    null
+                );
+            }
Image

symfony/expression-language@684ac49#diff-1a7c1850f88287936713a7072af9331818e14581fc71feed100c41e1747593ceR37-L103

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I agree, but I think we still need the "try/catch" to have a better error.

return;
}

try {
$expressionLanguage->lint(substr($condition, 2), [], Parser::IGNORE_UNKNOWN_VARIABLES);
} catch (\Throwable $e) {
throw new InvalidExpressionException(
sprintf(
'Failed to lint the "%s" condition of the "%s" hookable in the "%s" hook. Error: %s".',
$condition,
$hookableName,
$hookName,
$e->getMessage(),
),
previous: $e,
);
}
}

/**
* @param array<string, mixed> $hookable
*/
Expand Down Expand Up @@ -99,7 +133,7 @@ private function registerTemplateHookable(
$hookable['context'],
$hookable['configuration'],
$hookable['priority'],
$hookable['enabled'],
$hookable['condition'],
])
->addTag('sylius_twig_hooks.hookable', ['priority' => $hookable['priority']])
;
Expand All @@ -124,7 +158,7 @@ private function registerComponentHookable(
$hookable['context'],
$hookable['configuration'],
$hookable['priority'],
$hookable['enabled'],
$hookable['condition'],
])
->addTag('sylius_twig_hooks.hookable', ['priority' => $hookable['priority']])
;
Expand Down
7 changes: 7 additions & 0 deletions src/TwigHooks/src/Hook/Renderer/HookRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Sylius\TwigHooks\Bag\DataBag;
use Sylius\TwigHooks\Bag\ScalarDataBag;
use Sylius\TwigHooks\Hook\Metadata\HookMetadata;
use Sylius\TwigHooks\Hookable\Checker\HookableConditionCheckerInterface;
use Sylius\TwigHooks\Hookable\Metadata\HookableMetadataFactoryInterface;
use Sylius\TwigHooks\Hookable\Renderer\HookableRendererInterface;
use Sylius\TwigHooks\Provider\ConfigurationProviderInterface;
Expand All @@ -30,6 +31,7 @@ public function __construct(
private readonly ContextProviderInterface $contextProvider,
private readonly ConfigurationProviderInterface $configurationProvider,
private readonly HookableMetadataFactoryInterface $hookableMetadataFactory,
private readonly HookableConditionCheckerInterface $hookableConditionChecker,
) {
}

Expand All @@ -46,6 +48,11 @@ public function render(array $hookNames, array $hookContext = []): string
$hookMetadata = new HookMetadata($hookable->hookName, new DataBag($hookContext));

$context = $this->contextProvider->provide($hookable, $hookContext);

if (!$this->hookableConditionChecker->isEnabled($hookable, $context)) {
continue;
}

$configuration = $this->configurationProvider->provide($hookable);

$hookableMetadata = $this->hookableMetadataFactory->create(
Expand Down
4 changes: 4 additions & 0 deletions src/TwigHooks/src/Hookable/AbstractHookable.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ abstract class AbstractHookable
/** @var array<string, mixed> */
public readonly array $configuration;

public readonly ?string $condition;

private readonly ?int $priority;

public const DEFAULT_PRIORITY = 0;
Expand All @@ -41,13 +43,15 @@ public function __construct(
array $context = [],
array $configuration = [],
?int $priority = null,
?string $condition = null,
) {
$this->id = sprintf('%s#%s', $hookName, $name);
$this->hookName = $hookName;
$this->name = $name;
$this->context = $context;
$this->configuration = $configuration;
$this->priority = $priority;
$this->condition = $condition;
}

public function priority(): int
Expand Down
56 changes: 56 additions & 0 deletions src/TwigHooks/src/Hookable/Checker/HookableConditionChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Sylius\TwigHooks\Hookable\Checker;

use Sylius\TwigHooks\Bag\DataBag;
use Sylius\TwigHooks\Hookable\AbstractHookable;
use Sylius\TwigHooks\Provider\Exception\InvalidExpressionException;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

final class HookableConditionChecker implements HookableConditionCheckerInterface
{
public function __construct(
private readonly ExpressionLanguage $expressionLanguage,
) {
}

/**
* @param array<string, mixed> $context
*/
public function isEnabled(AbstractHookable $hookable, array $context): bool
{
$condition = $hookable->condition;

if (null === $condition) {
return true;
}

$values = array_merge($context, ['_context' => new DataBag($context)]);

try {
return (bool) $this->expressionLanguage->evaluate(substr($condition, 2), $values);
} catch (\Throwable $e) {
throw new InvalidExpressionException(
sprintf(
'Failed to evaluate the "%s" condition while rendering the "%s" hookable in the "%s" hook. Error: %s".',
$condition,
$hookable->name,
$hookable->hookName,
$e->getMessage(),
),
previous: $e,
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Sylius\TwigHooks\Hookable\Checker;

use Sylius\TwigHooks\Hookable\AbstractHookable;

interface HookableConditionCheckerInterface
{
/**
* @param array<string, mixed> $context
*/
public function isEnabled(AbstractHookable $hookable, array $context): bool;
}
4 changes: 3 additions & 1 deletion src/TwigHooks/src/Hookable/DisabledHookable.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ public function __construct(
array $context = [],
array $configuration = [],
?int $priority = null,
?string $condition = null,
) {
parent::__construct($hookName, $name, $context, $configuration, $priority);
parent::__construct($hookName, $name, $context, $configuration, $priority, $condition);
}

public function toArray(): array
Expand All @@ -32,6 +33,7 @@ public function toArray(): array
'name' => $this->name,
'context' => $this->context,
'configuration' => $this->configuration,
'condition' => $this->condition,
'priority' => $this->priority(),
];
}
Expand Down
4 changes: 3 additions & 1 deletion src/TwigHooks/src/Hookable/HookableComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ public function __construct(
array $context = [],
array $configuration = [],
?int $priority = null,
?string $condition = null,
) {
parent::__construct($hookName, $name, $context, $configuration, $priority);
parent::__construct($hookName, $name, $context, $configuration, $priority, $condition);
}

public function toArray(): array
Expand All @@ -41,6 +42,7 @@ public function toArray(): array
'props' => $this->props,
'context' => $this->context,
'configuration' => $this->configuration,
'condition' => $this->condition,
'priority' => $this->priority(),
];
}
Expand Down
4 changes: 3 additions & 1 deletion src/TwigHooks/src/Hookable/HookableTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ public function __construct(
array $context = [],
array $configuration = [],
?int $priority = null,
?string $condition = null,
) {
parent::__construct($hookName, $name, $context, $configuration, $priority);
parent::__construct($hookName, $name, $context, $configuration, $priority, $condition);
}

public function toArray(): array
Expand All @@ -34,6 +35,7 @@ public function toArray(): array
'template' => $this->template,
'context' => $this->context,
'configuration' => $this->configuration,
'condition' => $this->condition,
'priority' => $this->priority(),
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@ sylius_twig_hooks:
'hookable_twig_component':
dummy:
component: 'app:dummy'

'enabled_by_condition.index':
with_condition_true:
template: 'enabled_by_condition/index/block.html.twig'
condition: '@=show === true'
with_condition_false:
template: 'enabled_by_condition/index/block.html.twig'
condition: '@=show === false'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% hook 'enabled_by_condition.index' with {
show: show,
} only %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Condition block rendered.
Loading
Loading