Trabajando con ficheros en PHP 8: lectura, escritura, subida y descarga (actualizado 2026)

Artículo original de septiembre de 2002. Actualizado en mayo de 2026 por David Carrero. El original usaba PHP 4 con la etiqueta corta <? y la variable global $PHP_SELF. Esta versión cubre PHP 8 con las funciones modernas de manejo de ficheros, subida segura de archivos y descarga forzada.

Ficheros en PHP 8: más sencillo de lo que parece

PHP tiene una de las APIs más completas para trabajar con el sistema de ficheros: abrir, leer, escribir, copiar, mover, renombrar, obtener metadatos, subir archivos desde el navegador, forzar descargas, recorrer directorios. La mayor parte de las tareas del día a día se resuelven con cuatro o cinco funciones, y en PHP 8 hay atajos que hacen el código mucho más corto que el de 2002.

Este artículo cubre tres bloques: operaciones básicas de lectura y escritura, subida de archivos al servidor y descarga forzada desde PHP. Al final hay una sección con funciones de utilidad que no suelen aparecer en los tutoriales básicos pero que ahorran mucho tiempo.

Lectura y escritura: el camino corto con file_get_contents y file_put_contents

Para la mayoría de los casos — leer un fichero completo o escribir una cadena en un fichero — no hace falta abrir un descriptor y cerrarlo. PHP tiene dos funciones que hacen exactamente eso en una línea:

<?php
// Escribir contenido en un fichero (lo crea si no existe, lo sobreescribe si existe)
file_put_contents('datos.txt', "Primera líneanSegunda línean");

// Añadir al final sin borrar el contenido anterior
file_put_contents('log.txt', date('Y-m-d H:i:s') . " — Acceso registradon", FILE_APPEND | LOCK_EX);

// Leer el fichero completo como string
$contenido = file_get_contents('datos.txt');
echo $contenido;

// Leer el fichero como array de líneas
$lineas = file('datos.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lineas as $i => $linea) {
    echo ($i + 1) . ": $linean";
}
?>

La flag LOCK_EX en file_put_contents() adquiere un bloqueo exclusivo antes de escribir. En aplicaciones con varios procesos concurrentes (como contadores de visitas o logs) evita que dos procesos escriban a la vez y corrompan el fichero.

fopen: cuando necesitas control fino

Para operaciones más complejas — leer línea a línea, combinar lectura y escritura, posicionarte en un punto concreto — se usa fopen(). Los modos de apertura son los mismos que tenía PHP 4, pero ahora hay que cerrar siempre el descriptor con fclose() o usar un bloque try/finally:

Modo

Qué hace

Crea si no existe

Borra contenido

r

Solo lectura, cursor al inicio

No

No

r+

Lectura/escritura, cursor al inicio

No

No

w

Solo escritura, cursor al inicio

w+

Lectura/escritura, cursor al inicio

a

Solo escritura, cursor al final

No

a+

Lectura/escritura, cursor al final

No

x

Solo escritura, falla si ya existe

Sí (solo si no existe)

—

b (sufijo)

Modo binario (imprescindible en Windows)

—

—

<?php
// Leer un CSV línea a línea sin cargarlo completo en memoria (útil para ficheros grandes)
$handle = fopen('pedidos.csv', 'r');
if ($handle === false) {
    throw new RuntimeException('No se pudo abrir el fichero');
}

try {
    // Saltar la cabecera
    fgetcsv($handle);

    while (($fila = fgetcsv($handle, 0, ';')) !== false) {
        [$id, $cliente, $total] = $fila;
        echo "Pedido $id — $cliente — $total €n";
    }
} finally {
    fclose($handle); // Se ejecuta aunque haya una excepción
}
?>

Trabajar con CSV: fgetcsv y fputcsv

Una tarea muy habitual es leer o generar ficheros CSV para importar/exportar datos. PHP tiene funciones nativas que gestionan las comillas, los separadores y los saltos de línea correctamente:

<?php
// Generar un CSV de productos desde una consulta a BBDD
$productos = [
    ['id' => 1, 'nombre' => 'Auriculares', 'precio' => 59.99],
    ['id' => 2, 'nombre' => 'Cable USB-C',  'precio' =>  8.99],
    ['id' => 3, 'nombre' => 'Teclado "Pro"', 'precio' => 89.00], // las comillas se escapan solas
];

$handle = fopen('productos.csv', 'w');
// BOM UTF-8 para que Excel lo abra correctamente con acentos
fwrite($handle, "xEFxBBxBF");
// Cabecera
fputcsv($handle, ['ID', 'Nombre', 'Precio'], ';');
foreach ($productos as $p) {
    fputcsv($handle, [$p['id'], $p['nombre'], $p['precio']], ';');
}
fclose($handle);
echo "CSV generado: " . filesize('productos.csv') . " bytesn";

// Leer el mismo CSV
$handle = fopen('productos.csv', 'r');
fgetcsv($handle, 0, ';'); // saltar BOM + cabecera
while (($fila = fgetcsv($handle, 0, ';')) !== false) {
    echo "$fila[0] — $fila[1] — {$fila[2]} €n";
}
fclose($handle);
?>

Subida de archivos al servidor

La subida de archivos desde el navegador funciona con un formulario multipart/form-data y el array superglobal $_FILES. El código del artículo original de 2002 usaba copy(), que ya no funciona correctamente con archivos subidos. En PHP moderno se usa move_uploaded_file(), que además verifica que el archivo viene realmente de una subida HTTP:

<!-- formulario.html -->
<form method="post" action="subir.php" enctype="multipart/form-data">
  <label>Archivo: <input type="file" name="fichero" accept=".pdf,.docx,.jpg,.png"></label>
  <button type="submit">Subir</button>
</form>
<?php
// subir.php — PHP 8
define('DIRECTORIO_SUBIDAS', '/var/www/uploads/');
define('MAX_BYTES', 5 * 1024 * 1024); // 5 MB

$errores = [];

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    die('Método no permitido');
}

$archivo = $_FILES['fichero'] ?? null;

if (!$archivo || $archivo['error'] !== UPLOAD_ERR_OK) {
    $codigos = [
        UPLOAD_ERR_INI_SIZE   => 'El archivo supera upload_max_filesize en php.ini',
        UPLOAD_ERR_FORM_SIZE  => 'El archivo supera MAX_FILE_SIZE del formulario',
        UPLOAD_ERR_PARTIAL    => 'Se subió solo una parte del archivo',
        UPLOAD_ERR_NO_FILE    => 'No se seleccionó ningún archivo',
        UPLOAD_ERR_NO_TMP_DIR => 'Falta el directorio temporal',
        UPLOAD_ERR_CANT_WRITE => 'No se pudo escribir en disco',
    ];
    $msg = $codigos[$archivo['error'] ?? -1] ?? 'Error desconocido';
    die("Error al subir: $msg");
}

// Validar tamaño
if ($archivo['size'] > MAX_BYTES) {
    die('El archivo es demasiado grande (máximo 5 MB)');
}

// Validar tipo MIME real (no confiar en la extensión del nombre)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeReal = $finfo->file($archivo['tmp_name']);
$tiposPermitidos = ['image/jpeg', 'image/png', 'application/pdf',
                    'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];

if (!in_array($mimeReal, $tiposPermitidos, true)) {
    die("Tipo de archivo no permitido: $mimeReal");
}

// Generar nombre seguro (nunca usar el nombre original directamente)
$extension = match($mimeReal) {
    'image/jpeg'      => '.jpg',
    'image/png'       => '.png',
    'application/pdf' => '.pdf',
    default           => '.bin',
};
$nombreSeguro = uniqid('upload_', true) . $extension;
$rutaDestino  = DIRECTORIO_SUBIDAS . $nombreSeguro;

if (!move_uploaded_file($archivo['tmp_name'], $rutaDestino)) {
    die('No se pudo mover el archivo al destino');
}

// Ajustar permisos: solo lectura para el propietario
chmod($rutaDestino, 0644);

echo "Archivo subido correctamente: $nombreSeguro";
?>

Tres puntos clave que no aparecían en el artículo original de 2002:

  • Nunca uses el nombre original del archivo. Un atacante puede subir un archivo llamado shell.php si no validas y generas un nombre propio.
  • Valida el MIME type real con finfo, no la extensión ni el tipo que envía el navegador. Ambos son fácilmente manipulables.
  • Usa move_uploaded_file(), no copy(). Solo funciona con archivos de la subida actual, lo que cierra ataques de path traversal.

Subida de múltiples archivos

<!-- Múltiples archivos en un campo -->
<input type="file" name="fotos[]" multiple accept="image/*">
<?php
// Reorganizar $_FILES['fotos'] en un array de archivos individuales
function normalizarSubidas(array $files): array
{
    $resultado = [];
    foreach ($files['name'] as $i => $nombre) {
        $resultado[] = [
            'name'     => $nombre,
            'type'     => $files['type'][$i],
            'tmp_name' => $files['tmp_name'][$i],
            'error'    => $files['error'][$i],
            'size'     => $files['size'][$i],
        ];
    }
    return $resultado;
}

foreach (normalizarSubidas($_FILES['fotos']) as $archivo) {
    if ($archivo['error'] === UPLOAD_ERR_OK) {
        // procesar cada archivo...
    }
}
?>

Forzar descarga desde PHP

A veces el archivo está fuera del docroot (por seguridad) o quieres que el navegador descargue en vez de mostrar el contenido. PHP puede servir el fichero con las cabeceras correctas:

<?php
function descargarArchivo(string $rutaAbsoluta, string $nombreDescarga): void
{
    if (!file_exists($rutaAbsoluta) || !is_readable($rutaAbsoluta)) {
        http_response_code(404);
        exit('Archivo no encontrado');
    }

    // Detectar MIME real
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime  = $finfo->file($rutaAbsoluta) ?: 'application/octet-stream';

    header('Content-Type: ' . $mime);
    header('Content-Length: ' . filesize($rutaAbsoluta));
    // El nombre entre comillas dobles maneja espacios y caracteres especiales
    header('Content-Disposition: attachment; filename="' . rawurlencode($nombreDescarga) . '"');
    header('Cache-Control: no-cache, must-revalidate');
    header('Pragma: no-cache');

    // Limpiar cualquier output buffer activo
    if (ob_get_level()) {
        ob_end_clean();
    }

    readfile($rutaAbsoluta);
    exit;
}

// Uso:
// El archivo está en /var/private/docs/ — fuera del docroot
$id = (int) ($_GET['id'] ?? 0);
if ($id === 42) {
    descargarArchivo('/var/private/docs/informe-2026.pdf', 'Informe Anual 2026.pdf');
}
?>

Operaciones útiles sobre ficheros y directorios

<?php
// Información de un fichero
$ruta = '/var/www/uploads/foto.jpg';
echo filesize($ruta);          // tamaño en bytes
echo filetype($ruta);          // file, dir, link...
echo filemtime($ruta);         // timestamp de última modificación
echo date('d/m/Y', filemtime($ruta)); // fecha legible

// Comprobar existencia y tipo
file_exists($ruta);    // ¿existe? (fichero o directorio)
is_file($ruta);        // ¿es un fichero regular?
is_dir('/var/www');    // ¿es un directorio?
is_readable($ruta);    // ¿se puede leer?
is_writable($ruta);    // ¿se puede escribir?

// Manipulación
copy($ruta, '/var/backup/foto.jpg');          // copiar
rename($ruta, '/var/www/uploads/nueva.jpg');  // mover/renombrar
unlink('/var/www/temp/borrar.tmp');            // eliminar

// Trabajar con la ruta
basename('/var/www/uploads/foto.jpg');   // 'foto.jpg'
dirname('/var/www/uploads/foto.jpg');    // '/var/www/uploads'
pathinfo('/var/www/uploads/foto.jpg');   // array con dirname, basename, extension, filename

// Crear directorio (recursivo si es necesario)
if (!is_dir('/var/www/uploads/2026/05')) {
    mkdir('/var/www/uploads/2026/05', 0755, true);
}

// Listar el contenido de un directorio
foreach (new DirectoryIterator('/var/www/uploads') as $entrada) {
    if ($entrada->isFile()) {
        echo $entrada->getFilename() . ' — ' . $entrada->getSize() . " bytesn";
    }
}

// Buscar ficheros por patrón (glob)
$jpgs = glob('/var/www/uploads/*.jpg');
echo count($jpgs) . " archivos JPG encontradosn";

// Buscar recursivamente con RecursiveIterator
$iter = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator('/var/www/uploads')
);
foreach ($iter as $fichero) {
    if ($fichero->isFile() && $fichero->getExtension() === 'pdf') {
        echo $fichero->getPathname() . "n";
    }
}
?>

Ficheros temporales

<?php
// Crear un fichero temporal que se borra automáticamente cuando se cierra
$tmpFile = tmpfile(); // devuelve un handle de recurso
fwrite($tmpFile, "Datos temporales de proceson");
rewind($tmpFile);
echo fread($tmpFile, 1024);
fclose($tmpFile); // el fichero se borra aquí

// Obtener solo la ruta del temporal (para pasarla a otra función)
$rutaTmp = tempnam(sys_get_temp_dir(), 'proc_');
file_put_contents($rutaTmp, "Contenido temporal");
// ... hacer algo con $rutaTmp ...
unlink($rutaTmp); // borrar manualmente
?>

Para gestionar ficheros subidos y almacenarlos en base de datos en formato binario (BLOB), consulta los artículos Guardar y extraer imágenes en MySQL y Manejo de datos BLOB con PHP y MySQL. Para integrar la subida con una base de datos, revisa Cómo interactuar con MySQL usando PHP 8 y PDO.

Imagen: Pexels / Markus Winkler

COMPARTE ESTE ARTÍCULO

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

SIGUIENTE ARTÍCULO