Skip to content

Todo para WordPress, el desarrollo web — y mucho más

🚀 Subir múltiples archivos usando HTML y PHP

🚀 Subir múltiples archivos usando HTML y PHP

Un usuario necesita subir cinco imágenes, pero el formulario solo acepta una. O un cliente solicita carga de documentos por lotes con un título asociado a cada archivo. Esto no es fantasía: es una tarea estándar que PHP resuelve en treinta líneas.

El problema es que la mayoría de los tutoriales muestran cargas de un solo archivo. La carga múltiple funciona de manera distinta: un array de archivos en lugar de uno, mapeo a campos adicionales, protección contra sobrescritura. A continuación, una implementación funcional desde cero hasta un fragmento listo para usar.

💡 Resumen rápido:

  • Cree un formulario HTML con enctype="multipart/form-data" y un campo file con el atributo multiple.
  • Procese el array $_FILES en PHP: recorra los índices y use move_uploaded_file() para cada archivo.
  • Agregue validación de extensión y tamaño antes de guardar en disco.
  • Para vincular «archivo + título», use arrays paralelos title[] y fileUpload[].

Un video rápido que recorre el mismo tema, desde el formulario HTML hasta guardar archivos en una base de datos:

Paso 1: formulario HTML para subir múltiples archivos

Primero, construya el formulario. Puntos clave: el atributo enctype configurado como multipart/form-data y un campo de entrada con multiple. Sin enctype, el servidor simplemente no recibirá archivos; esto no es un error de PHP, sino de la especificación HTTP.

Nombre el campo como un array: fileUpload[]. Los corchetes al final le indican a PHP que el servidor está recibiendo una lista, no un solo archivo.

1<form action="" method="post" enctype="multipart/form-data">
2 <label>Select files:</label>
3 <input type="file" name="fileUpload[]" multiple>
4 <input type="submit" name="Submit" value="Upload">
5</form>

El formulario anterior permite seleccionar cualquier cantidad de archivos a la vez en el diálogo de archivos. El navegador los empaquetará en una solicitud multiparte y los enviará al servidor. No se necesita JavaScript en esta etapa.

Paso 2: procesamiento PHP del array de archivos

Cuando el formulario se envía al servidor, PHP llena el array superglobal $_FILES. Pero la estructura difiere de las cargas de un solo archivo: las claves name / tmp_name / size / error se convierten en arrays donde el índice corresponde al número secuencial del archivo.

Recorremos los índices y guardamos cada archivo usando move_uploaded_file():

1<?php
2
3$target_dir = 'uploads/';
4
5if (isset($_FILES['fileUpload']['name'])) {
6
7 $total_files = count($_FILES['fileUpload']['name']);
8
9 for ($key = 0; $key < $total_files; $key++) {
10
11 // Skip empty fields (no file selected)
12 if (isset($_FILES['fileUpload']['name'][$key])
13 && $_FILES['fileUpload']['size'][$key] > 0) {
14
15 $original_filename = $_FILES['fileUpload']['name'][$key];
16 $target = $target_dir . basename($original_filename);
17 $tmp = $_FILES['fileUpload']['tmp_name'][$key];
18
19 move_uploaded_file($tmp, $target);
20 }
21 }
22}

Agregue este código al functions.php de su tema hijo o al plugin Code Snippets, y el formulario del paso 1 comenzará a aceptar archivos.

Tenga en cuenta que basename() elimina la ruta del nombre del archivo. Esto protege contra ataques de path traversal: un atacante podría enviar ../../wp-config.php como nombre. basename() deja solo wp-config.php, pero la siguiente capa de defensa debería ser igualmente la validación de extensión.

Paso 3: verificación del tipo y tamaño del archivo

No puede aceptar «tal cual» todo lo que el navegador envía. Protección mínima: una lista blanca de extensiones y un límite de tamaño.

Verificación de extensión usando pathinfo() con el flag PATHINFO_EXTENSION. Convierta a minúsculas y compare con las extensiones permitidas:

1$allowed_ext = array('jpg', 'jpeg', 'png', 'gif', 'bmp', 'pdf', 'doc', 'docx');
2$ext = strtolower(pathinfo($_FILES['fileUpload']['name'][$key], PATHINFO_EXTENSION));
3
4if (!in_array($ext, $allowed_ext)) {
5 $errors[$key] = 'Invalid file type: ' . $ext;
6 continue;
7}

La verificación de tamaño es aún más simple; el valor size en bytes ya está en $_FILES:

1$max_file_size = 5 * 1024 * 1024; // 5 MB
2
3if ($_FILES['fileUpload']['size'][$key] > $max_file_size) {
4 $errors[$key] = 'File exceeds the allowed size (5 MB)';
5 continue;
6}

Coloque ambas verificaciones antes de move_uploaded_file() en el mismo bucle. Los archivos que no pasen la validación no llegarán al disco. Recoja los errores en un array y, después del procesamiento, muestre al usuario una lista indicando qué archivo falló y por qué. Sin esto, los usuarios solo verán un resultado vacío y se irán, asumiendo que el formulario está roto. Para cada archivo rechazado, informe el nombre y el motivo, como «photo.png: tipo no válido» o «archive.zip: tamaño excedido».

Paso 4: renombrar archivos durante la carga

Si dos usuarios suben photo.jpg, el segundo archivo sobrescribirá el primero. La solución es generar un nombre único al guardar. El enfoque más simple: reemplace espacios por guiones bajos y agregue time().

1if (isset($_FILES['fileUpload']['name'][$key])
2 && $_FILES['fileUpload']['size'][$key] > 0) {
3
4 $original_filename = $_FILES['fileUpload']['name'][$key];
5
6 // Extension separately
7 $ext = pathinfo($original_filename, PATHINFO_EXTENSION);
8
9 // Name without extension
10 $filename_without_ext = basename($original_filename, '.' . $ext);
11
12 // New name: no spaces + timestamp
13 $new_filename = str_replace(' ', '_', $filename_without_ext)
14 . '_' . time() . '.' . $ext;
15
16 move_uploaded_file(
17 $_FILES['fileUpload']['tmp_name'][$key],
18 $target_dir . $new_filename
19 );
20}

La marca de time() proporciona unicidad al segundo. En proyectos de alto tráfico, sustitúyala por uniqid() o UUID: dos solicitudes simultáneas dentro del mismo segundo son suficientes para causar una colisión y perder uno de los archivos. Además, time() revela la fecha de carga en el nombre del archivo, lo que a veces es indeseable. Un UUID de ramsey/uuid elimina completamente ambos problemas.

Paso 5: carga con campos adicionales para cada archivo

A veces necesita adjuntar un título, descripción o categoría a cada archivo. En lugar de un par «título + archivo», crea varios, nombrando los campos como arrays:

1<form action="" method="post" enctype="multipart/form-data">
2
3 <input type="text" name="title[]" placeholder="Title">
4 <input type="file" name="fileUpload[]">
5
6 <input type="text" name="title[]" placeholder="Title">
7 <input type="file" name="fileUpload[]">
8
9 <input type="text" name="title[]" placeholder="Title">
10 <input type="file" name="fileUpload[]">
11
12 <input type="submit" name="Submit" value="Upload">
13
14</form>
Formulario de carga con campos de título adicionales

Del lado del servidor, $_POST['title'] y $_FILES['fileUpload']['name'] son arrays paralelos. El índice 0 en uno corresponde al índice 0 en el otro. El procesamiento usa el mismo bucle, pero ahora con vinculación de título:

1$total_files = count($_FILES['fileUpload']['name']);
2
3for ($key = 0; $key < $total_files; $key++) {
4
5 if ($_FILES['fileUpload']['size'][$key] === 0) {
6 continue; // empty field — skip
7 }
8
9 $title = isset($_POST['title'][$key]) ? $_POST['title'][$key] : '';
10 $tmp = $_FILES['fileUpload']['tmp_name'][$key];
11 $fname = basename($_FILES['fileUpload']['name'][$key]);
12 $target = $target_dir . time() . '_' . $fname;
13
14 if (move_uploaded_file($tmp, $target)) {
15 // Save $title and $target to the database
16 // or into an array for further processing
17 }
18}

Asegúrese de que la cantidad de campos title[] coincida con la cantidad de campos fileUpload[] en el formulario. Si el usuario agrega campos dinámicamente mediante JavaScript, todos los índices deben ser consecutivos y sin huecos. PHP no colapsa automáticamente los arrays dispersos, por lo que $_POST['title'][5] puede existir mientras que $_FILES['fileUpload']['name'][5] no.

En la práctica, este enfoque cubre una buena mitad de las tareas de clientes: galerías de imágenes con leyendas, cargas de documentos con etiquetas, postulaciones con portafolios. El mismo principio de arrays paralelos funciona para descripción, categoría, orden y cualquier otro campo.

⁉️🤔 Preguntas frecuentes

¿Por qué el archivo no aparece en $_FILES aunque el formulario se haya enviado?

Nueve de cada diez veces, falta enctype="multipart/form-data" en la etiqueta <form>. Sin él, el navegador envía los datos como texto plano y PHP no llena $_FILES. Verifique también post_max_size y upload_max_filesize en php.ini; exceder cualquiera de ellos resulta en un array vacío sin errores visibles.

¿Cómo limito el tamaño total de carga para una sola solicitud?

La directiva post_max_size en php.ini trunca todo el cuerpo de la solicitud POST, incluidos archivos y campos de texto. El valor predeterminado según la documentación de PHP es 8 MB. Configúrelo más alto que upload_max_filesize con cierta holgura para la codificación multiparte (aproximadamente un 20%). Para cargas masivas, incremente ambos parámetros: upload_max_filesize = 20M y post_max_size = 25M.

¿Se pueden subir archivos de forma asíncrona mediante JavaScript?

Sí. Use FormData y fetch con method: 'POST'. Agregue campos fileUpload[] usando formData.append('fileUpload[]', file) para cada archivo seleccionado. No configure manualmente el encabezado Content-Type; el navegador establecerá automáticamente multipart/form-data con el límite correcto. Para retroalimentación visual, use una barra de progreso mediante XMLHttpRequest.upload.onprogress, ya que fetch no expone el progreso directamente.

¿Y la seguridad: un atacante sube un script PHP en lugar de una imagen?

La verificación de extensión del paso 3 es la primera línea de defensa. Segunda: almacene los archivos subidos FUERA de la carpeta raíz del sitio o en un directorio con un .htaccess que deshabilite la ejecución (php_flag engine off). Tercera: renombre los archivos al guardarlos (paso 4); incluso si shell.php pasa el filtro de extensión, se convierte en shell_1718300000.php, lo cual no es peligroso sin derechos de ejecución. Cuarta: verifique el tipo MIME no desde $_FILES['type'] (enviado por el navegador, fácilmente falsificable), sino mediante finfo (Fileinfo integrado de PHP), que lee la firma del archivo.

Lo que este enfoque proporciona: el panorama completo

Usted ha construido un sistema de carga que maneja tres escenarios de fábrica: archivos por lotes con un botón, carga única con validación y emparejamiento «archivo + metadatos» mediante arrays paralelos. El código ronda las treinta líneas por cada paso, nada extra.

Si trabaja en WordPress, coloque el manejador en el functions.php de su tema hijo o en el plugin Code Snippets. En PHP puro, simplemente ponga el script de procesamiento en el destino de acción del formulario.

El código de este artículo cubre la mayoría de las tareas típicas de carga. Los escenarios restantes (carga fragmentada para archivos grandes, carga directa a almacenamiento compatible con S3 y arrastrar y soltar con vista previa) se tratarán en artículos separados. Escriba en los comentarios cuál es relevante para su proyecto y lo priorizaremos.