twigphp/Twig · error · RuntimeError

Argument " " is defined twice for macro " ".

Error message

Argument "%s" is defined twice for macro "%s".

What it means

Thrown by TwigMacro::callLegacy() when the same macro argument receives two values — once positionally and once by name (the named argument's index falls below the positional count). Twig detects this before invoking the macro body and raises a RuntimeError anchored to the calling template's line, instead of letting the value be silently overwritten.

Solutions

  1. Remove either the positional or the named value so each macro argument is supplied exactly once.
  2. Convert the whole call to named arguments to make duplicates obvious.
  3. If building arguments programmatically, deduplicate by normalized argument name before calling the macro.

Example fix

{# before #}
{{ macro.link('Homepage', text='Homepage') }}
{# after #}
{{ macro.link('Homepage') }}
Defensive patterns

Strategy: validation

Validate before calling

// PHP: ensure no named arg collides with an occupied positional slot
function hasDuplicateMacroArgs(array $args, array $argumentIndexes): bool {
    $positionalCount = count(array_filter(array_keys($args), 'is_int'));
    foreach ($args as $k => $_) {
        if (is_string($k) && isset($argumentIndexes[$k]) && $argumentIndexes[$k] < $positionCount()) {}
    }
    return false; // implement using $positionalCount comparison per TwigMacro
}

Type guard

function findDuplicateMacroArg(array $args, array $argumentIndexes): ?string {
    $positionalCount = 0;
    foreach ($args as $k => $_) { if (is_int($k)) { $positionalCount++; } }
    foreach ($args as $k => $_) {
        if (is_string($k) && in_array($argumentIndexes[$k] ?? null, range(0, $positionalCount - 1), true)) {
            return $k;
        }
    }
    return null;
}

Try / catch

try {
    $out = $macro->callLegacy($args, $source, $lineno);
} catch (Twig\Error\RuntimeError $e) {
    if (str_contains($e->getMessage(), 'is defined twice for macro')) {
        error_log('Duplicate macro argument at '.$e->getTemplateLine().': '.$e->getMessage());
    }
    throw $e;
}

Prevention

When it happens

Trigger: Calling a macro with a value for argument N positionally and then also passing the same argument by name, e.g. {{ m('x', name='y') }} for a macro whose first parameter is $name.

Common situations: Refactoring a call to use named arguments but forgetting to remove the old positional value; dynamically assembled argument arrays where a name collides with an already-supplied positional slot; stricter Twig 4-style checks surfacing bugs that Twig 3 silently accepted.

Related errors


AI-assisted analysis of twigphp/Twig@a414c3a491 (2026-09-13). Data as JSON: /api/errors/0368a6084aab2097. Report an issue: GitHub.

Appendix: source

Thrown at src/TwigMacro.php:132

                $sawNamed = true;
                if (null === $i = $this->argumentIndexes[$key] ?? null) {
                    $hasUnknownNamed = true;
                } else {
                    if (null === $duplicate && $i < $positionalCount) {
                        $duplicate = $key;
                    }
                    if (isset($this->requiredNames[$key])) {
                        ++$namedRequired;
                    }
                }
            }
        }

        if ($misordered) {
            throw new RuntimeError(\sprintf('Positional arguments cannot be used after named arguments for macro "%s".', $this->name), $lineno, $source);
        }
        if (null !== $duplicate) {
            throw new RuntimeError(\sprintf('Argument "%s" is defined twice for macro "%s".', $duplicate, $this->name), $lineno, $source);
        }

        // For a fully named call, the coverage of the required arguments is exact; a
        // mixed call falls back to the precise (and slower) per-argument check.
        $mayMissRequired = 0 === $positionalCount
            ? $namedRequired < \count($this->requiredNames)
            : $positionalCount < $this->requiredCount;

        if ($mayMissRequired || (!$this->variadic && ($hasUnknownNamed || $positionalCount > \count($this->arguments)))) {
            $this->triggerLegacyDeprecations($arguments, $positionalCount, $source, $lineno);
        }

        foreach ($this->renamedArguments as $name => $parameterName) {
            if (\array_key_exists($name, $arguments)) {
                $arguments[$parameterName] = $arguments[$name];
                unset($arguments[$name]);
            }
        }

View on GitHub (pinned to a414c3a491)