Artículo original de marzo de 2003, cedido por MySQL Hispano. Actualizado en mayo de 2026 por David Carrero. El código original usaba mysql_connect() y addslashes() para escapar los datos binarios, técnicas eliminadas en PHP 7.0 (diciembre de 2015) e incompatibles con datos binarios arbitrarios. Esta versión usa PHP 8 con PDO y PDO::PARAM_LOB.
Qué es un repositorio de archivos en MySQL
Un repositorio de archivos almacena documentos binarios (PDFs, imágenes, ZIPs, presentaciones) directamente en MySQL usando el tipo de dato BLOB. Cada registro de la tabla contiene tanto los metadatos del archivo (nombre, tipo MIME, tamaño, fecha) como su contenido binario.
La ventaja principal es la coherencia: el archivo y sus metadatos siempre están en el mismo backup y la misma transacción. No es posible que exista un registro en la base de datos apuntando a un fichero que alguien borró del disco.
La desventaja es el rendimiento y la escalabilidad: servir un archivo desde MySQL implica que PHP lea los bytes de la base de datos y los escriba en la respuesta HTTP, mientras que servir un fichero del disco puede hacerlo el servidor web (Apache o Nginx) directamente, con soporte de caché, sendfile() y CDN. Para repositorios pequeños o de uso interno, MySQL es perfectamente válido. Para almacenamiento masivo de archivos grandes en producción, lo habitual es el sistema de ficheros o un servicio de objeto como Amazon S3.
Tipos BLOB disponibles en MySQL
Tipo | Capacidad máxima | Recomendado para |
| 255 bytes | Datos muy pequeños, iconos |
| ~64 KB | Miniaturas, thumbnails |
| ~16 MB | PDFs, documentos Office, imágenes |
| ~4 GB | Vídeos, archivos de backup |
Para un repositorio de documentos general, MEDIUMBLOB es la opción correcta: cubre la mayoría de PDFs, presentaciones y documentos sin consumir el límite de LONGBLOB. Recuerda ajustar max_allowed_packet en my.cnf al tamaño máximo que quieras permitir:
# /etc/mysql/mysql.conf.d/mysqld.cnf [mysqld] max_allowed_packet = 64M
Crear la base de datos y la tabla
El esquema original de 2003 usaba cuatro campos. Añadimos el tamaño en bytes y la fecha de subida, que son útiles para listar y gestionar el repositorio:
CREATE DATABASE IF NOT EXISTS repositorio
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE repositorio;
-- Crear el usuario con los permisos mínimos necesarios
CREATE USER IF NOT EXISTS 'repo_user'@'localhost' IDENTIFIED BY 'contraseña_fuerte';
GRANT SELECT, INSERT, UPDATE, DELETE ON repositorio.* TO 'repo_user'@'localhost';
CREATE TABLE archivos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(255) NOT NULL COMMENT 'nombre original del fichero',
titulo VARCHAR(255) NOT NULL DEFAULT '',
tipo VARCHAR(100) NOT NULL COMMENT 'MIME type real (image/jpeg, application/pdf...)',
tamanio INT UNSIGNED NOT NULL DEFAULT 0,
contenido MEDIUMBLOB NOT NULL,
fecha_subida DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (fecha_subida)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Formulario de subida
<!-- escoger_archivo.html -->
<form enctype="multipart/form-data" action="guardar_archivo.php" method="post">
<label>
Título (descripción breve):
<input type="text" name="titulo" maxlength="255" required>
</label>
<label>
Archivo (PDF, DOCX, ZIP, JPG, PNG máx. 10 MB):
<input type="file" name="archivito" required>
</label>
<button type="submit">Enviar archivo</button>
</form>
Script guardar_archivo.php PHP 8 con PDO
El código original usaba addslashes() para escapar el binario antes de interpolarlo en la SQL. Este método es incorrecto para datos binarios: addslashes() solo escapa comillas y barras, no todos los bytes que puede contener un binario arbitrario. PDO con PDO::PARAM_LOB gestiona el binario de forma correcta y además evita la inyección SQL por diseño:
<?php
// guardar_archivo.php PHP 8, PDO
declare(strict_types=1);
// ??? Configuración ????????????????????????????????????????????????????????????
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
$tiposPermitidos = [
'application/pdf',
'application/zip',
'application/x-zip-compressed',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'text/plain',
];
// ??? Validar la subida ????????????????????????????????????????????????????????
$archivo = $_FILES['archivito'] ?? null;
if (!$archivo || $archivo['error'] !== UPLOAD_ERR_OK) {
$mensajes = [
UPLOAD_ERR_INI_SIZE => 'El archivo supera el límite de php.ini (upload_max_filesize)',
UPLOAD_ERR_FORM_SIZE => 'El archivo supera el límite del formulario',
UPLOAD_ERR_PARTIAL => 'Solo se subió una parte del archivo',
UPLOAD_ERR_NO_FILE => 'No se seleccionó ningún archivo',
UPLOAD_ERR_NO_TMP_DIR => 'No existe directorio temporal',
UPLOAD_ERR_CANT_WRITE => 'No se pudo escribir en disco',
];
$msg = $mensajes[$archivo['error'] ?? -1] ?? 'Error desconocido';
die("Error al subir: $msg");
}
if ($archivo['size'] > MAX_BYTES) {
die('El archivo supera el tamaño máximo permitido (10 MB).');
}
// Validar tipo MIME real con finfo no confiar en $_FILES['type']
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeReal = $finfo->file($archivo['tmp_name']);
if (!in_array($mimeReal, $tiposPermitidos, true)) {
die("Tipo de archivo no permitido: $mimeReal");
}
// ??? Leer el contenido binario del archivo temporal ???????????????????????????
$contenido = file_get_contents($archivo['tmp_name']);
if ($contenido === false) {
die('No se pudo leer el archivo temporal.');
}
// ??? Insertar en MySQL con PDO ????????????????????????????????????????????????
try {
$dsn = 'mysql:host=localhost;dbname=repositorio;charset=utf8mb4';
$pdo = new PDO($dsn, 'repo_user', 'contraseña_fuerte', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
"INSERT INTO archivos (nombre, titulo, tipo, tamanio, contenido)
VALUES (:nombre, :titulo, :tipo, :tamanio, :contenido)"
);
$stmt->bindValue(':nombre', $archivo['name'], PDO::PARAM_STR);
$stmt->bindValue(':titulo', $_POST['titulo'] ?? '', PDO::PARAM_STR);
$stmt->bindValue(':tipo', $mimeReal, PDO::PARAM_STR);
$stmt->bindValue(':tamanio', $archivo['size'], PDO::PARAM_INT);
$stmt->bindParam(':contenido', $contenido, PDO::PARAM_LOB);
$stmt->execute();
$nuevoId = $pdo->lastInsertId();
echo "Archivo guardado correctamente. ID asignado: $nuevoId";
} catch (PDOException $e) {
error_log('Error guardando archivo: ' . $e->getMessage());
die('No se pudo guardar el archivo. Inténtalo de nuevo.');
}
?>
Listar los archivos del repositorio
<?php
// listar_archivos.php
$dsn = 'mysql:host=localhost;dbname=repositorio;charset=utf8mb4';
$pdo = new PDO($dsn, 'repo_user', 'contraseña_fuerte', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// No recuperamos el campo 'contenido' en el listado solo metadatos
$stmt = $pdo->query(
"SELECT id, nombre, titulo, tipo, tamanio, fecha_subida
FROM archivos
ORDER BY fecha_subida DESC"
);
foreach ($stmt->fetchAll() as $fila) {
$tamKB = number_format($fila['tamanio'] / 1024, 1);
$fecha = date('d/m/Y H:i', strtotime($fila['fecha_subida']));
echo htmlspecialchars($fila['titulo']);
echo ' <br> ';
echo htmlspecialchars($fila['nombre']) . " ($fila[tipo] $tamKB KB $fecha)";
echo ' <br> ';
echo '<a href="descargar_archivo.php?id=' . (int)$fila['id'] . '">Descargar</a>';
echo '<br><br>';
}
?>
Nota importante: no incluir el campo contenido en las consultas de listado. Recuperar los binarios en cada petición de listado saturaría la memoria de PHP y la conexión a MySQL innecesariamente.
Descargar un archivo del repositorio
<?php
// descargar_archivo.php?id=N
declare(strict_types=1);
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!$id || $id <= 0) {
http_response_code(400);
exit('ID no válido.');
}
$dsn = 'mysql:host=localhost;dbname=repositorio;charset=utf8mb4';
$pdo = new PDO($dsn, 'repo_user', 'contraseña_fuerte', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
"SELECT nombre, tipo, tamanio, contenido FROM archivos WHERE id = ?"
);
$stmt->execute([$id]);
$fila = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$fila) {
http_response_code(404);
exit('Archivo no encontrado.');
}
// El navegador puede mostrar el archivo (inline) o forzar la descarga (attachment)
// Usamos 'attachment' para que siempre se descargue independientemente del tipo
header('Content-Type: ' . $fila['tipo']);
header('Content-Length: ' . $fila['tamanio']);
header('Content-Disposition: attachment; filename="' . rawurlencode($fila['nombre']) . '"');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
if (ob_get_level()) {
ob_end_clean(); // limpiar cualquier buffer de salida activo
}
echo $fila['contenido'];
exit;
?>
La función rawurlencode() en el nombre de fichero gestiona espacios y caracteres especiales de forma compatible con todos los navegadores. Sin esto, un fichero llamado «Informe Anual 2026.pdf» podría cortarse en «Informe».
Consideraciones de seguridad
Tres puntos que el artículo original de 2003 no cubría porque las vulnerabilidades se hicieron evidentes años después:
- Validar el MIME type real con
finfo. El valor que envía el navegador en$_FILES['archivito']['type']puede falsificarse. Un atacante puede renombrar un script PHP a «foto.jpg» y subirlo.finfolee la firma del fichero (los primeros bytes, la «magic number») y devuelve el tipo real. - Limitar los tipos aceptados. Rechazar explícitamente tipos peligrosos:
text/x-php,application/x-httpd-php,application/x-sh, etc. Usar una lista blanca en lugar de una lista negra. - Limitar el tamaño. Sin límite, un usuario malintencionado puede llenar el disco de la base de datos. Ajustar tanto
MAX_BYTESen el código comoupload_max_filesizeypost_max_sizeenphp.ini.
Cuándo NO usar BLOB en MySQL para archivos
El almacenamiento BLOB en MySQL tiene sus límites. Estos son los casos donde conviene usar el sistema de ficheros o almacenamiento en la nube:
- Archivos de más de 16 MB de forma habitual. Aunque
LONGBLOBsoporta hasta 4 GB, mover esos datos por la red MySQL en cada descarga es ineficiente. - Alta concurrencia de descargas. Si el repositorio sirve cientos de descargas simultáneas, el servidor MySQL se convierte en un cuello de botella. Un servidor web sirviendo ficheros estáticos escala mucho mejor.
- Integración con CDN. Los CDN (Cloudflare R2, Amazon S3, etc.) distribuyen los archivos geográficamente y sirven desde el nodo más cercano al usuario. MySQL no tiene esa capacidad.
Para trabajar con archivos en el sistema de ficheros directamente desde PHP, consulta Trabajando con ficheros en PHP 8. Para almacenar específicamente imágenes en MySQL con un script de visualización, revisa Guardar y extraer imágenes en MySQL con PHP 8. Si necesitas conectar PHP a MySQL en general, la guía completa está en Cómo interactuar con MySQL usando PHP 8 y PDO.
Imagen: Pexels / Pixabay
