Ya sabes crear formularios en PHP y guardar datos en MySQL. Pero llega el momento en que el formulario no pide solo texto — pide una foto de perfil, un justificante en PDF, un currículum. Y ahí es cuando la mayoría de tutoriales de subida de archivos se quedan cortos: te enseñan a mover el archivo al servidor y ya está, sin decirte que ese "ya está" es justo donde la gente mete la pata.
Subir un archivo mal validado no es un fallo estético. Es la puerta de entrada para que alguien suba un script PHP disfrazado de imagen y lo ejecute en tu servidor. Este tutorial va de eso: subir el archivo, sí, pero sobre todo de comprobar que lo que estás guardando es lo que crees que es.
Lo básico: formulario, $_FILES y move_uploaded_file()
Para subir un archivo desde un formulario HTML necesitas un atributo que casi todo el mundo olvida la primera vez: enctype="multipart/form-data". Sin él, PHP ni siquiera recibe el archivo, solo el nombre del campo vacío — y te puedes tirar media hora mirando el código pensando que el problema está en PHP cuando está en el HTML.
<form action="subir.php" method="POST" enctype="multipart/form-data">
<label>Selecciona un archivo:</label>
<input type="file" name="archivo" required>
<button type="submit">Subir</button>
</form>
Cuando el formulario llega a PHP, la información del archivo no está en $_POST — está en un superglobal aparte llamado $_FILES. Es un array un poco raro la primera vez que lo ves, así que vamos a desmontarlo:
<?php
print_r($_FILES['archivo']);
// Array
// (
// [name] => factura.pdf -> nombre original que puso el usuario
// [type] => application/pdf -> tipo MIME que dice el navegador (NO te fíes)
// [tmp_name] => /tmp/php7A3f.tmp -> dónde está guardado TEMPORALMENTE
// [error] => 0 -> 0 significa "sin errores"
// [size] => 204800 -> tamaño en bytes
// )
?>
⚠️ Ojo con esto:
El archivo llega a una carpeta temporal del servidor (tmp_name) y se borra automáticamente al terminar la petición. Si no lo mueves a un sitio permanente antes de que el script acabe, lo pierdes. Para eso está move_uploaded_file().
<?php
if ($_FILES['archivo']['error'] === UPLOAD_ERR_OK) {
$origen = $_FILES['archivo']['tmp_name'];
$destino = 'subidas/' . $_FILES['archivo']['name'];
if (move_uploaded_file($origen, $destino)) {
echo "Archivo subido correctamente";
}
}
?>
Esto funciona. Y es exactamente el punto donde el 90% de los tutoriales de PHP se paran, te dan la enhorabuena y te dejan con un formulario que acepta absolutamente cualquier cosa. Aquí es donde de verdad empieza el tutorial.
Validación: el core de todo esto
El código de arriba coge lo que sea que te manden y lo guarda con el nombre que sea que te manden, en la carpeta que sea que tú decidiste que fuera pública. Eso son tres problemas a la vez. Vamos uno a uno.
Whitelist, no blacklist
La primera tentación es hacer una lista de extensiones prohibidas: "bloqueo .php, .exe, .sh y ya está protegido". Esto falla, y falla rápido. Un atacante no necesita subir malware.php — le basta con factura.pdf.php. Si tu validación solo mira si la extensión termina en algo "peligroso" conocido, ese archivo pasa sin problema, porque técnicamente no está en tu lista negra.
La solución real es al revés: en vez de bloquear lo malo, permite solo lo que esperas. Si tu formulario es para subir una foto de perfil, solo tiene sentido aceptar jpg, jpeg, png y webp. Todo lo demás, fuera — sin excepciones ni "por si acaso".
<?php
$extensionesPermitidas = ['jpg', 'jpeg', 'png', 'webp'];
$nombreOriginal = $_FILES['archivo']['name'];
$extension = strtolower(pathinfo($nombreOriginal, PATHINFO_EXTENSION));
if (!in_array($extension, $extensionesPermitidas)) {
die("Extensión no permitida. Solo se aceptan: " . implode(', ', $extensionesPermitidas));
}
?>
💡 Esto me costó pillarlo:
La extensión sola no basta como validación — solo sirve para descartar rápido lo obviamente incorrecto. Un archivo puede llamarse foto.jpg y llevar dentro código PHP perfectamente válido. La extensión es solo el primer filtro, no el único.
Verificar el tipo real del archivo, no lo que dice el navegador
$_FILES['archivo']['type'] es el tipo MIME que el navegador afirma que tiene el archivo — y eso lo controla el cliente, no tu servidor. Cualquiera con las herramientas de desarrollador abiertas puede cambiar ese valor antes de enviarlo. Confiar en ese campo para decidir si algo es seguro es como confiar en que alguien te diga la verdad sobre su edad sin pedirle el DNI.
Lo que sí puedes verificar de verdad es el contenido real del archivo, con finfo, que mira los primeros bytes del fichero (la "firma" del formato) en vez de fiarse de lo que pone la extensión o el navegador:
<?php
$tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipoReal = $finfo->file($_FILES['archivo']['tmp_name']);
if (!in_array($tipoReal, $tiposPermitidos)) {
die("El archivo no es una imagen válida");
}
?>
Esta comprobación es la que de verdad separa "confío en la extensión" de "he mirado qué es realmente este archivo". Un .jpg que en realidad es un script PHP no pasa este filtro, porque su contenido no empieza con la firma de una imagen JPEG.
💡 Una capa extra si luego vas a procesar la imagen:
Si en algún momento vas a abrir esa imagen con funciones como imagecreatefromjpeg() (por ejemplo para generar una miniatura), añade también getimagesize($_FILES['archivo']['tmp_name']) antes de procesarla. finfo te dice que el archivo tiene la firma de una imagen; getimagesize() va un paso más allá y confirma que la imagen es decodificable de verdad, con ancho y alto legibles. Si no es una imagen válida, devuelve false en vez de lanzar un error, así que compruébalo con un if.
Límite de tamaño
Tienes que limitar el tamaño en dos sitios, no en uno. En tu código PHP con $_FILES['archivo']['size'], que te da el tamaño en bytes:
<?php
$tamanoMaximo = 2 * 1024 * 1024; // 2 MB
if ($_FILES['archivo']['size'] > $tamanoMaximo) {
die("El archivo pesa demasiado. Máximo 2 MB");
}
?>
⚠️ No te olvides de php.ini:
PHP también aplica su propio límite antes de que tu código se ejecute siquiera, con las directivas upload_max_filesize y post_max_size de php.ini. Si alguien sube un archivo más grande que ese límite del servidor, $_FILES ni siquiera llega a rellenarse — el error aparece en $_FILES['archivo']['error'] con el código UPLOAD_ERR_INI_SIZE. Si haces pruebas subiendo un archivo grande y no pasa nada, mira ahí antes de sospechar de tu propio código.
Seguridad al guardar el archivo
Ya validaste extensión, tipo real y tamaño. Falta la parte que casi nadie menciona: cómo y dónde lo guardas influye tanto como lo que dejaste pasar.
Nunca uses el nombre que manda el usuario
El nombre original del archivo es texto que controla quien lo sube. Puede llevar rutas (../../config.php), caracteres raros, o coincidir con el nombre de un archivo que ya existe y sobrescribirlo sin que te enteres. La solución es generar tú el nombre, siempre:
<?php
$extension = strtolower(pathinfo($_FILES['archivo']['name'], PATHINFO_EXTENSION));
$nombreSeguro = bin2hex(random_bytes(16)) . '.' . $extension;
// nombreSeguro ahora es algo tipo: 8f3a1c9b7e2d4f10a6b5c8e9d7f2a4b3.jpg
?>
El nombre original, si te interesa conservarlo para mostrarlo al usuario, lo guardas en la base de datos junto a la ruta real. Nunca como parte del nombre físico del archivo en disco.
Guarda fuera de la raíz pública
Si guardas los archivos subidos dentro de una carpeta accesible directamente por URL (por ejemplo /subidas/ dentro de htdocs), cualquiera que adivine o encuentre la ruta puede acceder al archivo directamente — y si por lo que sea consiguió colar algo ejecutable, el servidor lo ejecutaría igual que cualquier otro .php. Lo más seguro es guardar los archivos fuera de la carpeta pública y servirlos a través de un script PHP que compruebe permisos antes de mostrarlos:
// Estructura recomendada:
// /htdocs/ <- raíz pública, accesible por navegador
// index.php
// descargar.php <- este SÍ sirve los archivos, con control de acceso
// /almacen_privado/ <- FUERA de htdocs, no accesible directamente
// 8f3a1c9b...jpg
Si tu hosting no te permite salir de la carpeta pública, la alternativa mínima es una carpeta dedicada con un archivo .htaccess que impida ejecutar PHP dentro de ella:
# Dentro de /subidas/.htaccess
php_flag engine off
Permisos correctos, nunca 777
Es tentador poner chmod 777 a la carpeta de subidas cuando algo no funciona y así "quitarte el problema de encima". No lo hagas — 777 significa que cualquier proceso del servidor puede leer, escribir y ejecutar cualquier cosa ahí dentro. Para una carpeta de archivos subidos, con 644 en los archivos (lectura para todos, escritura solo para el propietario) y 755 en la carpeta contenedora es más que suficiente.
❌ Error real que verás mucho en foros:
Alguien pregunta "por qué no me sube el archivo" y la primera respuesta que encuentra en internet es "prueba con chmod 777". Funciona, sí — porque quita literalmente todas las restricciones. Es la versión programación de quitar la puerta de casa porque la llave no giraba bien.
Ejercicio: formulario de imagen de perfil con validación completa
Vamos a juntar todo en un ejemplo real: un formulario que sube una foto de perfil, valida extensión, tipo real y tamaño, y guarda el archivo con un nombre seguro.
perfil.html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Subir foto de perfil</title>
</head>
<body>
<h2>Sube tu foto de perfil</h2>
<form action="procesar_perfil.php" method="POST" enctype="multipart/form-data">
<label>Imagen (JPG, PNG o WEBP, máx. 2 MB):</label><br>
<input type="file" name="foto_perfil" accept=".jpg,.jpeg,.png,.webp" required><br><br>
<button type="submit">Subir foto</button>
</form>
</body>
</html>
El atributo accept del input solo mejora la experiencia (filtra qué se ve en el selector de archivos del navegador) — no es seguridad real, porque se puede saltar fácilmente. La validación de verdad va siempre en el servidor.
procesar_perfil.php
<?php
if (!isset($_FILES['foto_perfil']) || $_FILES['foto_perfil']['error'] !== UPLOAD_ERR_OK) {
die("No se recibió ningún archivo válido");
}
$archivo = $_FILES['foto_perfil'];
// 1. Validar extensión (whitelist)
$extensionesPermitidas = ['jpg', 'jpeg', 'png', 'webp'];
$extension = strtolower(pathinfo($archivo['name'], PATHINFO_EXTENSION));
if (!in_array($extension, $extensionesPermitidas)) {
die("Extensión no permitida. Solo JPG, PNG o WEBP");
}
// 2. Validar tipo real del archivo (no la extensión ni el navegador)
$tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipoReal = $finfo->file($archivo['tmp_name']);
if (!in_array($tipoReal, $tiposPermitidos)) {
die("El archivo no es una imagen válida");
}
// 3. Validar tamaño (2 MB máximo)
$tamanoMaximo = 2 * 1024 * 1024;
if ($archivo['size'] > $tamanoMaximo) {
die("La imagen pesa demasiado. Máximo 2 MB");
}
// 4. Generar nombre seguro (nunca el original)
$nombreSeguro = bin2hex(random_bytes(16)) . '.' . $extension;
$carpetaDestino = __DIR__ . '/almacen_privado/';
$rutaDestino = $carpetaDestino . $nombreSeguro;
// 5. Mover el archivo
if (move_uploaded_file($archivo['tmp_name'], $rutaDestino)) {
chmod($rutaDestino, 0644);
echo "<p>✅ Foto de perfil subida correctamente</p>";
// Aquí guardarías $nombreSeguro en la base de datos, asociado al usuario
} else {
die("Error al guardar el archivo");
}
?>
Fíjate en el orden: primero se valida todo, y solo si pasa las tres comprobaciones se genera el nombre y se mueve el archivo. Si cambias el orden y mueves el archivo antes de validar, durante una fracción de segundo tienes un archivo sin verificar en tu servidor — mejor no arriesgarse.
¿Y ahora qué?
Ya sabes recibir un archivo, validarlo de verdad (no solo por la extensión) y guardarlo sin exponerte. Si llegaste aquí directamente sin pasar por los fundamentos de PHP, te conviene empezar por PHP básico para principiantes — variables, condicionales y formularios explicados desde cero.
El siguiente paso lógico es dejar de guardar solo el archivo en el disco y empezar a guardar su ruta en una base de datos, para poder asociarlo a un usuario o a un registro concreto. Eso lo tienes en PHP y MySQL CRUD (primera base de datos conectada).