Módulo 10: Ficheros

Objetivos del módulo

  • Construir rutas portables con __DIR__, DIRECTORY_SEPARATOR y realpath()
  • Leer y escribir ficheros completos con file_get_contents(), file_put_contents() y file()
  • Trabajar línea a línea con fopen(), fgets(), fwrite() y fclose() y conocer los modos de apertura
  • Usar la clase SplFileObject como alternativa orientada a objetos
  • Comprobar la existencia, el tipo, el tamaño y la fecha de ficheros
  • Crear, recorrer, copiar, renombrar y borrar ficheros y directorios
  • Leer y generar ficheros CSV, incluida una descarga para Excel
  • Codificar y decodificar JSON con los flags adecuados y validarlo con json_validate()
  • Construir una mini base de datos en JSON con un CRUD de tareas
  • Escribir logs sencillos y protegerte del path traversal

1. ¿Por qué trabajar con ficheros?

Antes de tener una base de datos (módulo 15), los ficheros son la forma más sencilla de guardar información de forma persistente: lo que escribes sigue ahí cuando el script termina. Pero incluso en aplicaciones profesionales con base de datos seguirás usando ficheros para:

Uso Ejemplo
Configuración config.php, .env, config.json
Registros (logs) var/log/app.log
Importar/exportar datos CSV para Excel, JSON para otras aplicaciones
Caché Resultados costosos guardados temporalmente
Subidas de usuarios Imágenes, PDF (módulo 08)
Plantillas Vistas HTML, correos

📘 Concepto: Un fichero es una secuencia de bytes con un nombre dentro de una carpeta. PHP no distingue entre “texto” y “binario”: lees y escribes bytes. Lo que esos bytes significan (texto UTF-8, CSV, JSON, una imagen) lo decides tú al interpretarlos.


2. Rutas

Rutas relativas frente a absolutas

<?php
// ❌ Relativa: depende del directorio de trabajo actual, que puede ser cualquiera
$datos = file_get_contents('datos/usuarios.json');

// ✅ Absoluta a partir de la carpeta de ESTE fichero
$datos = file_get_contents(__DIR__ . '/datos/usuarios.json');

⚠️ Importante: Una ruta relativa se resuelve desde el directorio de trabajo del proceso (getcwd()), que no tiene por qué ser la carpeta del script: depende de desde dónde lo ejecutes (php scripts/tarea.php frente a cd scripts && php tarea.php) o de cómo lo lance el servidor web. Usa siempre __DIR__ como punto de partida.

Constantes y funciones útiles

Elemento Devuelve Ejemplo
__DIR__ Carpeta del fichero actual /var/www/app/src
__FILE__ Ruta completa del fichero actual /var/www/app/src/index.php
DIRECTORY_SEPARATOR Separador del sistema / en Linux/macOS, \ en Windows
dirname($ruta, $niveles) Carpeta padre dirname(__DIR__) → /var/www/app
basename($ruta) Nombre final basename('/a/b/foto.jpg') → foto.jpg
pathinfo($ruta) Array con dirname, basename, extension, filename  
realpath($ruta) Ruta absoluta canónica (resuelve .., . y enlaces) o false si no existe  
getcwd() Directorio de trabajo actual  
sys_get_temp_dir() Carpeta temporal del sistema /tmp
<?php
declare(strict_types=1);

$ruta = __DIR__ . '/../datos/informe.final.pdf';

print_r(pathinfo($ruta));
// [dirname] => /var/www/app/src/../datos
// [basename] => informe.final.pdf
// [extension] => pdf
// [filename] => informe.final

echo realpath(__DIR__ . '/..') . PHP_EOL;     // /var/www/app (sin el "..")
var_dump(realpath(__DIR__ . '/no-existe'));   // bool(false)

$raiz = dirname(__DIR__);                      // Un nivel arriba
$config = $raiz . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'app.php';

💡 Consejo: PHP en Windows acepta la barra / en las rutas, así que __DIR__ . '/datos/x.json' funciona en todos los sistemas. DIRECTORY_SEPARATOR es útil sobre todo cuando comparas o descompones rutas que te devuelve el sistema (por ejemplo, con explode()).


3. Leer y escribir ficheros completos

file_get_contents(): leer todo

<?php
declare(strict_types=1);

$ruta = __DIR__ . '/notas.txt';

$contenido = file_get_contents($ruta);
if ($contenido === false) {
    exit('No se pudo leer el fichero');
}

echo $contenido;
echo 'Tamaño: ' . strlen($contenido) . ' bytes' . PHP_EOL;

file_get_contents() devuelve false (y emite un warning) si el fichero no existe o no se puede leer. También funciona con URLs si allow_url_fopen está activado (file_get_contents('https://...')), aunque para APIs es mejor cURL o Guzzle (módulos 13 y 17).

file_put_contents(): escribir todo

<?php
declare(strict_types=1);

$ruta = __DIR__ . '/notas.txt';

// Crea el fichero o lo SOBRESCRIBE por completo
$bytes = file_put_contents($ruta, "Primera línea\nSegunda línea\n");
echo "Escritos $bytes bytes" . PHP_EOL;

// Añadir al final, con bloqueo exclusivo mientras se escribe
file_put_contents($ruta, "Tercera línea\n", FILE_APPEND | LOCK_EX);

// También acepta un array: une sus elementos sin separador
file_put_contents($ruta, ["a\n", "b\n", "c\n"], FILE_APPEND | LOCK_EX);
Flag Efecto
(ninguno) Sobrescribe el fichero
FILE_APPEND Añade al final en lugar de sobrescribir
LOCK_EX Bloqueo exclusivo durante la escritura (evita que dos peticiones mezclen datos)

⚠️ Importante: En una web, varias peticiones pueden escribir a la vez en el mismo fichero. Sin LOCK_EX, las líneas pueden mezclarse o perderse. Úsalo siempre que escribas en ficheros compartidos (logs, contadores, datos).

file(): leer como array de líneas

<?php
declare(strict_types=1);

// Cada elemento es una línea, sin el salto de línea y sin líneas vacías
$lineas = file(__DIR__ . '/notas.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

foreach ($lineas as $numero => $linea) {
    echo ($numero + 1) . ": $linea" . PHP_EOL;
}
// 1: Primera línea
// 2: Segunda línea
// ...
Función Devuelve Ideal para
file_get_contents() string Ficheros pequeños/medianos que procesas enteros (JSON, plantillas)
file() array de líneas Ficheros de texto pequeños procesados por líneas
fopen() + fgets() Línea a línea Ficheros grandes (no los cargas enteros en memoria)

4. Manejo con fopen(), fgets(), fwrite() y fclose()

Las funciones anteriores cargan el fichero entero en memoria. Con un log de 2 GB eso es inviable. Para trabajar por partes se abre el fichero y se obtiene un recurso (un “manejador”), como abrir un libro y leerlo página a página.

Leer línea a línea

<?php
declare(strict_types=1);

$ruta = __DIR__ . '/acceso.log';

$f = fopen($ruta, 'r');
if ($f === false) {
    exit('No se pudo abrir el fichero');
}

$errores = 0;
while (($linea = fgets($f)) !== false) {
    if (str_contains($linea, ' 500 ')) {
        $errores++;
    }
}

fclose($f);
echo "Peticiones con error 500: $errores" . PHP_EOL;

Así puedes procesar ficheros de cualquier tamaño consumiendo muy poca memoria.

Escribir

<?php
declare(strict_types=1);

$f = fopen(__DIR__ . '/informe.txt', 'w');

fwrite($f, "INFORME DE VENTAS\n");
fwrite($f, str_repeat('=', 20) . "\n");
foreach (['enero' => 1200, 'febrero' => 980, 'marzo' => 1430] as $mes => $total) {
    fwrite($f, sprintf("%-10s %8.2f €\n", ucfirst($mes), $total));
}

fclose($f);
INFORME DE VENTAS
====================
Enero       1200.00 €
Febrero      980.00 €
Marzo       1430.00 €

Modos de apertura

Modo Lectura Escritura Si no existe Si existe Puntero
r ✅ ❌ Error Lo abre Al inicio
r+ ✅ ✅ Error Lo abre Al inicio
w ❌ ✅ Lo crea Lo vacía Al inicio
w+ ✅ ✅ Lo crea Lo vacía Al inicio
a ❌ ✅ Lo crea Lo abre Siempre al final
a+ ✅ ✅ Lo crea Lo abre Lectura: inicio; escritura: final
x ❌ ✅ Lo crea Error Al inicio
x+ ✅ ✅ Lo crea Error Al inicio
c ❌ ✅ Lo crea Lo abre sin vaciar Al inicio
c+ ✅ ✅ Lo crea Lo abre sin vaciar Al inicio

💡 Consejo: x es perfecto para crear un fichero solo si no existe sin condiciones de carrera. c sirve para abrir, bloquear con flock() y después decidir si vaciarlo con ftruncate(). Puedes añadir b (rb, wb) para modo binario: en Linux no cambia nada, en Windows evita conversiones de saltos de línea. Es buena costumbre usarlo.

Otras funciones del manejador

Función Para qué
fgets($f) Lee una línea (incluye \n)
fgetc($f) Lee un carácter (byte)
fread($f, $bytes) Lee N bytes
fwrite($f, $texto) Escribe; devuelve los bytes escritos o false
feof($f) ¿Se ha llegado al final?
ftell($f) / fseek($f, $pos) / rewind($f) Posición del puntero
ftruncate($f, $tam) Recorta el fichero
flock($f, LOCK_SH \| LOCK_EX \| LOCK_UN) Bloqueo compartido, exclusivo o liberar
fflush($f) Fuerza la escritura en disco

Bloqueo con flock(): un contador seguro

<?php
declare(strict_types=1);

function incrementarContador(string $ruta): int
{
    $f = fopen($ruta, 'c+');          // Crea si no existe, sin vaciar
    if (!flock($f, LOCK_EX)) {        // Espera a tener el bloqueo exclusivo
        throw new RuntimeException('No se pudo bloquear el contador');
    }

    $valor = (int) stream_get_contents($f) + 1;

    ftruncate($f, 0);                 // Vaciar
    rewind($f);                       // Volver al inicio
    fwrite($f, (string) $valor);
    fflush($f);

    flock($f, LOCK_UN);               // Liberar
    fclose($f);

    return $valor;
}

echo 'Visita número ' . incrementarContador(__DIR__ . '/contador.txt');

⚠️ Importante: Sin bloqueo, dos visitas simultáneas podrían leer ambas 41 y escribir ambas 42, perdiendo una visita. Con flock(LOCK_EX) la segunda petición espera a que la primera termine.

Flujos especiales

<?php
$salida  = fopen('php://output', 'w');  // Escribe en la respuesta (como echo)
$entrada = fopen('php://input', 'r');   // Cuerpo crudo de la petición (APIs, módulo 17)
$memoria = fopen('php://memory', 'w+'); // Fichero en memoria
$temp    = fopen('php://temp', 'w+');   // En memoria y, si crece, en disco
fwrite(STDERR, "Mensaje de error\n");   // En CLI: STDIN, STDOUT y STDERR ya están abiertos

5. SplFileObject: ficheros orientados a objetos

La SPL (Standard PHP Library) incluye SplFileObject, que envuelve todas estas funciones en un objeto iterable con foreach. Lo entenderás mejor tras los módulos de POO, pero ya puedes usarlo:

<?php
declare(strict_types=1);

$fichero = new SplFileObject(__DIR__ . '/notas.txt', 'r');

// Flags: quitar saltos de línea y saltar líneas vacías
$fichero->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD);

foreach ($fichero as $numero => $linea) {
    echo ($numero + 1) . ": $linea" . PHP_EOL;
}

// Escribir
$log = new SplFileObject(__DIR__ . '/app.log', 'a');
$log->fwrite(date('c') . " Aplicación iniciada\n");

// Información del fichero (hereda de SplFileInfo)
echo $fichero->getFilename() . ' - ' . $fichero->getSize() . ' bytes' . PHP_EOL;
echo $fichero->getExtension() . PHP_EOL; // txt

No necesitas fclose(): el fichero se cierra cuando el objeto deja de usarse (por ejemplo, con $fichero = null;).

💡 Consejo: SplFileObject brilla con ficheros grandes y CSV: combina el bajo consumo de memoria de fgets() con la comodidad de foreach.


6. Comprobaciones sobre ficheros

<?php
declare(strict_types=1);

$ruta = __DIR__ . '/notas.txt';

var_dump(file_exists($ruta));   // ¿Existe (fichero o directorio)?
var_dump(is_file($ruta));       // ¿Es un fichero normal?
var_dump(is_dir(__DIR__));      // ¿Es un directorio?
var_dump(is_readable($ruta));   // ¿Puedo leerlo?
var_dump(is_writable($ruta));   // ¿Puedo escribirlo?

echo filesize($ruta) . ' bytes' . PHP_EOL;
echo 'Modificado: ' . date('d/m/Y H:i:s', filemtime($ruta)) . PHP_EOL;

/** Tamaño legible por humanos */
function tamanoLegible(int $bytes): string
{
    $unidades = ['B', 'KB', 'MB', 'GB', 'TB'];
    $i = 0;
    while ($bytes >= 1024 && $i < count($unidades) - 1) {
        $bytes /= 1024;
        $i++;
    }
    return round($bytes, 1) . ' ' . $unidades[$i];
}

echo tamanoLegible(filesize($ruta)) . PHP_EOL;  // 1.2 KB
echo tamanoLegible(5_368_709_120) . PHP_EOL;    // 5 GB
Función Devuelve
file_exists() true si existe un fichero o directorio
is_file() / is_dir() / is_link() Tipo concreto
is_readable() / is_writable() / is_executable() Permisos efectivos para el usuario de PHP
filesize() Tamaño en bytes
filemtime() / fileatime() / filectime() Fechas de modificación, acceso y cambio (timestamp)
fileperms() Permisos (número; ver sección 12)
disk_free_space() Espacio libre en el disco

⚠️ Importante: PHP cachea los resultados de filesize(), filemtime(), is_file()… dentro de una misma ejecución. Si el fichero cambia durante el script (por ejemplo, en un bucle que lo va escribiendo), llama a clearstatcache() antes de volver a consultarlos.


7. Directorios

Crear y borrar

<?php
declare(strict_types=1);

$base = __DIR__ . '/almacen';

// Crear varios niveles de una vez (recursive = true)
if (!is_dir("$base/2026/10") && !mkdir("$base/2026/10", 0775, true)) {
    throw new RuntimeException('No se pudo crear la carpeta');
}

file_put_contents("$base/2026/10/a.txt", 'A');
copy("$base/2026/10/a.txt", "$base/2026/10/b.txt");     // Copiar
rename("$base/2026/10/b.txt", "$base/2026/10/c.txt");   // Renombrar o MOVER
unlink("$base/2026/10/a.txt");                          // Borrar fichero

// rmdir solo borra directorios VACÍOS
unlink("$base/2026/10/c.txt");
rmdir("$base/2026/10");

💡 Consejo: La condición !is_dir(...) && !mkdir(...) es el patrón habitual: solo intenta crear si no existe, y lanza el error si la creación falla. rename() sirve también para mover un fichero a otra carpeta.

Listar con scandir()

<?php
declare(strict_types=1);

$elementos = scandir(__DIR__);  // Incluye "." y ".."
$elementos = array_diff($elementos, ['.', '..']);

foreach ($elementos as $nombre) {
    $ruta = __DIR__ . '/' . $nombre;
    $tipo = is_dir($ruta) ? '[DIR]' : tamanoLegible(filesize($ruta));
    printf("%-30s %10s\n", $nombre, $tipo);
}

Buscar con glob()

glob() busca rutas con comodines, igual que en la terminal:

<?php
declare(strict_types=1);

$imagenes = glob(__DIR__ . '/uploads/*.{jpg,png,webp}', GLOB_BRACE);
$logs     = glob(__DIR__ . '/logs/app-2026-*.log');
$carpetas = glob(__DIR__ . '/*', GLOB_ONLYDIR);

foreach ($imagenes as $img) {
    echo basename($img) . PHP_EOL;
}
Comodín Significado
* Cualquier secuencia de caracteres
? Un carácter
[abc] Uno de los caracteres
{a,b} Alternativas (requiere GLOB_BRACE; no disponible en todos los sistemas)

Recorrido recursivo

Para recorrer un árbol de carpetas completo, la SPL ofrece iteradores:

<?php
declare(strict_types=1);

/** Calcula el tamaño total de una carpeta y sus subcarpetas */
function tamanoCarpeta(string $carpeta): int
{
    $total = 0;
    $iterador = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($carpeta, FilesystemIterator::SKIP_DOTS)
    );
    foreach ($iterador as $fichero) {   // Cada $fichero es un SplFileInfo
        if ($fichero->isFile()) {
            $total += $fichero->getSize();
        }
    }
    return $total;
}

/** Borra una carpeta con todo su contenido (¡cuidado!) */
function borrarCarpeta(string $carpeta): void
{
    $iterador = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($carpeta, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST   // Primero el contenido, luego la carpeta
    );
    foreach ($iterador as $elemento) {
        $elemento->isDir() ? rmdir($elemento->getPathname()) : unlink($elemento->getPathname());
    }
    rmdir($carpeta);
}

echo tamanoLegible(tamanoCarpeta(__DIR__)) . PHP_EOL;

8. Ficheros CSV

📘 Concepto: CSV (Comma-Separated Values) es un formato de texto para datos en tabla: cada línea es una fila y los valores se separan con un delimitador (coma o, en la configuración española de Excel, punto y coma). Los valores que contienen el delimitador o saltos de línea van entre comillas dobles.

id;producto;precio;stock
1;Teclado mecánico;59,90;12
2;"Ratón ""gaming""";24,50;30
3;"Monitor 27; 4K";189,00;5

Leer CSV con fgetcsv()

<?php
declare(strict_types=1);

$f = fopen(__DIR__ . '/productos.csv', 'r');

$cabecera = fgetcsv($f, separator: ';', escape: '');   // Primera fila: nombres de columna
$productos = [];

while (($fila = fgetcsv($f, separator: ';', escape: '')) !== false) {
    if ($fila === [null]) {
        continue;                           // Línea vacía
    }
    $productos[] = array_combine($cabecera, $fila);
}
fclose($f);

foreach ($productos as $p) {
    $precio = (float) str_replace(',', '.', $p['precio']);
    printf("%-20s %8.2f €  (stock: %d)\n", $p['producto'], $precio, $p['stock']);
}

⚠️ Importante: Desde PHP 8.4, fgetcsv(), fputcsv(), str_getcsv() y sus equivalentes de SplFileObject emiten un aviso de obsolescencia si no indicas el parámetro escape. El mecanismo de escape propio de PHP (la barra \) no es estándar y puede corromper datos, así que pasa siempre escape: '', como en los ejemplos.

Escribir CSV con fputcsv()

<?php
declare(strict_types=1);

$ventas = [
    ['fecha' => '2026-10-01', 'cliente' => 'Ana López', 'total' => 125.5],
    ['fecha' => '2026-10-02', 'cliente' => 'Bar "El Puerto"', 'total' => 89.9],
    ['fecha' => '2026-10-02', 'cliente' => 'Luis; Pérez', 'total' => 42.0],
];

$f = fopen(__DIR__ . '/ventas.csv', 'w');
fputcsv($f, ['Fecha', 'Cliente', 'Total'], ';', '"', '');
foreach ($ventas as $v) {
    fputcsv($f, [$v['fecha'], $v['cliente'], number_format($v['total'], 2, ',', '')], ';', '"', '');
}
fclose($f);

fputcsv() se encarga de añadir comillas y duplicar las comillas internas cuando hace falta:

Fecha;Cliente;Total
2026-10-01;"Ana López";125,50
2026-10-02;"Bar ""El Puerto""";89,90
2026-10-02;"Luis; Pérez";42,00

Analizar una cadena con str_getcsv()

<?php
$linea = 'Madrid,"Calle Mayor, 1",28013';
print_r(str_getcsv($linea, ',', '"', ''));
// [0 => Madrid, 1 => Calle Mayor, 1, 2 => 28013]

CSV con SplFileObject

<?php
declare(strict_types=1);

$csv = new SplFileObject(__DIR__ . '/productos.csv');
$csv->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE);
$csv->setCsvControl(';', '"', '');

foreach ($csv as $i => $fila) {
    if ($i === 0) {
        continue; // Cabecera
    }
    echo $fila[1] . PHP_EOL;
}

Exportar a un CSV descargable

Para que el navegador descargue el CSV en lugar de mostrarlo, se envían cabeceras y se escribe directamente en la salida con php://output:

<?php
// exportar-clientes.php
declare(strict_types=1);

$clientes = [
    ['id' => 1, 'nombre' => 'Ana López', 'email' => 'ana@correo.es', 'alta' => '2026-01-15'],
    ['id' => 2, 'nombre' => 'José Núñez', 'email' => 'jose@correo.es', 'alta' => '2026-03-02'],
    ['id' => 3, 'nombre' => 'María Ibáñez', 'email' => 'maria@correo.es', 'alta' => '2026-09-28'],
];

$nombreFichero = 'clientes-' . date('Y-m-d') . '.csv';

header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $nombreFichero . '"');
header('Cache-Control: no-store');

$salida = fopen('php://output', 'w');

// BOM UTF-8: hace que Excel muestre bien las tildes y la ñ
fwrite($salida, "\xEF\xBB\xBF");

fputcsv($salida, ['ID', 'Nombre', 'Email', 'Fecha de alta'], ';', '"', '');
foreach ($clientes as $c) {
    $fecha = DateTimeImmutable::createFromFormat('Y-m-d', $c['alta'])->format('d/m/Y');
    fputcsv($salida, [$c['id'], $c['nombre'], $c['email'], $fecha], ';', '"', '');
}

fclose($salida);
exit;

⚠️ Importante: Si un valor de usuario empieza por =, +, - o @, Excel puede interpretarlo como una fórmula (CSV injection). Si exportas datos introducidos por usuarios que se abrirán en Excel, antepón un apóstrofo a esos valores: preg_match('/^[=+\-@]/', $v) ? "'$v" : $v.


9. JSON

📘 Concepto: JSON (JavaScript Object Notation) es el formato estándar de intercambio de datos en la web: APIs, configuración, almacenamiento sencillo. Sus tipos (objeto, array, cadena, número, booleano, null) se corresponden casi uno a uno con los de PHP.

PHP JSON
Array asociativo ['a' => 1] Objeto {"a": 1}
Array indexado (0, 1, 2…) [1, 2] Array [1, 2]
string Cadena "..."
int, float Número
true, false true, false
null null
Objeto Objeto (propiedades públicas o JsonSerializable, módulo 12)

json_encode(): de PHP a JSON

<?php
declare(strict_types=1);

$producto = [
    'id'        => 7,
    'nombre'    => 'Café de Colombia',
    'precio'    => 8.5,
    'etiquetas' => ['ecológico', 'molido'],
    'url'       => 'https://tienda.example/cafe',
    'oferta'    => false,
];

echo json_encode($producto);
// {"id":7,"nombre":"Café de Colombia","precio":8.5,"etiquetas":["ecológico","molido"],"url":"https:\/\/tienda.example\/cafe","oferta":false}

echo json_encode($producto, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
{
    "id": 7,
    "nombre": "Café de Colombia",
    "precio": 8.5,
    "etiquetas": [
        "ecológico",
        "molido"
    ],
    "url": "https://tienda.example/cafe",
    "oferta": false
}
Flag Efecto
JSON_PRETTY_PRINT Sangría y saltos de línea (legible para humanos)
JSON_UNESCAPED_UNICODE Escribe é en lugar de é
JSON_UNESCAPED_SLASHES Escribe / en lugar de \/
JSON_THROW_ON_ERROR Lanza JsonException en vez de devolver false
JSON_PRESERVE_ZERO_FRACTION Escribe 10.0 en lugar de 10 para floats
JSON_FORCE_OBJECT Convierte arrays indexados en objetos
JSON_NUMERIC_CHECK Convierte cadenas numéricas en números (con cuidado: "007" → 7)

json_decode(): de JSON a PHP

<?php
declare(strict_types=1);

$json = '{"usuario": {"nombre": "Ana", "edad": 31}, "roles": ["admin", "editor"]}';

// Sin el segundo parámetro: objetos stdClass
$obj = json_decode($json);
echo $obj->usuario->nombre . PHP_EOL;    // Ana

// Con true: arrays asociativos (lo más habitual)
$datos = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
echo $datos['usuario']['edad'] . PHP_EOL; // 31
echo implode(', ', $datos['roles']) . PHP_EOL; // admin, editor

Errores al decodificar

<?php
declare(strict_types=1);

$roto = '{"nombre": "Ana",}'; // Coma final: JSON no válido

// Forma antigua: comprobar json_last_error()
$r = json_decode($roto, true);
if ($r === null && json_last_error() !== JSON_ERROR_NONE) {
    echo 'Error: ' . json_last_error_msg() . PHP_EOL; // Error: Syntax error
}

// Forma moderna: excepciones
try {
    $r = json_decode($roto, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    echo 'JSON no válido: ' . $e->getMessage() . PHP_EOL;
}

💡 Consejo: Usa siempre JSON_THROW_ON_ERROR. Sin él, json_decode('null') y un JSON roto devuelven ambos null y no puedes distinguirlos sin consultar json_last_error().

json_validate() (PHP 8.3)

Si solo necesitas saber si una cadena es JSON válido, json_validate() lo comprueba sin construir el array en memoria, lo que es más rápido y eficiente con documentos grandes:

<?php
var_dump(json_validate('{"a": 1}'));      // bool(true)
var_dump(json_validate('{"a": 1,}'));     // bool(false)
var_dump(json_validate('[1, 2, 3]'));     // bool(true)

⚠️ Importante: No uses json_validate() y después json_decode() sobre el mismo texto: estarías analizándolo dos veces. Si vas a usar los datos, decodifica directamente con JSON_THROW_ON_ERROR.


10. Mini base de datos en JSON: CRUD de tareas

CRUD son las cuatro operaciones básicas sobre datos: Create (crear), Read (leer), Update (actualizar) y Delete (borrar). Vamos a implementarlas sobre un fichero tareas.json.

Capa de datos: lib/tareas.php

<?php
declare(strict_types=1);

const FICHERO_TAREAS = __DIR__ . '/../datos/tareas.json';

/**
 * Lee todas las tareas.
 * @return array<int, array{id: int, titulo: string, hecha: bool, creada: string}>
 */
function leerTareas(): array
{
    if (!is_file(FICHERO_TAREAS)) {
        return [];
    }
    $json = file_get_contents(FICHERO_TAREAS);
    return $json === '' ? [] : json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}

/** Guarda todas las tareas de forma atómica */
function guardarTareas(array $tareas): void
{
    $carpeta = dirname(FICHERO_TAREAS);
    if (!is_dir($carpeta) && !mkdir($carpeta, 0775, true)) {
        throw new RuntimeException("No se pudo crear $carpeta");
    }

    $json = json_encode(array_values($tareas), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

    // Escritura atómica: primero a un temporal y después se renombra.
    // Si el proceso falla a mitad, el fichero original sigue intacto.
    $temporal = FICHERO_TAREAS . '.tmp';
    file_put_contents($temporal, $json, LOCK_EX);
    rename($temporal, FICHERO_TAREAS);
}

function crearTarea(string $titulo): array
{
    $tareas = leerTareas();
    $ids = array_column($tareas, 'id');
    $tarea = [
        'id'     => $ids ? max($ids) + 1 : 1,
        'titulo' => $titulo,
        'hecha'  => false,
        'creada' => date('Y-m-d H:i'),
    ];
    $tareas[] = $tarea;
    guardarTareas($tareas);
    return $tarea;
}

function buscarTarea(int $id): ?array
{
    return array_find(leerTareas(), fn(array $t) => $t['id'] === $id);
}

function actualizarTarea(int $id, array $cambios): bool
{
    $tareas = leerTareas();
    foreach ($tareas as &$t) {
        if ($t['id'] === $id) {
            $t = [...$t, ...array_intersect_key($cambios, ['titulo' => 1, 'hecha' => 1])];
            unset($t);
            guardarTareas($tareas);
            return true;
        }
    }
    unset($t);
    return false;
}

function borrarTarea(int $id): bool
{
    $tareas = leerTareas();
    $restantes = array_filter($tareas, fn(array $t) => $t['id'] !== $id);
    if (count($restantes) === count($tareas)) {
        return false;
    }
    guardarTareas($restantes);
    return true;
}

💡 Consejo: El unset($t) tras un foreach por referencia es importante: si no rompes la referencia, la variable $t seguiría apuntando al último elemento y un uso posterior lo modificaría por accidente.

Interfaz web: public/index.php

<?php
declare(strict_types=1);

require_once __DIR__ . '/../lib/tareas.php';

function e(string|int|null $v): string
{
    return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $accion = $_POST['accion'] ?? '';
    $id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT) ?: 0;

    switch ($accion) {
        case 'crear':
            $titulo = trim((string) ($_POST['titulo'] ?? ''));
            if ($titulo !== '' && mb_strlen($titulo) <= 200) {
                crearTarea($titulo);
            }
            break;
        case 'alternar':
            $tarea = buscarTarea($id);
            if ($tarea) {
                actualizarTarea($id, ['hecha' => !$tarea['hecha']]);
            }
            break;
        case 'borrar':
            borrarTarea($id);
            break;
    }

    header('Location: index.php', true, 303);
    exit;
}

$tareas = leerTareas();
$pendientes = count(array_filter($tareas, fn($t) => !$t['hecha']));
?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Mis tareas</title>
    <style>
        .hecha { text-decoration: line-through; color: #888; }
        form.inline { display: inline; }
    </style>
</head>
<body>
    <h1>Mis tareas (<?= $pendientes ?> pendientes)</h1>

    <form method="post">
        <input type="hidden" name="accion" value="crear">
        <input name="titulo" placeholder="Nueva tarea" required maxlength="200">
        <button>Añadir</button>
    </form>

    <ul>
        <?php foreach ($tareas as $t): ?>
            <li>
                <form method="post" class="inline">
                    <input type="hidden" name="accion" value="alternar">
                    <input type="hidden" name="id" value="<?= $t['id'] ?>">
                    <button title="Marcar"><?= $t['hecha'] ? '↩️' : '✅' ?></button>
                </form>
                <span class="<?= $t['hecha'] ? 'hecha' : '' ?>"><?= e($t['titulo']) ?></span>
                <small>(<?= e($t['creada']) ?>)</small>
                <form method="post" class="inline" onsubmit="return confirm('¿Borrar la tarea?')">
                    <input type="hidden" name="accion" value="borrar">
                    <input type="hidden" name="id" value="<?= $t['id'] ?>">
                    <button title="Borrar">🗑️</button>
                </form>
            </li>
        <?php endforeach; ?>
    </ul>
</body>
</html>

Estructura final:

tareas-app/
├── datos/
│   └── tareas.json      ← Fuera de public/: no accesible desde el navegador
├── lib/
│   └── tareas.php
└── public/              ← Raíz web: php -S localhost:8000 -t public
    └── index.php

⚠️ Importante: Una base de datos en JSON es perfecta para aprender y para datos pequeños con pocas escrituras, pero tiene límites: cada operación lee y reescribe todo el fichero, y con muchos usuarios simultáneos aparecen problemas de rendimiento y concurrencia. Por eso existen las bases de datos (módulo 15). Fíjate en que la capa lib/tareas.php aísla el almacenamiento: para pasar a MySQL solo tendrás que cambiar ese fichero.


11. Logs simples

Un log es un registro cronológico de lo que ocurre en la aplicación: errores, accesos, acciones importantes. Es la “caja negra” que consultarás cuando algo falle en producción.

Con error_log()

<?php
// Al log de errores configurado en php.ini (error_log)
error_log('No se pudo conectar con el servicio de pagos');

// A un fichero concreto (tipo 3): hay que añadir el salto de línea
error_log(date('[Y-m-d H:i:s] ') . "Pedido 1234 creado\n", 3, __DIR__ . '/logs/app.log');

Una función de log propia

<?php
declare(strict_types=1);

const NIVELES_LOG = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];

function registrar(string $nivel, string $mensaje, array $contexto = []): void
{
    $nivel = strtoupper($nivel);
    if (!in_array($nivel, NIVELES_LOG, true)) {
        $nivel = 'INFO';
    }

    $carpeta = __DIR__ . '/logs';
    if (!is_dir($carpeta) && !mkdir($carpeta, 0775, true)) {
        return; // Un fallo del log nunca debe tumbar la aplicación
    }

    // Un fichero por día: app-2026-10-03.log
    $fichero = $carpeta . '/app-' . date('Y-m-d') . '.log';

    // Sin saltos de línea dentro del mensaje: una entrada = una línea
    $mensaje = str_replace(["\r", "\n"], ' ', $mensaje);
    $extra = $contexto ? ' ' . json_encode($contexto, JSON_UNESCAPED_UNICODE) : '';

    $linea = sprintf("[%s] %s: %s%s\n", date('Y-m-d H:i:s'), $nivel, $mensaje, $extra);
    file_put_contents($fichero, $linea, FILE_APPEND | LOCK_EX);
}

registrar('info', 'Usuario ha iniciado sesión', ['usuario_id' => 42, 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli']);
registrar('error', 'Fallo al enviar correo', ['destino' => 'ana@correo.es']);
[2026-10-03 10:15:02] INFO: Usuario ha iniciado sesión {"usuario_id":42,"ip":"127.0.0.1"}
[2026-10-03 10:15:07] ERROR: Fallo al enviar correo {"destino":"ana@correo.es"}

⚠️ Importante: Nunca escribas en un log contraseñas, números de tarjeta ni tokens de sesión. Y elimina los saltos de línea de los datos del usuario: si no, un atacante podría inyectar líneas falsas en tu log (log injection).

💡 Consejo: En proyectos reales se usa una librería como Monolog, que implementa el estándar PSR-3 y permite enviar logs a ficheros, correo, Slack o servicios externos. La verás en los módulos 13 y 14.


12. Permisos y seguridad

Permisos en Linux/macOS

Cada fichero tiene permisos de lectura (r = 4), escritura (w = 2) y ejecución (x = 1) para tres grupos: propietario, grupo y otros. Se expresan en octal:

Permiso Significado Uso típico
0644 Propietario lee y escribe; resto solo lee Ficheros normales
0664 Propietario y grupo leen y escriben Ficheros compartidos con el grupo del servidor
0755 Propietario todo; resto lee y entra Carpetas
0775 Propietario y grupo todo Carpetas donde escribe PHP
0600 Solo el propietario Secretos, .env
0777 Todos todo ❌ Nunca en producción
<?php
chmod(__DIR__ . '/datos/tareas.json', 0664);
echo substr(sprintf('%o', fileperms(__DIR__ . '/datos/tareas.json')), -4); // 0664

📘 Concepto: PHP se ejecuta con el usuario del servidor web (por ejemplo www-data en Debian/Ubuntu). Para que PHP pueda escribir en una carpeta, ese usuario necesita permiso de escritura. En Windows los permisos funcionan con ACL y chmod() apenas tiene efecto.

⚠️ Importante: Si algo “no se puede escribir”, la solución no es chmod 777. Asigna la carpeta al usuario o grupo del servidor web (chown/chgrp) y da 0775 solo a las carpetas que lo necesiten (logs, caché, subidas, datos).

Path traversal

📘 Concepto: El ataque path traversal (recorrido de directorios) consiste en manipular un nombre de fichero con ../ para salir de la carpeta prevista y leer (o escribir) ficheros arbitrarios del servidor.

<?php
// ver.php?doc=manual.pdf
// ❌ VULNERABLE
$doc = $_GET['doc'];
readfile(__DIR__ . '/documentos/' . $doc);

// Un atacante pide: ver.php?doc=../../../../etc/passwd
// o:                ver.php?doc=../config/database.php

Defensas

1. basename(): quedarte solo con el nombre final, eliminando cualquier carpeta:

<?php
$doc = basename($_GET['doc'] ?? '');  // '../../etc/passwd' → 'passwd'

2. realpath() + comprobar que sigue dentro de la carpeta permitida:

<?php
declare(strict_types=1);

function rutaSegura(string $carpetaBase, string $nombre): ?string
{
    $base = realpath($carpetaBase);
    $ruta = realpath($carpetaBase . DIRECTORY_SEPARATOR . $nombre);

    // realpath devuelve false si no existe; y la ruta debe empezar por la base
    if ($base === false || $ruta === false || !str_starts_with($ruta, $base . DIRECTORY_SEPARATOR)) {
        return null;
    }
    return is_file($ruta) ? $ruta : null;
}

$ruta = rutaSegura(__DIR__ . '/documentos', (string) ($_GET['doc'] ?? ''));
if ($ruta === null) {
    http_response_code(404);
    exit('Documento no encontrado');
}

header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . basename($ruta) . '"');
readfile($ruta);

3. Lista blanca o identificadores: la mejor defensa es que el usuario no envíe nombres de fichero, sino un identificador que tú traduces:

<?php
$documentos = [
    'manual'   => 'manual-usuario-v3.pdf',
    'tarifas'  => 'tarifas-2026.pdf',
];
$clave = $_GET['doc'] ?? '';
if (!isset($documentos[$clave])) {
    http_response_code(404);
    exit;
}
readfile(__DIR__ . '/documentos/' . $documentos[$clave]);

Resumen de buenas prácticas

Práctica Motivo
Datos, logs y subidas fuera de la raíz web No se pueden descargar ni ejecutar desde el navegador
Nunca usar nombres de fichero del usuario sin validar Path traversal
Nombres aleatorios para ficheros subidos Sobrescrituras y extensiones peligrosas (módulo 08)
LOCK_EX en escrituras compartidas Datos corruptos por concurrencia
Comprobar el valor de retorno (false) Las funciones de ficheros fallan en silencio con un warning
Permisos mínimos Limita el daño si algo va mal
Escritura atómica (temporal + rename) No dejar ficheros a medio escribir

13. Ejercicios

Ejercicio 1: Estadísticas de un texto

Escribe analizar.php que reciba la ruta de un fichero de texto por la línea de comandos ($argv[1]) y muestre: número de líneas, palabras y caracteres (multibyte), las 10 palabras más frecuentes (ignorando mayúsculas y signos de puntuación) y la línea más larga. Debe procesar el fichero línea a línea con fopen()/fgets() para funcionar con ficheros grandes.

Ejercicio 2: Explorador de carpetas

Crea explorador.php que muestre en una tabla HTML el contenido de una carpeta compartido/: nombre, tipo (carpeta/fichero), tamaño legible y fecha de modificación, ordenable por cualquier columna con un parámetro GET (?orden=tamano). Permite navegar a subcarpetas con ?ruta=... y protégelo contra path traversal con realpath().

Ejercicio 3: Importador CSV

Prepara un alumnos.csv (separador ;) con nombre, email, nota1, nota2 y nota3 de al menos 10 alumnos, incluyendo algunas filas con errores (email no válido, nota fuera de 0-10). Escribe un script que lo lea con fgetcsv(), valide cada fila, calcule la media, y genere dos ficheros: aprobados.csv (con la media) y errores.log (con el número de línea y el motivo de cada fila rechazada).

Ejercicio 4: Exportación descargable

Crea una página con una tabla de productos (array en PHP) y dos botones: “Descargar CSV” y “Descargar JSON”. El CSV debe abrirse correctamente en Excel con tildes (BOM y ;), y el JSON debe estar formateado con JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE. Ambos ficheros deben llamarse productos-AAAA-MM-DD.ext.

Ejercicio 5: CRUD de tareas ampliado

Amplía la mini base de datos de tareas con: edición del título, prioridad (alta, media, baja, validada con lista blanca), fecha límite opcional, filtros por estado (?filtro=pendientes) y un botón “Borrar completadas”. Añade una función exportarTareasCsv() que reutilice leerTareas().

Ejercicio 6: Visor de logs

Usa la función registrar() de la sección 11 para generar entradas de log de distintos niveles durante varios días (puedes simular las fechas). Crea visor-logs.php que liste los ficheros de log disponibles con glob(), permita elegir uno y filtrar por nivel, y muestre las últimas 50 entradas en orden inverso. Añade un script de “rotación” que borre los logs de más de 30 días según filemtime().

Ejercicio 7: Caché en disco

Escribe cache(string $clave, int $segundos, callable $generar): mixed que guarde el resultado de $generar() serializado en JSON en cache/<md5 de la clave>.json y lo reutilice mientras no hayan pasado $segundos (compruébalo con filemtime()). Úsala para “cachear” una función lenta simulada con sleep(2) y mide la diferencia de tiempo.


Resumen

Concepto Sintaxis / clave
Ruta absoluta __DIR__ . '/datos/x.json'
Ruta canónica realpath($ruta) (o false)
Leer todo file_get_contents($ruta)
Escribir / añadir file_put_contents($ruta, $txt, FILE_APPEND \| LOCK_EX)
Leer líneas file($ruta, FILE_IGNORE_NEW_LINES \| FILE_SKIP_EMPTY_LINES)
Abrir / cerrar $f = fopen($ruta, 'r'); ... fclose($f);
Línea a línea while (($l = fgets($f)) !== false)
Bloqueo flock($f, LOCK_EX) / flock($f, LOCK_UN)
Orientado a objetos new SplFileObject($ruta) + foreach
Comprobar is_file(), is_dir(), is_readable(), filesize(), filemtime()
Directorios mkdir($d, 0775, true), scandir(), glob(), rmdir()
Ficheros copy(), rename(), unlink()
CSV fgetcsv($f, separator: ';', escape: ''), fputcsv($f, $fila, ';', '"', '')
Descarga Content-Disposition: attachment + php://output
JSON json_encode($d, JSON_PRETTY_PRINT \| JSON_UNESCAPED_UNICODE \| JSON_THROW_ON_ERROR)
Decodificar json_decode($json, true, 512, JSON_THROW_ON_ERROR)
Validar JSON (8.3) json_validate($json)
Path traversal basename(), realpath() + str_starts_with(), listas blancas