Repositorio de archivos binarios en MySQL con PHP 8 y PDO: guía completa

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

TINYBLOB

255 bytes

Datos muy pequeños, iconos

BLOB

~64 KB

Miniaturas, thumbnails

MEDIUMBLOB

~16 MB

PDFs, documentos Office, imágenes

LONGBLOB

~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. finfo lee 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_BYTES en el código como upload_max_filesize y post_max_size en php.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 LONGBLOB soporta 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

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP
SIGUIENTE ARTÍCULO