Actualizado en noviembre de 2026. Este artículo se ha reescrito por completo: el código PHP original (que usaba las funciones mysql_* retiradas desde PHP 7) se ha migrado a PDO con consultas preparadas, se ha añadido una versión con JavaScript vanilla (fetch + async/await) sin jQuery, una demo interactiva que puedes probar aquí mismo y un ZIP renovado listo para desplegar. Los ejemplos siguen la misma lógica que la versión de 2015, así que si ya conocías el post te resultará familiar.

En este tutorial vas a aprender tres formas de cargar dinámicamente un campo desplegable <select> de HTML con datos desde el servidor:

  1. Con jQuery + AJAX, insertando directamente HTML.
  2. Con jQuery + JSON, para selects encadenados (país → región).
  3. Con JavaScript vanilla usando fetch() y async/await, sin ninguna librería.

En todos los casos el backend en PHP consulta una base de datos MySQL con PDO y consultas preparadas para evitar inyección SQL y devuelve los datos al frontend.

Al final del artículo tienes una demo interactiva y el código descargable actualizado.

 

Qué vamos a construir

Un formulario con dos selects encadenados:

  • El primer select se rellena al cargar la página con la lista de países.
  • Cuando el usuario elige un país, el segundo select se rellena automáticamente con las regiones de ese país.

Es el patrón clásico de select dependiente o dropdown en cascada, útil para formularios de dirección, filtros de búsqueda o cualquier caso donde una elección condicione la siguiente.

 

Estructura de la base de datos

Vamos a trabajar con dos tablas MySQL sencillas:

CREATE TABLE paises (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(80) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE regiones (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(80) NOT NULL,
  id_pais INT UNSIGNED NOT NULL,
  INDEX idx_pais (id_pais)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Fíjate en utf8mb4 como charset: es la recomendación actual porque soporta correctamente acentos, eñes y emojis. En el ZIP descargable tienes un paises.sql con la estructura y datos de ejemplo listos para importar en phpMyAdmin.

 

Conexión a la base de datos con PDO

Creamos un archivo db.php que centralice la conexión. Lo incluiremos desde los webservices:

<?php
// db.php - Conexión centralizada con PDO
$host    = 'localhost';
$db      = 'nombrebasededatos';
$user    = 'usuariobbdd';
$pass    = 'password';
$charset = 'utf8mb4';

$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
    $pdo = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
    http_response_code(500);
    exit('Error de conexión con la base de datos.');
}

Con esto tenemos una conexión reutilizable, con excepciones activadas y sin emulación de prepared statements (más seguro).

Ejemplo 1: jQuery + AJAX cargando HTML

El primer select se rellena en cuanto la página carga.

HTML:

<div class="selector-pais">
    <label>Elige un país</label>
    <select></select>
</div>

JavaScript (jQuery):

$(document).ready(function () {
    $.ajax({
        type: 'GET',
        url: 'getPaises.php',
        success: function (response) {
            $('.selector-pais select').html(response).fadeIn();
        },
        error: function () {
            $('.selector-pais select').html('<option>Error al cargar</option>');
        }
    });
});

Webservice PHP (getPaises.php):

<?php
require __DIR__ . '/db.php';
header('Content-Type: text/html; charset=utf-8');

$stmt = $pdo->query('SELECT id, nombre FROM paises ORDER BY nombre');

echo '<option value="0">Seleccionar</option>';
foreach ($stmt as $fila) {
    printf(
        '<option value="%d">%s</option>',
        (int) $fila['id'],
        htmlspecialchars($fila['nombre'], ENT_QUOTES, 'UTF-8')
    );
}

Qué ha cambiado respecto a la versión de 2015:

  • mysql_*PDO.
  • Salida escapada con htmlspecialchars() para evitar XSS.
  • id casteado a entero antes de imprimirlo.
  • Cabecera Content-Type explícita con charset UTF-8.
  • error handler en la llamada AJAX.

Ejemplo 2: jQuery + JSON para selects encadenados

Cuando el usuario cambia el país, cargamos sus regiones desde otro webservice que devuelve JSON.

HTML:

<div class="con-json">
    <label>Regiones</label>
    <select disabled><option>Elige un país primero</option></select>
</div>

JavaScript (jQuery):

$(document).ready(function () {
    $('.selector-pais select').on('change', function () {
        var pais = $(this).val();
        var $regiones = $('.con-json select');

        $regiones.prop('disabled', true).empty()
                 .append('<option>Cargando…</option>');

        $.getJSON('getRegionesJson.php', { pais: pais }, function (data) {
            $regiones.empty()
                     .append('<option value="0">Seleccionar</option>');
            $.each(data, function (id, nombre) {
                $regiones.append(
                    $('<option>').val(id).text(nombre)
                );
            });
            $regiones.prop('disabled', false);
        }).fail(function () {
            $regiones.empty().append('<option>Error al cargar</option>');
        });
    });
});

Fíjate en dos detalles importantes:

  • $('<option>').val(id).text(nombre) en lugar de concatenar strings. jQuery escapa el texto automáticamente, lo que evita XSS si el nombre de la región contiene caracteres HTML.
  • Estado disabled y mensaje «Cargando…» para dar feedback al usuario mientras dura la petición (UX).

Webservice PHP (getRegionesJson.php):

<?php
require __DIR__ . '/db.php';
header('Content-Type: application/json; charset=utf-8');

$pais = filter_input(INPUT_GET, 'pais', FILTER_VALIDATE_INT);

if ($pais === false || $pais === null || $pais <= 0) {
    echo json_encode([]);
    exit;
}

$stmt = $pdo->prepare(
    'SELECT id, nombre FROM regiones WHERE id_pais = :pais ORDER BY nombre'
);
$stmt->execute([':pais' => $pais]);

$regiones = [];
foreach ($stmt as $fila) {
    $regiones[(int) $fila['id']] = $fila['nombre'];
}

echo json_encode($regiones, JSON_UNESCAPED_UNICODE);

Cambios críticos respecto a 2015:

  • filter_input(..., FILTER_VALIDATE_INT) valida que pais sea un entero antes de usarlo.
  • Consulta preparada con :pais: el valor nunca se concatena al SQL, cerrando por completo la puerta a la inyección SQL.
  • Content-Type: application/json para que el navegador lo interprete correctamente.
  • JSON_UNESCAPED_UNICODE para que los acentos y la ñ salgan tal cual, sin escapes tipo \u00f1.

Ejemplo 3: la misma solución sin jQuery (vanilla JS)

Si empiezas un proyecto nuevo hoy en 2026, probablemente no necesites jQuery. Aquí tienes la misma funcionalidad con fetch() y async/await:

document.addEventListener('DOMContentLoaded', async () => {
    const paisSelect   = document.querySelector('.selector-pais select');
    const regionSelect = document.querySelector('.con-json select');

    // 1. Cargar países al iniciar
    try {
        const res  = await fetch('getPaises.php');
        const html = await res.text();
        paisSelect.innerHTML = html;
    } catch (err) {
        paisSelect.innerHTML = '<option>Error al cargar</option>';
    }

    // 2. Al cambiar de país, cargar regiones
    paisSelect.addEventListener('change', async () => {
        const pais = paisSelect.value;
        regionSelect.disabled = true;
        regionSelect.innerHTML = '<option>Cargando…</option>';

        try {
            const res  = await fetch(`getRegionesJson.php?pais=${encodeURIComponent(pais)}`);
            const data = await res.json();

            regionSelect.innerHTML = '<option value="0">Seleccionar</option>';
            for (const [id, nombre] of Object.entries(data)) {
                const opt = document.createElement('option');
                opt.value = id;
                opt.textContent = nombre; // .textContent escapa automáticamente
                regionSelect.appendChild(opt);
            }
            regionSelect.disabled = false;
        } catch (err) {
            regionSelect.innerHTML = '<option>Error al cargar</option>';
        }
    });
});

Los webservices PHP son exactamente los mismos. Solo cambia el frontend. Ventajas: sin dependencias, más rápido en descargar, sintaxis moderna con async/await.

Demo interactiva

Prueba el patrón sin salir del artículo. La demo usa datos embebidos (sin backend), así ves el comportamiento en tiempo real:

Demo en vivo

Selects encadenados: País → Región



Selección actual:

Datos embebidos en JavaScript (sin backend) para que la demo funcione en cualquier navegador. En producción, los datos vendrían del webservice PHP.

Descargar el código completo

En el ZIP encontrarás:

  • db.php — conexión PDO reutilizable.
  • getPaises.php — webservice del primer select.
  • getRegionesJson.php — webservice del segundo select con JSON.
  • index.html — demo con jQuery apuntando a los webservices.
  • paises.sql — estructura de las tablas y datos de ejemplo (países y regiones de Europa y América Latina).
  • README.md — instrucciones de instalación paso a paso.

Descargar

Descargas realizadas: 6.867

Preguntas frecuentes

¿Por qué no funciona el código original con mysql_connect?

Porque las funciones mysql_* están oficialmente eliminadas desde PHP 7.0, publicado en diciembre de 2015. Cualquier hosting moderno usa PHP 7.4, 8.0, 8.1 o superior, donde esas funciones ya no existen. La versión actualizada usa PDO, que es la API recomendada y funciona en todas las versiones actuales de PHP.

¿Cómo evito la inyección SQL en el segundo select?

Nunca concatenes el valor recibido al SQL. Usa consultas preparadas con parámetros nombrados (:pais) y valida el tipo del dato antes con filter_input(INPUT_GET, 'pais', FILTER_VALIDATE_INT). Con eso, aunque un atacante envíe ?pais=1; DROP TABLE regiones, el valor se rechaza porque no es un entero válido.

Los acentos y la ñ salen como símbolos raros, ¿cómo lo arreglo?

Es un problema de codificación. Asegúrate de tres cosas: 1) que la tabla MySQL use utf8mb4, 2) que el archivo PHP se guarde como UTF-8 sin BOM, y 3) que el webservice envíe header('Content-Type: application/json; charset=utf-8') y use JSON_UNESCAPED_UNICODE al codificar.

¿Cómo hago un tercer select en cascada (país → región → ciudad)?

Aplica la misma lógica: crea un webservice getCiudadesJson.php que reciba ?region=X, y añade un tercer change listener en el segundo select apuntando al tercer select. Cada select depende del anterior siguiendo el mismo patrón.

¿Cómo marco una opción como seleccionada al editar un registro?

Después de rellenar el select con los datos, ejecuta $('.con-json select').val(idGuardado). Si el valor no existe todavía cuando cargas la página, hazlo dentro del callback success o después del await fetch, para asegurarte de que las opciones ya están en el DOM.

¿Puedo usar esto sin base de datos MySQL?

Sí. Sustituye la consulta PDO por un array PHP con los datos (o un archivo JSON estático) y el resto del código funciona igual. Para prototipos rápidos también puedes servir directamente un .json desde el servidor y llamarlo con fetch('paises.json').

¿Cómo envío datos adicionales por POST al webservice?

En jQuery, añade la propiedad data a $.ajax: data: { id: $('#miInput').val() }. Recíbelo en PHP con filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT) y úsalo como parámetro en la consulta preparada.

Changelog

  • 2026-11 – Migración completa a PDO con prepared statements. Corregida vulnerabilidad de inyección SQL. Añadida versión con JavaScript vanilla + fetch. Nueva demo interactiva. Nuevo ZIP descargable con datos de ejemplo.
  • 2015-08 – Publicación original con mysql_* y jQuery.


(Visitado 54.869 veces, 1 visitas hoy)

¿Te ha sido útil este contenido? Puedes invitarme a un café para ayudar a mantener el proyecto.

Invítame a un café