Ficheros y subida de archivos en PHP
En esta lección
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ón | Devuelve 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_APPENDañade al final en lugar de sobrescribir. Es lo que quieres para un registro.LOCK_EXbloquea 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()yfile()cargan el archivo entero en memoria. Para un registro de varios cientos de megas, usafopen()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:
| Modo | Para | Si no existe | Si existe |
|---|---|---|---|
'r' | Leer | Error | Empieza al principio |
'w' | Escribir | Lo crea | Lo vacía |
'a' | Añadir al final | Lo crea | Escribe al final |
'x' | Escribir solo si es nuevo | Lo crea | Error |
'r+' / 'w+' / 'a+' | Leer y escribir | Como r / w / a | Como 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()yfgetcsv()avisan de que el valor por defecto del parámetroescapeva a cambiar. Pasarescape: ''(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']:
| Clave | Contenido |
|---|---|
name | Nombre original en el ordenador del usuario (vacaciones.jpg) |
type | Tipo que dice el navegador (no te fíes) |
tmp_name | Ruta del archivo temporal en el servidor |
error | Código de error; UPLOAD_ERR_OK (0) si todo fue bien |
size | Tamañ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
errorprimero. Los más habituales:UPLOAD_ERR_INI_SIZE(supera el límite dephp.ini),UPLOAD_ERR_PARTIAL(se cortó) yUPLOAD_ERR_NO_FILE(no eligió nada). - Limitar el tamaño en tu código, aunque
php.iniya tenga un límite (upload_max_filesizeypost_max_size). - Detectar el tipo real con
finfo, que mira los primeros bytes del archivo. Eltypede$_FILESy 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 llamarsefoto.php. Genera uno aleatorio conrandom_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:
| Permiso | Significado | Uso típico |
|---|---|---|
644 | Dueño lee y escribe; los demás solo leen | Archivos .php, .html, imágenes |
755 | Dueño todo; los demás leen y entran | Carpetas |
600 | Solo el dueño lee y escribe | Archivos con contraseñas, .env |
777 | Todos pueden todo | Nunca 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()sinFILE_APPENDofopen()con'w'vacían el archivo. - Olvidar
enctype="multipart/form-data":$_FILESllega vacío. - Fiarse de
$_FILES['x']['type']o de la extensión: los controla el usuario. Usafinfo. - 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
| Tarea | Código |
|---|---|
| Ruta segura | __DIR__ . '/datos/archivo.txt' |
| Leer todo | file_get_contents($ruta) |
| Leer en un array de líneas | file($ruta, FILE_IGNORE_NEW_LINES) |
| Escribir / añadir | file_put_contents($ruta, $texto) / ..., FILE_APPEND | LOCK_EX |
| Leer línea a línea | fopen($ruta, 'r'), fgets($f), fclose($f) |
| Guardar / leer JSON | json_encode($datos) / json_decode($texto, true) |
| Escribir / leer CSV | fputcsv($f, $fila, ';', escape: '') / fgetcsv($f, null, ';', escape: '') |
| Formulario de subida | method="post" enctype="multipart/form-data" |
| Tipo real de un archivo | (new finfo(FILEINFO_MIME_TYPE))->file($tmp) |
| Nombre aleatorio | bin2hex(random_bytes(16)) |
| Guardar la subida | move_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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.