Previous Article
Find a Visitor's Location from Their IP Address in PHP (Safely)
23 January 2018
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 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.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 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.
<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');
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.
aria-live region. A sighted user sees the list populate; a screen reader user gets nothing unless you say so.<label> elements. Placeholder-only selects fail for screen readers and disappear once a value is chosen.replaceChildren discards the focused option.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.
tinyint caps at 127.AbortController to prevent stale overwrites.Advertisement
Written by
Amit VermaFounder / 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 AmitYou might also like
We ship software that scales. Let's work together.