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 |
| Solo lectura, cursor al inicio | No | No |
| Lectura/escritura, cursor al inicio | No | No |
| Solo escritura, cursor al inicio | Sí | Sí |
| Lectura/escritura, cursor al inicio | Sí | Sí |
| Solo escritura, cursor al final | Sí | No |
| Lectura/escritura, cursor al final | Sí | No |
| Solo escritura, falla si ya existe | Sí (solo si no existe) | |
| 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.phpsi 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(), nocopy(). 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
