From 5ed8c4ce7c0aa20d15eafa832f773ed986deae59 Mon Sep 17 00:00:00 2001 From: Oliver Mesieh Date: Wed, 11 Nov 2026 10:28:21 +0100 Subject: [PATCH 1/3] Let modifiers declare that they run on null --- src/Modifiers/AcceptsNull.php | 13 ++++++++ src/Modifiers/CoreModifiers.php | 2 ++ src/Modifiers/Loader.php | 30 ++++++++++++++++--- .../Language/Runtime/ModifierManager.php | 12 ++------ tests/Antlers/Runtime/CoreModifiersTest.php | 29 ++++++++++++++++++ 5 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 src/Modifiers/AcceptsNull.php diff --git a/src/Modifiers/AcceptsNull.php b/src/Modifiers/AcceptsNull.php new file mode 100644 index 00000000000..4dfe5586a17 --- /dev/null +++ b/src/Modifiers/AcceptsNull.php @@ -0,0 +1,13 @@ +augment($items); } + #[AcceptsNull] public function boolString($value) { if ($value == true) { @@ -2802,6 +2803,7 @@ public function toBool($value, $params) * * @return string */ + #[AcceptsNull] public function toJson($value, $params) { $options = 0; diff --git a/src/Modifiers/Loader.php b/src/Modifiers/Loader.php index d9552532790..0e72970043f 100644 --- a/src/Modifiers/Loader.php +++ b/src/Modifiers/Loader.php @@ -2,11 +2,14 @@ namespace Statamic\Modifiers; +use ReflectionMethod; use Statamic\Support\Str; use Statamic\View\State\StateManager; class Loader { + protected static $acceptsNull = []; + /** * Loads a modifier. * @@ -20,12 +23,31 @@ public function load($name) throw new ModifierNotFoundException($name); } - if (Str::contains($class = $modifiers->get($key), 'CoreModifiers@')) { - [$class, $method] = explode('@', $class); - } + [$class, $method] = $this->resolve($modifiers->get($key)); StateManager::track($class); - return [app($class), $method ?? 'index']; + return [app($class), $method]; + } + + public function acceptsNull($name) + { + if (! $binding = app('statamic.modifiers')->get(Str::snake($name))) { + return false; + } + + [$class, $method] = $this->resolve($binding); + + return static::$acceptsNull[$binding] ??= method_exists($class, $method) + && (new ReflectionMethod($class, $method))->getAttributes(AcceptsNull::class); + } + + protected function resolve($binding) + { + if (Str::contains($binding, 'CoreModifiers@')) { + return explode('@', $binding); + } + + return [$binding, 'index']; } } diff --git a/src/View/Antlers/Language/Runtime/ModifierManager.php b/src/View/Antlers/Language/Runtime/ModifierManager.php index 3d3b9bd5178..5eeb7230cf9 100644 --- a/src/View/Antlers/Language/Runtime/ModifierManager.php +++ b/src/View/Antlers/Language/Runtime/ModifierManager.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Log; use Statamic\Fields\Value; +use Statamic\Modifiers\Loader; use Statamic\Modifiers\ModifierException; use Statamic\Modifiers\Modify; use Statamic\Support\Str; @@ -20,15 +21,6 @@ class ModifierManager public static $lastModifierName = null; const METHOD_MODIFIER = '{__method_args}'; - /** - * Modifiers that should still run when the value is null. - * - * @var string[] - */ - protected static $nullAwareModifiers = [ - 'bool_string', - ]; - public static function isModifier(ParameterNode $node) { if (self::$statamicModifiers == null) { @@ -104,7 +96,7 @@ public static function guardRuntimeModifier($modifierName) protected static function acceptsNull($modifierName) { - return in_array($modifierName, self::$nullAwareModifiers, true); + return app(Loader::class)->acceptsNull($modifierName); } public static function evaluate($value, Environment $env, ModifierChainNode $modifierChain, $context) diff --git a/tests/Antlers/Runtime/CoreModifiersTest.php b/tests/Antlers/Runtime/CoreModifiersTest.php index 851c57f4705..39335342381 100644 --- a/tests/Antlers/Runtime/CoreModifiersTest.php +++ b/tests/Antlers/Runtime/CoreModifiersTest.php @@ -10,6 +10,8 @@ use Statamic\Fields\Fieldtype; use Statamic\Fields\Value; use Statamic\Fieldtypes\Text; +use Statamic\Modifiers\AcceptsNull; +use Statamic\Modifiers\Modifier; use Tests\Antlers\ParserTestCase; class CoreModifiersTest extends ParserTestCase @@ -623,6 +625,33 @@ public function test_bool_string_outputs_false_for_null() $this->assertSame('FALSE', $this->renderString('{{ empty_field | bool_string | upper }}', $data, true)); $this->assertSame('true', $this->renderString('{{ filled_field | bool_string }}', $data, true)); } + + public function test_core_modifiers_that_accept_null_run_for_empty_fields() + { + $data = ['empty_field' => new Value(null, 'empty_field', new Text)]; + + $this->assertSame('null', $this->renderString('{{ empty_field | to_json }}', $data, true)); + $this->assertSame('null', $this->renderString('{{ empty_field | json }}', $data, true)); + } + + public function test_modifiers_marked_with_accepts_null_run_for_null() + { + (new class extends Modifier + { + protected static $handle = 'accepts_null'; + + #[AcceptsNull] + public function index($value) + { + return var_export($value, true); + } + })::register(); + + $data = ['bare' => null, 'wrapped' => new Value(null)]; + + $this->assertSame('NULL', $this->renderString('{{ bare | accepts_null }}', $data, true)); + $this->assertSame('NULL', $this->renderString('{{ wrapped | accepts_null }}', $data, true)); + } } class SimpleEntryObject implements Arrayable From cbf555fb9ed3bb1bb0e690d75471906b54806a2f Mon Sep 17 00:00:00 2001 From: Oliver Mesieh Date: Wed, 11 Nov 2026 10:29:01 +0100 Subject: [PATCH 2/3] Let is_blank run on null --- src/Modifiers/CoreModifiers.php | 1 + tests/Antlers/Runtime/CoreModifiersTest.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index dd6f5a5fb5a..5e1e62a1fa2 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -1297,6 +1297,7 @@ public function isBetween($value, $params, $context) * * @return bool */ + #[AcceptsNull] public function isBlank($value) { return Stringy::isBlank($value); diff --git a/tests/Antlers/Runtime/CoreModifiersTest.php b/tests/Antlers/Runtime/CoreModifiersTest.php index 39335342381..fc3a79579b0 100644 --- a/tests/Antlers/Runtime/CoreModifiersTest.php +++ b/tests/Antlers/Runtime/CoreModifiersTest.php @@ -601,7 +601,7 @@ public function test_a_value_wrapping_null_short_circuits_modifiers_like_a_bare_ { $data = ['wrapped' => new Value(null), 'bare' => null]; - foreach (['sanitize', 'count', 'md5', 'is_blank', 'upper | md5'] as $modifiers) { + foreach (['sanitize', 'count', 'md5', 'is_alpha', 'upper | md5'] as $modifiers) { $this->assertSame('', $this->renderString("{{ bare | {$modifiers} }}", $data, true), $modifiers); $this->assertSame('', $this->renderString("{{ wrapped | {$modifiers} }}", $data, true), $modifiers); } @@ -632,6 +632,7 @@ public function test_core_modifiers_that_accept_null_run_for_empty_fields() $this->assertSame('null', $this->renderString('{{ empty_field | to_json }}', $data, true)); $this->assertSame('null', $this->renderString('{{ empty_field | json }}', $data, true)); + $this->assertSame('1', $this->renderString('{{ empty_field | is_blank }}', $data, true)); } public function test_modifiers_marked_with_accepts_null_run_for_null() From 70fab29468b52acf1755767c7b96daf443d95c3b Mon Sep 17 00:00:00 2001 From: Oliver Mesieh Date: Wed, 11 Nov 2026 10:30:05 +0100 Subject: [PATCH 3/3] Skip null in parameter-style modifiers too --- .../Language/Runtime/ModifierManager.php | 2 +- .../Language/Runtime/NodeProcessor.php | 4 +++ .../Runtime/ParameterStyleModifierTest.php | 27 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/View/Antlers/Language/Runtime/ModifierManager.php b/src/View/Antlers/Language/Runtime/ModifierManager.php index 5eeb7230cf9..9050c9a42b0 100644 --- a/src/View/Antlers/Language/Runtime/ModifierManager.php +++ b/src/View/Antlers/Language/Runtime/ModifierManager.php @@ -94,7 +94,7 @@ public static function guardRuntimeModifier($modifierName) return true; } - protected static function acceptsNull($modifierName) + public static function acceptsNull($modifierName) { return app(Loader::class)->acceptsNull($modifierName); } diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 632bc5142e8..427a93363d2 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -2821,6 +2821,10 @@ protected function runModifier($modifier, $parameters, $data, $context = []) GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; } + if ($value === null && ! ModifierManager::acceptsNull($modifier)) { + return null; + } + try { return Modify::value($value)->context($context)->$modifier($parameters)->fetch(); } catch (ModifierException $e) { diff --git a/tests/Antlers/Runtime/ParameterStyleModifierTest.php b/tests/Antlers/Runtime/ParameterStyleModifierTest.php index 476c8ce3e2e..722d2c0cc9c 100644 --- a/tests/Antlers/Runtime/ParameterStyleModifierTest.php +++ b/tests/Antlers/Runtime/ParameterStyleModifierTest.php @@ -4,6 +4,10 @@ use Facades\Tests\Factories\EntryFactory; use Statamic\Entries\Collection; +use Statamic\Fields\Value; +use Statamic\Fieldtypes\Text; +use Statamic\View\Antlers\Language\Exceptions\RuntimeException; +use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Tests\Antlers\Fixtures\Addon\Modifiers\VarTestModifier; use Tests\Antlers\ParserTestCase; @@ -69,4 +73,27 @@ public function test_modifier_style_parameters_applies_brace_escape_sequences() $this->assertSame(['test{}', 'param_two{}'], VarTestModifier::$params); } + + public function test_parameter_style_modifiers_skip_null_like_chained_modifiers() + { + $data = ['empty_field' => new Value(null, 'empty_field', new Text)]; + + $this->assertSame('', $this->renderString('{{ empty_field count="true" }}', $data, true)); + $this->assertSame('null', $this->renderString('{{ empty_field to_json="true" }}', $data, true)); + } + + public function test_parameter_style_modifiers_check_access_before_skipping_null() + { + $banned = GlobalRuntimeState::$bannedModifierPaths; + GlobalRuntimeState::$bannedModifierPaths = ['upper']; + GlobalRuntimeState::$throwErrorOnAccessViolation = true; + + $this->expectException(RuntimeException::class); + + try { + $this->renderString('{{ empty_field upper="true" }}', ['empty_field' => new Value(null, 'empty_field', new Text)], true); + } finally { + GlobalRuntimeState::$bannedModifierPaths = $banned; + } + } }