Saltar al contenido
ficheros.php · devschool

Ficheros y subida de archivos en PHP

Lección 17 de 26 · 12 min de lectura · Actualizado el

En esta lección
  1. Rutas: dónde está el archivo
  2. Leer y escribir de una vez
  3. Leer y escribir poco a poco: fopen
  4. Guardar datos en JSON
  5. Archivos CSV
  6. Subir archivos desde un formulario
  7. Permisos
  8. Errores frecuentes
  9. Resumen

No todo tiene que ir a una base de datos. Un registro de errores, un archivo de configuración, una lista de precios exportada a CSV o las fotos que suben los usuarios son ficheros. En esta lección aprenderás a leer y escribir archivos de texto con PHP, a guardar datos en JSON y CSV, a recibir archivos desde un formulario sin abrir agujeros de seguridad y a entender los permisos que hacen que todo esto funcione (o no) en el servidor.

Rutas: dónde está el archivo

Antes de abrir un archivo, PHP tiene que encontrarlo. Una ruta relativa como 'datos/precios.txt' se busca desde el directorio de trabajo actual, que no siempre es la carpeta de tu script (depende de cómo se ejecute). Para no llevarte sorpresas, construye las rutas a partir de la constante __DIR__, que siempre vale la carpeta del archivo PHP donde está escrita:

<?php
$ruta = __DIR__ . '/datos/precios.txt';
echo $ruta;
// /var/www/rocodromo/datos/precios.txt   (según dónde esté tu script)

Funciones útiles para comprobar antes de actuar:

FunciónDevuelve true si…
file_exists($ruta)Existe (archivo o carpeta)
is_file($ruta) / is_dir($ruta)Es un archivo / una carpeta
is_readable($ruta) / is_writable($ruta)PHP puede leerlo / escribir en él

Y para gestionar archivos y carpetas: mkdir($ruta, 0755, true) crea una carpeta (con true, también las intermedias), unlink($ruta) borra un archivo, rename($origen, $destino) lo mueve o renombra, copy() lo copia y filesize() da su tamaño en bytes.

Leer y escribir de una vez

Para archivos pequeños y medianos, lo más cómodo son dos funciones que hacen todo el trabajo de abrir, leer o escribir y cerrar.

file_put_contents: escribir

<?php
$archivo = __DIR__ . '/notas.txt';

file_put_contents($archivo, "Lunes: bloque 5b\n");                       // crea o SOBRESCRIBE
file_put_contents($archivo, "Martes: vía 6a\n", FILE_APPEND);             // añade al final
file_put_contents($archivo, "Jueves: travesía\n", FILE_APPEND | LOCK_EX); // añade con bloqueo

echo file_get_contents($archivo);
// Lunes: bloque 5b
// Martes: vía 6a
// Jueves: travesía
  • Sin opciones, sobrescribe el contenido entero. Si el archivo no existe, lo crea.
  • FILE_APPEND añade al final en lugar de sobrescribir. Es lo que quieres para un registro.
  • LOCK_EX bloquea el archivo mientras escribe. En una web, dos usuarios pueden enviar un formulario a la vez; sin bloqueo, sus líneas podrían mezclarse. Úsalo siempre que varias peticiones escriban en el mismo archivo.

Devuelve el número de bytes escritos, o false si falla (por ejemplo, por falta de permisos).

file_get_contents y file(): leer

file_get_contents() devuelve todo el archivo como una cadena. Si necesitas trabajar línea a línea, file() devuelve un array con una línea en cada posición:

<?php
$lineas = file(__DIR__ . '/notas.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
print_r($lineas);
// Array
// (
//     [0] => Lunes: bloque 5b
//     [1] => Martes: vía 6a
//     [2] => Jueves: travesía
// )
echo count($lineas);   // 3

Sin FILE_IGNORE_NEW_LINES, cada elemento conservaría el \n del final. Si el archivo no existe, las dos funciones devuelven false y muestran un aviso; compruébalo antes con is_file().

Cuidado: file_get_contents() y file() cargan el archivo entero en memoria. Para un registro de varios cientos de megas, usa fopen() y lee línea a línea.

Leer y escribir poco a poco: fopen

Para archivos grandes o cuando quieres más control, trabajas con un manejador (handle): abres el archivo con fopen(), lees o escribes con otras funciones y lo cierras con fclose().

<?php
$f = fopen(__DIR__ . '/notas.txt', 'r');
if ($f === false) {
    exit('No se pudo abrir el archivo');
}

$numero = 1;
while (($linea = fgets($f)) !== false) {
    echo $numero++ . '. ' . trim($linea) . "\n";
}
fclose($f);
// 1. Lunes: bloque 5b
// 2. Martes: vía 6a
// 3. Jueves: travesía

fgets() lee hasta el final de la línea (incluido el \n) y devuelve false al llegar al final del archivo. Por eso el bucle compara con !== false. Para escribir se usa fwrite($f, $texto).

El segundo argumento de fopen() es el modo:

ModoParaSi no existeSi existe
'r'LeerErrorEmpieza al principio
'w'EscribirLo creaLo vacía
'a'Añadir al finalLo creaEscribe al final
'x'Escribir solo si es nuevoLo creaError
'r+' / 'w+' / 'a+'Leer y escribirComo r / w / aComo r / w / a

Guardar datos en JSON

Si quieres guardar datos con estructura (un array de reservas, la configuración de la web) sin montar una base de datos, el formato JSON es la opción más cómoda: json_encode() convierte un array en texto y json_decode(..., true) lo convierte de vuelta.

<?php
$archivo = __DIR__ . '/reservas.json';

// Leer lo que hay (o empezar con un array vacío)
$reservas = is_file($archivo)
    ? json_decode(file_get_contents($archivo), true)
    : [];

$reservas[] = ['nombre' => 'Nerea', 'actividad' => 'Yoga', 'dia' => '2026-10-10'];

file_put_contents(
    $archivo,
    json_encode($reservas, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
    LOCK_EX
);

echo count($reservas) . " reserva(s)\n";   // 1 reserva(s)  (2 la segunda vez...)
echo $reservas[0]['actividad'];            // Yoga

Las opciones de json_encode y el manejo de errores las verás a fondo en JSON y APIs. Ten en cuenta que este sistema lee y reescribe el archivo entero en cada cambio: vale para decenas o cientos de registros, no para miles con muchos usuarios a la vez. Para eso está la base de datos.

Archivos CSV

El CSV (valores separados por comas) es el formato que entienden Excel, LibreOffice y casi cualquier programa de datos. PHP trae fputcsv() para escribir una fila y fgetcsv() para leerla, y se encargan de las comillas cuando un campo contiene el separador.

<?php
$archivo = __DIR__ . '/tarifas.csv';

$tarifas = [
    ['Tarifa', 'Precio', 'Incluye'],
    ['Entrada suelta', '9.50', 'Boulder'],
    ['Bono 10', '80.00', 'Boulder; vías'],
    ['Mensual', '49.00', 'Todo, "sin límite"'],
];

$f = fopen($archivo, 'w');
foreach ($tarifas as $fila) {
    fputcsv($f, $fila, ';', escape: '');
}
fclose($f);

echo file_get_contents($archivo);
// Tarifa;Precio;Incluye
// "Entrada suelta";9.50;Boulder
// "Bono 10";80.00;"Boulder; vías"
// Mensual;49.00;"Todo, ""sin límite"""

Hemos usado ; como separador porque es lo que espera Excel en España (la coma se usa para los decimales). fputcsv() pone comillas a los campos que tienen espacios, el separador o comillas, y duplica las comillas internas.

Para leerlo, el bucle es como el de fgets():

<?php
$f = fopen(__DIR__ . '/tarifas.csv', 'r');
$cabecera = fgetcsv($f, null, ';', escape: '');

while (($fila = fgetcsv($f, null, ';', escape: '')) !== false) {
    $tarifa = array_combine($cabecera, $fila);
    echo "{$tarifa['Tarifa']}: {$tarifa['Precio']} €\n";
}
fclose($f);
// Entrada suelta: 9.50 €
// Bono 10: 80.00 €
// Mensual: 49.00 €

array_combine() une la cabecera con cada fila para tener un array asociativo, más fácil de leer que $fila[1].

Nota: desde PHP 8.4, fputcsv() y fgetcsv() avisan de que el valor por defecto del parámetro escape va a cambiar. Pasar escape: '' (un argumento con nombre) evita el aviso y es el comportamiento correcto según el estándar CSV.

Subir archivos desde un formulario

Para que un usuario suba un archivo (la foto de su perfil, un justificante en PDF), el formulario necesita dos cosas: method="post" y el atributo enctype="multipart/form-data". Sin él, solo llegaría el nombre del archivo, no su contenido.

<form method="post" enctype="multipart/form-data">
  <label for="foto">Foto de perfil (JPG, PNG o WebP, máx. 2 MB)</label>
  <input type="file" id="foto" name="foto" accept="image/jpeg,image/png,image/webp">
  <button>Subir</button>
</form>

PHP guarda el archivo recibido en una carpeta temporal y deja su información en $_FILES['foto']:

ClaveContenido
nameNombre original en el ordenador del usuario (vacaciones.jpg)
typeTipo que dice el navegador (no te fíes)
tmp_nameRuta del archivo temporal en el servidor
errorCódigo de error; UPLOAD_ERR_OK (0) si todo fue bien
sizeTamaño en bytes

Si no lo mueves a otro sitio con move_uploaded_file(), el temporal se borra al terminar el script.

Subida segura paso a paso

Un formulario de subida es una de las partes más delicadas de una web: si alguien consigue subir un archivo .php a una carpeta pública y luego lo visita, ejecutará su código en tu servidor. Por eso cada comprobación de este ejemplo importa:

<?php
const TAMANO_MAXIMO = 2 * 1024 * 1024;   // 2 MB
const TIPOS_PERMITIDOS = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
];

$error = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $foto = $_FILES['foto'] ?? null;

    if ($foto === null || $foto['error'] === UPLOAD_ERR_NO_FILE) {
        $error = 'Elige un archivo.';
    } elseif ($foto['error'] !== UPLOAD_ERR_OK) {
        $error = 'No se pudo subir el archivo (código ' . $foto['error'] . ').';
    } elseif ($foto['size'] > TAMANO_MAXIMO) {
        $error = 'El archivo pasa de 2 MB.';
    } else {
        // 1. Tipo real según el contenido, no según el nombre ni el navegador
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $tipo = $finfo->file($foto['tmp_name']);

        if (!isset(TIPOS_PERMITIDOS[$tipo])) {
            $error = 'Solo se admiten imágenes JPG, PNG o WebP.';
        } else {
            // 2. Nombre nuevo, aleatorio, con la extensión que decidimos nosotros
            $nombre = bin2hex(random_bytes(16)) . '.' . TIPOS_PERMITIDOS[$tipo];
            $destino = __DIR__ . '/subidas/' . $nombre;

            // 3. Mover desde la carpeta temporal
            if (move_uploaded_file($foto['tmp_name'], $destino)) {
                header('Location: perfil.php?foto=ok', true, 303);
                exit;
            }
            $error = 'No se pudo guardar el archivo.';
        }
    }
}

Las claves de esta subida segura:

  • Comprobar error primero. Los más habituales: UPLOAD_ERR_INI_SIZE (supera el límite de php.ini), UPLOAD_ERR_PARTIAL (se cortó) y UPLOAD_ERR_NO_FILE (no eligió nada).
  • Limitar el tamaño en tu código, aunque php.ini ya tenga un límite (upload_max_filesize y post_max_size).
  • Detectar el tipo real con finfo, que mira los primeros bytes del archivo. El type de $_FILES y la extensión del nombre los decide el usuario.
  • No usar nunca el nombre original. Puede contener ../ para escribir fuera de la carpeta, caracteres raros o llamarse foto.php. Genera uno aleatorio con random_bytes() y pon tú la extensión según el tipo detectado. Si quieres conservar el nombre original para mostrarlo, guárdalo aparte (en la base de datos) y escápalo al imprimirlo.
  • Usar move_uploaded_file(), que además comprueba que el archivo llegó de verdad por una subida HTTP.

Consejo: si puedes, guarda las subidas fuera de la carpeta pública de la web y sírvelas con un script PHP que compruebe permisos. Si tienen que estar en una carpeta pública, configura el servidor para que no ejecute PHP en ella. Más en Seguridad.

Permisos

En Linux (casi todos los servidores web), cada archivo tiene un dueño y unos permisos de lectura (r), escritura (w) y ejecución (x) para tres grupos: el dueño, su grupo y el resto. Se escriben como tres cifras en octal:

PermisoSignificadoUso típico
644Dueño lee y escribe; los demás solo leenArchivos .php, .html, imágenes
755Dueño todo; los demás leen y entranCarpetas
600Solo el dueño lee y escribeArchivos con contraseñas, .env
777Todos pueden todoNunca en un servidor

PHP se ejecuta con el usuario del servidor web (en Debian y Ubuntu, www-data). Si file_put_contents() devuelve false con el aviso Permission denied, ese usuario no puede escribir en la carpeta. La solución es darle permiso solo en las carpetas donde debe escribir (por ejemplo, subidas/ o datos/), no poner 777 a todo. En XAMPP para Windows este problema casi no aparece; en un hosting Linux, sí.

Desde PHP puedes cambiar permisos con chmod($ruta, 0644). Fíjate en el 0 inicial: indica que el número está en octal.

Errores frecuentes

  • Rutas relativas que fallan según desde dónde se ejecute el script. Usa __DIR__ . '/archivo'.
  • Sobrescribir sin querer: file_put_contents() sin FILE_APPEND o fopen() con 'w' vacían el archivo.
  • Olvidar enctype="multipart/form-data": $_FILES llega vacío.
  • Fiarse de $_FILES['x']['type'] o de la extensión: los controla el usuario. Usa finfo.
  • Guardar con el nombre original: riesgo de sobrescribir archivos, de rutas con ../ y de subir .php. Genera nombres aleatorios.
  • No cerrar con fclose(): en scripts largos acabas con muchos archivos abiertos y datos sin escribir.
  • Arreglar permisos con 777: cualquier proceso del servidor podría modificar tus archivos.

Resumen

TareaCódigo
Ruta segura__DIR__ . '/datos/archivo.txt'
Leer todofile_get_contents($ruta)
Leer en un array de líneasfile($ruta, FILE_IGNORE_NEW_LINES)
Escribir / añadirfile_put_contents($ruta, $texto) / ..., FILE_APPEND | LOCK_EX
Leer línea a líneafopen($ruta, 'r'), fgets($f), fclose($f)
Guardar / leer JSONjson_encode($datos) / json_decode($texto, true)
Escribir / leer CSVfputcsv($f, $fila, ';', escape: '') / fgetcsv($f, null, ';', escape: '')
Formulario de subidamethod="post" enctype="multipart/form-data"
Tipo real de un archivo(new finfo(FILEINFO_MIME_TYPE))->file($tmp)
Nombre aleatoriobin2hex(random_bytes(16))
Guardar la subidamove_uploaded_file($tmp, $destino)

Hasta aquí has programado con funciones y arrays. En la siguiente lección das el salto a la programación orientada a objetos: Clases y objetos.

Pon a prueba lo que has aprendido

[PHP] Si el archivo notas.txt no existe, ¿qué muestra este código?
file_put_contents("notas.txt", "a\n");
file_put_contents("notas.txt", "b\n");
echo file_get_contents("notas.txt");

[PHP] Subes una foto y $_FILES llega vacío aunque el formulario tiene <input type="file">. ¿Qué falta casi seguro?

[PHP] ¿Cómo se comprueba con seguridad que un archivo subido es una imagen?

[PHP] ¿Por qué se guarda un archivo subido con un nombre aleatorio (por ejemplo, bin2hex(random_bytes(16)) más la extensión elegida por ti)?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.