Skip to main content
Being Idea Innovations
Dependent Country / State / City Dropdowns with PHP and Fetch
Back to Blog

Dependent Country / State / City Dropdowns with PHP and Fetch

11 February 20186 min readMySql
Share:

Dependent dropdowns — pick a country, the state list populates, pick a state, the city list follows — are a standard form pattern. The classic PHP implementation has a schema flaw that breaks at scale and a race condition that shows up on slow connections.

The schema, fixed

The version that circulates widely, including the earlier version of this article, declares its keys like this:

-- Don't do this
CREATE TABLE `country` (
  `id` tinyint(4) NOT NULL auto_increment,
  `country` varchar(20) NOT NULL default '',
  PRIMARY KEY (`id`)
) ENGINE=MyISAM;

Three problems:

  • tinyint(4) holds a maximum of 127. There are 195 countries. Your inserts fail silently past 127, or land on the wrong rows. Cities are worse — you will exhaust it almost immediately.
  • MyISAM has no foreign keys and no transactions. Nothing stops a city referencing a deleted state.
  • varchar(20) for a country name truncates plenty of real ones.

A workable version:

CREATE TABLE countries (
  id   SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  iso2 CHAR(2) NOT NULL,
  name VARCHAR(100) NOT NULL,
  UNIQUE KEY uniq_iso2 (iso2)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE states (
  id         MEDIUMINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  country_id SMALLINT UNSIGNED NOT NULL,
  name       VARCHAR(100) NOT NULL,
  KEY idx_country (country_id, name),
  CONSTRAINT fk_state_country FOREIGN KEY (country_id)
    REFERENCES countries(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE cities (
  id       INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  state_id MEDIUMINT UNSIGNED NOT NULL,
  name     VARCHAR(100) NOT NULL,
  KEY idx_state (state_id, name),
  CONSTRAINT fk_city_state FOREIGN KEY (state_id)
    REFERENCES states(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The composite indexes on (country_id, name) serve both the filter and the sort, so the query never needs a filesort.

A note on the sample data in the old article: it listed "Los Angeles" as a state of the USA and "Toranto" as a state of Canada. Los Angeles is a city in California; Toronto is a city in Ontario, and is misspelled. Worth mentioning because people copy sample data straight into production and then wonder why their address forms are wrong.

The API: return JSON, not HTML

The old approach echoed <option> tags from PHP and dropped them in with innerHTML. That couples your markup to your backend — restyling the dropdown means a PHP deploy — and it is an XSS vector if any name contains markup.

<?php
declare(strict_types=1);

header('Content-Type: application/json');
header('Cache-Control: public, max-age=86400');   // this data barely changes

$pdo = new PDO('mysql:host=localhost;dbname=geo;charset=utf8mb4', $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$countryId = filter_input(INPUT_GET, 'country_id', FILTER_VALIDATE_INT);
$stateId   = filter_input(INPUT_GET, 'state_id', FILTER_VALIDATE_INT);

try {
    if ($stateId) {
        $stmt = $pdo->prepare('SELECT id, name FROM cities WHERE state_id = ? ORDER BY name');
        $stmt->execute([$stateId]);
    } elseif ($countryId) {
        $stmt = $pdo->prepare('SELECT id, name FROM states WHERE country_id = ? ORDER BY name');
        $stmt->execute([$countryId]);
    } else {
        $stmt = $pdo->query('SELECT id, name FROM countries ORDER BY name');
    }

    echo json_encode($stmt->fetchAll(), JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
    error_log($e->getMessage());
    http_response_code(500);
    echo json_encode(['error' => 'Could not load options.']);
}

Prepared statements throughout. The original built its query by concatenating $_GET directly, which is straightforward SQL injection.

The Cache-Control header is worth having. Country and state lists change perhaps once a year, so letting the browser cache them for a day removes most of these requests entirely.

The front end

<div class="field">
  <label for="country">Country</label>
  <select id="country" name="country_id" required>
    <option value="">Select a country</option>
  </select>
</div>

<div class="field">
  <label for="state">State</label>
  <select id="state" name="state_id" required disabled>
    <option value="">Select a country first</option>
  </select>
</div>

<div class="field">
  <label for="city">City</label>
  <select id="city" name="city_id" required disabled>
    <option value="">Select a state first</option>
  </select>
</div>

<p id="dropdown-status" role="status" aria-live="polite" class="sr-only"></p>
const country = document.getElementById('country');
const state   = document.getElementById('state');
const city    = document.getElementById('city');
const status  = document.getElementById('dropdown-status');

// One controller per select, so a slow response cannot overwrite a newer one.
const controllers = new Map();

async function loadOptions(select, params, placeholder) {
  // Cancel any request still in flight for this select.
  controllers.get(select.id)?.abort();
  const controller = new AbortController();
  controllers.set(select.id, controller);

  select.disabled = true;
  select.innerHTML = '<option value="">Loading…</option>';

  try {
    const url = new URL('/api/locations.php', location.origin);
    Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));

    const res = await fetch(url, { signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const items = await res.json();

    // Build with the DOM API — textContent cannot execute injected markup.
    const frag = document.createDocumentFragment();
    frag.append(new Option(placeholder, ''));

    for (const item of items) {
      const opt = new Option(item.name, item.id);
      frag.append(opt);
    }

    select.replaceChildren(frag);
    select.disabled = items.length === 0;
    status.textContent = `${items.length} options loaded`;
  } catch (err) {
    if (err.name === 'AbortError') return;   // superseded, not a failure
    select.innerHTML = '<option value="">Could not load options</option>';
    status.textContent = 'Failed to load options.';
  }
}

function reset(select, placeholder) {
  select.replaceChildren(new Option(placeholder, ''));
  select.disabled = true;
}

country.addEventListener('change', () => {
  reset(city, 'Select a state first');

  if (!country.value) {
    reset(state, 'Select a country first');
    return;
  }
  loadOptions(state, { country_id: country.value }, 'Select a state');
});

state.addEventListener('change', () => {
  if (!state.value) {
    reset(city, 'Select a state first');
    return;
  }
  loadOptions(city, { state_id: state.value }, 'Select a city');
});

// Initial population
loadOptions(country, {}, 'Select a country');

Why AbortController matters here

Without it there is a real race. A user picks "India", the request starts. Before it returns they change to "Australia", starting a second request. If the first response arrives after the second — entirely possible on a flaky connection — the state dropdown ends up showing Indian states while the country reads Australia. Cancelling the previous request makes that impossible.

The AbortError check in the catch is important too: an aborted request rejects, and without that guard you would show an error for a request you deliberately cancelled.

Accessibility

  • Disable dependent selects until they have options. An empty enabled dropdown is confusing; a disabled one with explanatory placeholder text is not.
  • Announce loading and completion through an aria-live region. A sighted user sees the list populate; a screen reader user gets nothing unless you say so.
  • Keep real <label> elements. Placeholder-only selects fail for screen readers and disappear once a value is chosen.
  • Never rebuild a select while it has focus without restoring focus — replaceChildren discards the focused option.

When the list gets long

A native <select> with 40,000 cities is unusable and slow to render. Past a few hundred options, switch to a search-as-you-type combobox backed by a query with LIMIT:

$stmt = $pdo->prepare(
    'SELECT id, name FROM cities
      WHERE state_id = ? AND name LIKE CONCAT(?, "%")
      ORDER BY name LIMIT 25'
);
$stmt->execute([$stateId, $term]);

Use a prefix match ('term%') rather than a contains match ('%term%') — a leading wildcard cannot use the index and forces a full scan. Debounce the input by around 250ms so you are not firing a query per keystroke.

Summary

  • Size your key columns for the data — tinyint caps at 127.
  • Use InnoDB with real foreign keys.
  • Return JSON and build options on the client.
  • Prepared statements, always.
  • Cancel in-flight requests with AbortController to prevent stale overwrites.
  • Cache the responses — this data rarely changes.
  • Disable dependent selects and announce changes to assistive technology.
Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers