No Nested Switch

ID

php.no_nested_switch

Severity

high

Remediation Complexity

medium

Remediation Risk

low

Remediation Effort

medium

Resource

Code Smell

Language

Php

Tags

code-style, control-flow, switch

Description

Reports a switch statement written inside another switch.

The inner switch is reported, since that is the statement to extract. Nesting several levels deep reports every inner level. The alternative syntax (switch (…): … endswitch;) is covered the same way.

Rationale

<?php
function dispatch(string $verb, string $scope): string
{
    switch ($verb) {
        case 'get':
            switch ($scope) {           // FLAW — two selectors interleaved in one function
                case 'user':
                    return 'get.user';
                default:
                    return 'get.any';
            }
        default:
            return 'unknown';
    }
}

Two switches in one place force the reader to track two independent selector values and two sets of case labels at the same time. Worse, break binds to the innermost enclosing switch, so an inner break reads as if it left the outer one; getting that wrong silently falls through into the next outer case. The number of paths through the function is the product of the two label counts, which is why nested switches are where a missed combination hides.

Remediation

Extract the inner switch into a function named after the decision it makes. Each switch then has one selector, and the outer one reads as a single dispatch.

<?php
function dispatch(string $verb, string $scope): string
{
    switch ($verb) {
        case 'get':
            return 'get.' . scopeOf($scope);
        default:
            return 'unknown';
    }
}

function scopeOf(string $scope): string
{
    switch ($scope) {
        case 'user':
            return 'user';
        default:
            return 'any';
    }
}

When the inner decision maps one value to another with no statements involved, a match expression replaces it inline and is not reported:

switch ($verb) {
    case 'get':
        return match ($scope) {
            'user' => 'get.user',
            default => 'get.any',
        };
    default:
        return 'unknown';
}

Configuration

This rule has no configurable properties. Two shapes are excluded.

A switch inside a closure or an arrow function. The ancestor walk stops at the first enclosing function, and a function () use (…) closure or an fn () ⇒ … arrow function is one. Such a body is a unit of code the reader already considers on its own, even though it sits inside the outer switch’s braces lexically.

match, in either position. PHP 8’s match is an expression whose arms hold a single expression each, with no fall-through and no break, so the confusion this rule is about cannot arise in one. It is also the construct the language now recommends in place of switch, so reporting a match nested in a switch would penalise exactly the refactoring the rule asks for. php.switch_default_first_or_last scopes itself to switch for the same reason.

© 2026 Xygeni. All rights reserved