symfony/http-kernel · error · TypeError

The value of the "$key" option of the

Error message

The value of the "$key" option of the "%s" attribute must evaluate to a string, "%s" given.

What it means

The #[RateLimit] key option may be a string expression evaluated via the ControllerArgumentsEvent evaluator; its result is used as the limiter bucket key. If the evaluation yields a non-string, a TypeError is thrown naming the actual type.

Solutions

  1. Make the expression return a string, e.g. key: "request.headers.get('X-Api-Key', 'anonymous')".
  2. Cast inside the expression or use a fallback: key: "user.getId() ~ ''" is discouraged; prefer explicit string functions.
  3. Ensure default values are strings when header/attribute may be absent.

Example fix

// before
#[RateLimit(limiter: 'basic', key: 'request.headers.get("X-Api-Key")')]
// after
#[RateLimit(limiter: 'basic', key: "request.headers.get('X-Api-Key', 'anonymous')")]
Defensive patterns

Strategy: type-guard

Validate before calling

$key = $attribute->key;
if ($key !== null && !is_string($key)) { /* expression must be string-typed source */ }

Type guard

function isValidRateLimitKey(mixed $evaluated): bool { return is_string($evaluated); }

Try / catch

try { $listener->onKernelControllerAttribute($event, 'kernel.controller', ...); } catch (\TypeError $e) { /* fall back to IP-based key */ }

Prevention

When it happens

Trigger: Annotating with #[RateLimit(key: 'expr...')] where the expression evaluates to an int, array, or null instead of a string, during onKernelControllerAttribute.

Common situations: Using key: 'request.headers.get("X-Api-Key")' which returns null when the header is missing; an expression returning a numeric user id rather than casting it to string.

Understand the failure class

Background: Type mismatch errors: IllegalArgumentException, TypeError and type guards across 150 open-source libraries — this error's family across 150 libraries.

Related errors


AI-assisted analysis of symfony/http-kernel@aa3a39d728 (2026-09-13). Data as JSON: /api/errors/2a854c39738a765f. Report an issue: GitHub.

Appendix: source

Thrown at EventListener/RateLimitAttributeListener.php:63

     * @param ControllerAttributeEvent<RateLimit, ControllerArgumentsEvent> $event
     */
    public function onKernelControllerAttribute(ControllerAttributeEvent $event, ?string $eventName = null, ?EventDispatcherInterface $dispatcher = null): void
    {
        $request = $event->kernelEvent->getRequest();
        $attribute = $event->attribute;

        if ($attribute->methods && !\in_array($request->getMethod(), $attribute->methods, true)) {
            return;
        }

        if (!$this->limiters->has($attribute->limiter)) {
            throw new \InvalidArgumentException(\sprintf('Rate limiter "%s" does not exist. Did you forget to configure it? Available limiters: "%s".', $attribute->limiter, implode('", "', array_keys($this->limiters->getProvidedServices()))));
        }

        if (null === $attribute->key) {
            $key = ($request->getClientIp() ?? 'unknown').'~'.$request->getMethod().'~'.$request->getPathInfo();
        } elseif (!\is_string($key = $event->evaluate($attribute->key))) {
            throw new \TypeError(\sprintf('The value of the "$key" option of the "%s" attribute must evaluate to a string, "%s" given.', RateLimit::class, get_debug_type($key)));
        }

        $rateLimit = $this->limiters->get($attribute->limiter)->create($key)->consume($attribute->tokens);

        $candidate = $attribute->exposeHeaders && null !== $rateLimit->getResetAt()
            ? new AppliedRateLimit($rateLimit, $attribute->tokens)
            : null;

        if (!$rateLimit->isAccepted()) {
            $request->attributes->set(self::RATE_LIMIT_ATTRIBUTE, $candidate);

            if ($dispatcher && class_exists(RateLimitExceededEvent::class)) {
                $dispatcher->dispatch(new RateLimitExceededEvent($rateLimit, $attribute->limiter, $key));
            }

            throw new TooManyRequestsHttpException(max(0, $rateLimit->getRetryAfter()->getTimestamp() - time()));
        }

View on GitHub (pinned to aa3a39d728)