Ir al contenido principal

Lección 4 · Archivos y errores

Archivos en el servidor

Hasta acá todo lo que el sistema sabía se perdía al terminar la petición. Un archivo es la forma más simple de que algo sobreviva: el programa lo escribe y la próxima ejecución lo lee. Con eso alcanza para el catálogo del proveedor, para la foto de un producto y para el registro de lo que falla.

Las rutas

Un archivo se nombra de dos maneras, y confundirlas es la causa de la mitad de los errores de esta lección.

Forma Ejemplo Depende de…
Relativa 'datos/catalogo.csv' El directorio de trabajo del proceso, que cambia según cómo se llame al script.
Absoluta '/var/www/tienda/datos/catalogo.csv' Nada: siempre apunta al mismo lugar.
Armada con __DIR__ __DIR__ . '/datos/catalogo.csv' Nada: __DIR__ es la carpeta del archivo actual.

La tercera es la que se usa en todo el sistema. Es absoluta en el momento de ejecutarse, pero se escribe relativa al archivo, así que el proyecto se puede mover de carpeta o de servidor sin tocar una línea.

<?php

$ruta = __DIR__ . '/../datos/catalogo.csv';

echo $ruta;   // /var/www/tienda/datos/catalogo.csv

Permisos

En GNU/Linux, un archivo tiene dueño y permisos. PHP corre como el usuario del servidor web —www-data en Debian y Ubuntu—, así que para escribir un archivo, ese usuario necesita permiso sobre el archivo y sobre la carpeta que lo contiene.

Lo que no se hace nunca es chmod 777: deja el archivo escribible por cualquiera del sistema. Lo correcto es darle la carpeta de datos al usuario del servidor, sudo chown -R www-data:www-data datos, y que los permisos sean 755 para las carpetas y 644 para los archivos. Si el sistema dice «Permission denied» al guardar, el problema es ese y no el código.

Leer archivos

Hay cuatro formas y cada una sirve para una cosa distinta.

Todo el contenido de una vez

$contenido = file_get_contents($ruta);

if ($contenido === false) {
    // el archivo no se pudo leer
}

echo $contenido;

Sirve para archivos chicos: una plantilla, un JSON de configuración, un texto corto. Devuelve el contenido completo como texto, o false si no pudo. Nunca devuelve '' en caso de error: un archivo vacío y un archivo que no se pudo abrir son cosas distintas, y por eso se compara con === false.

Una línea por elemento

$lineas = file($ruta, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

foreach ($lineas as $numero => $linea) {
    echo ($numero + 1) . ': ' . $linea . "\n";
}

Devuelve un array con una entrada por línea. Las dos banderas son las que se usan casi siempre: FILE_IGNORE_NEW_LINES evita el salto de línea al final de cada elemento —que arruina las comparaciones— y FILE_SKIP_EMPTY_LINES descarta las líneas en blanco.

De a una, sin cargar todo

$manejador = fopen($ruta, 'r');

if ($manejador === false) {
    exit('No se pudo abrir el archivo.');
}

while (($linea = fgets($manejador)) !== false) {
    echo $linea;
}

fclose($manejador);

Se abre el archivo, se lee línea por línea y se cierra. Es la forma correcta para un archivo grande: no carga todo en memoria. El fclose() no es opcional —PHP cierra los archivos al terminar la petición, pero dejarlos abiertos mientras se procesa es un desperdicio y, en un bucle, un problema.

Un CSV, línea por línea

El catálogo del proveedor llega como CSV: valores separados por punto y coma. PHP trae una función que respeta las comillas y los separadores dentro de un campo, cosa que explode() no hace:

$manejador = fopen($ruta, 'r');

while (($campos = fgetcsv($manejador, 0, ';')) !== false) {
    print_r($campos);
}

fclose($manejador);

La misma función sirve para escribir: fputcsv($manejador, $campos, ';') arma la línea con las comillas donde hacen falta. Es la pareja que se usa en la importación y en la exportación del catálogo.

Un detalle que cuesta encontrar: si el archivo viene de Windows, las líneas terminan en \r\n y el último campo de cada fila queda con un \r al final. Se ve como un espacio invisible. Se resuelve pasando trim() a cada valor.

Escribir archivos

Para escribir hay dos caminos, según el tamaño de lo que se guarda.

// Reemplaza el contenido completo
file_put_contents($ruta, $texto, LOCK_EX);

// Agrega al final, sin borrar lo que había
file_put_contents($ruta, $linea . PHP_EOL, FILE_APPEND | LOCK_EX);
Bandera Qué hace
FILE_APPEND Agrega al final en lugar de reemplazar. Es lo que se usa para un archivo de registro.
LOCK_EX Bloquea el archivo mientras se escribe. Evita que dos peticiones simultáneas se pisen a mitad de una línea.
PHP_EOL El salto de línea del sistema. Se usa en lugar de "\n" para que el archivo se abra bien en cualquier editor.

file_put_contents() devuelve la cantidad de bytes escritos, o false si falló. Comprobar el resultado es lo que separa un sistema que avisa «no se pudo guardar» de uno que pierde el dato en silencio.

El registro de lo que pasa

El primer archivo que escribe el sistema no guarda datos de negocio: guarda lo que falla. Cada vez que una importación rechaza una línea, o que alguien intenta entrar con la clave equivocada, queda una línea con la fecha.

function registrar(string $mensaje): void
{
    $linea = date('Y-m-d H:i:s') . ' ' . $mensaje . PHP_EOL;

    file_put_contents(
        __DIR__ . '/../datos/registro.log',
        $linea,
        FILE_APPEND | LOCK_EX
    );
}

registrar('Importación: línea 12 rechazada, precio inválido.');

Lo que no va en ese archivo: contraseñas, números de tarjeta y datos personales de los clientes. Un registro se copia, se manda por correo para revisar y se deja en el servidor durante meses; cualquier cosa que se escriba ahí deja de estar protegida.

Subir archivos

La foto de un producto no se escribe con file_put_contents: llega dentro de la petición, en un archivo temporal que el servidor crea y borra solo. El programa tiene que moverlo a su lugar definitivo.

El formulario

<form action="subir-foto.php" method="post" enctype="multipart/form-data">
    <input type="hidden" name="codigo" value="1042">

    <label for="foto">Foto del producto</label>
    <input type="file" id="foto" name="foto" accept="image/jpeg,image/png">

    <button type="submit">Subir</button>
</form>

Sin enctype="multipart/form-data" el archivo no viaja: en el servidor llega solo el nombre como texto. Es el primer error de toda la gente que empieza con subidas.

Lo que llega

Del otro lado, el archivo está en $_FILES['foto'], que es un array con datos del archivo y no el archivo en sí:

Clave Qué trae
nameEl nombre original, escrito por el cliente. No es confiable.
typeEl tipo MIME declarado por el navegador. Tampoco es confiable.
tmp_nameDónde quedó el archivo temporal en el servidor. Es la ruta que hay que usar.
error0 si salió bien; un número si no, como UPLOAD_ERR_NO_FILE o UPLOAD_ERR_INI_SIZE.
sizeTamaño en bytes, medido por el servidor.

La subida, paso a paso

<?php

declare(strict_types=1);

const MAX_BYTES = 2 * 1024 * 1024;   // 2 MB
const TIPOS = [IMAGETYPE_JPEG, IMAGETYPE_PNG];

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

if ($archivo === null || $archivo['error'] === UPLOAD_ERR_NO_FILE) {
    exit('Elegí un archivo.');
}

if ($archivo['error'] !== UPLOAD_ERR_OK) {
    exit('La subida falló con el código ' . (int) $archivo['error']);
}

if (!is_uploaded_file($archivo['tmp_name'])) {
    exit('El archivo no vino en una subida válida.');
}

if ($archivo['size'] > MAX_BYTES) {
    exit('La imagen no puede pasar de 2 MB.');
}

// El tipo se decide mirando el contenido, no el nombre ni lo que declara el navegador
$info = getimagesize($archivo['tmp_name']);

if ($info === false || !in_array($info[2], TIPOS, true)) {
    exit('El archivo no es una imagen JPEG o PNG.');
}

// Nombre nuevo, generado por el servidor
$extension = $info[2] === IMAGETYPE_PNG ? 'png' : 'jpg';
$destino = __DIR__ . '/../public/img/productos/' . $codigo . '.' . $extension;

if (!move_uploaded_file($archivo['tmp_name'], $destino)) {
    exit('No se pudo guardar la imagen.');
}

Tres comprobaciones de seguridad en ese código, y las tres se justifican solas:

  • is_uploaded_file() confirma que la ruta es de un archivo subido en esta petición y no cualquier archivo del servidor. Sin eso, alguien podría hacer que el programa copie /etc/passwd.
  • getimagesize() mira el contenido del archivo. Un ejecutable renombrado a foto.jpg no pasa esa prueba, aunque el nombre y el MIME declarado digan que es una imagen.
  • El nombre final lo elige el servidor. El nombre original puede traer ../, caracteres raros o una extensión distinta.

move_uploaded_file() es la única función que hay que usar para mover el temporal: verifica por dentro que el archivo sea una subida válida y, si no lo es, no hace nada.

Lo que puede salir mal con una subida

Subir archivos es la operación más peligrosa de un sitio PHP, porque significa dejar que un extraño escriba un archivo en el servidor. Los errores clásicos, y cómo se evita cada uno:

Error Qué permite Cómo se evita
Confiar en $_FILES['name'] Guardar ../../../var/www/html/shell.php y salir de la carpeta prevista. Generar el nombre en el servidor: código del producto más la extensión deducida del contenido.
Confiar en $_FILES['type'] Subir un .php declarado como image/jpeg. Comprobar con getimagesize() o finfo_file(), que leen el contenido.
Guardar en una carpeta que ejecuta PHP Pedir el archivo subido y que el servidor lo ejecute como programa. Guardar fuera de la raíz del sitio, o en una carpeta con la ejecución de PHP desactivada.
No limitar el tamaño Llenar el disco con un archivo enorme. Comprobar size y configurar upload_max_filesize y post_max_size.
Permitir SVG Un SVG puede contener JavaScript y ejecutarse al mostrarlo. No aceptar SVG para imágenes subidas por usuarios.
Mostrar el error crudo de la subida Revelar rutas internas del servidor. Un mensaje propio y el detalle al registro.

El resumen es una sola idea: todo lo que trae el cliente se puede falsear. El nombre, el tipo y hasta el tamaño declarado. Lo único que vale es lo que el servidor comprueba por su cuenta.

Los errores de PHP

Cuando algo sale mal, PHP avisa. Entender cómo avisa es lo que separa «no anda» de «falta el paquete php-mysql».

Nivel Qué significa ¿Detiene el programa?
E_WARNINGAlgo salió mal pero se puede seguir: un archivo que no existe, una variable sin definir.No.
E_NOTICEUn detalle sospechoso: una clave que no está.No.
E_DEPRECATEDAlgo que va a dejar de funcionar en una versión futura.No.
E_ERRORUn error que impide continuar: llamar a una función que no existe.Sí.
E_PARSEError de sintaxis: el archivo ni siquiera se ejecuta.Sí, antes de empezar.

Desarrollo y producción no se configuran igual

; Desarrollo: ver todo, para corregirlo
display_errors = On
error_reporting = E_ALL

; Producción: no mostrar nada, pero registrar todo
display_errors = Off
error_reporting = E_ALL
log_errors = On
error_log = /var/log/php/tienda-error.log

En producción, un mensaje de error en pantalla revela rutas, nombres de tablas, consultas y hasta fragmentos de claves. El error_reporting sigue en E_ALL: lo que cambia es dónde va el mensaje, no si se registra.

Los errores no son excepciones

Un E_WARNING no se atrapa con try/catch. Es un aviso que se imprime y el programa sigue, con el valor equivocado. Por eso conviene no depender de que algo avise: si una función puede fallar, se comprueba lo que devuelve.

Desde PHP 8 los errores graves —como llamar a un método de null— sí se lanzan como Error, que es una excepción y se puede atrapar. Pero un warning sigue siendo un aviso silencioso si no se mira el registro.

Excepciones

Una excepción es una forma de cortar el camino normal y avisar que algo no se puede hacer. A diferencia de un warning, no sigue de largo: la ejecución salta al catch más cercano.

<?php

function leerCatalogo(string $ruta): array
{
    if (!is_readable($ruta)) {
        throw new RuntimeException("No se puede leer el catálogo: $ruta");
    }

    $lineas = file($ruta, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

    if ($lineas === false) {
        throw new RuntimeException('La lectura del catálogo falló.');
    }

    return $lineas;
}

Y quien la llama decide qué hacer:

try {
    $lineas = leerCatalogo(__DIR__ . '/../datos/catalogo.csv');
} catch (RuntimeException $e) {
    registrar('Catálogo: ' . $e->getMessage());
    exit('No se pudo cargar el catálogo. Probá más tarde.');
} finally {
    // Se ejecuta siempre: haya error o no
    registrar('Intento de carga terminado.');
}
Parte Qué hace
tryEl código que puede fallar.
catchQué hacer si falló. Se puede repetir para distintos tipos de excepción.
finallyLo que se ejecuta siempre, haya error o no. Sirve para cerrar un archivo o liberar algo.
throwLanza la excepción y corta la ejecución normal.

El orden de los catch importa

Se evalúan de arriba hacia abajo y se ejecuta el primero que coincide. Como toda excepción hereda de Exception, un catch (Exception $e) puesto primero atrapa todo y los demás nunca se ejecutan.

try {
    importar($ruta);
} catch (ArchivoInvalidoException $e) {
    exit('El archivo tiene un formato que no se puede leer.');
} catch (Throwable $e) {
    registrar($e->getMessage());
    exit('Ocurrió un error inesperado.');
}

Lo específico va primero y lo general al final. Throwable es la interfaz común de Exception y de Error: atraparlo cubre las dos familias.

Excepciones propias

Cuando el error tiene datos que quien llama va a necesitar, conviene una clase propia. En la importación, saber qué línea falló es lo que permite corregir el archivo.

<?php

class ImportacionException extends RuntimeException
{
    public function __construct(
        string $mensaje,
        private int $linea,
        ?Throwable $anterior = null,
    ) {
        parent::__construct($mensaje, 0, $anterior);
    }

    public function linea(): int
    {
        return $this->linea;
    }
}

Se lanza desde la importación y se atrapa en la pantalla, que puede mostrar el dato:

try {
    importarCatalogo($ruta);
} catch (ImportacionException $e) {
    registrar('Importación: ' . $e->getMessage() . ' (línea ' . $e->linea() . ')');
    exit('El archivo tiene un error en la línea ' . $e->linea() . '.');
}

La propiedad se declara en el constructor —una de las comodidades del lenguaje actual, que crea el atributo y lo asigna sola— y se expone con un método. Lo que no se hace es mostrar el mensaje crudo de una excepción al usuario: $e->getMessage() va al registro, y a la pantalla va un texto que se entienda.

Atrapar sin tragarse el error

Un catch vacío es la peor forma de manejar un error: el programa sigue como si nada, el problema queda invisible y aparece mucho después, en otro lugar, como un dato que falta.

Mal Por qué Bien
catch (Exception $e) { } Se pierde el error por completo. Registrar y decidir qué se muestra.
catch (Exception $e) { echo $e; } Muestra rutas, consultas y datos internos al usuario. registrar($e->getMessage()) y un mensaje propio en pantalla.
catch (Exception $e) { return false; } Convierte el error en un valor que nadie comprueba. Propagar o manejar de verdad.
catch (Throwable $e) sin pensar Atrapa también los errores de programación, como un tipo mal declarado. Atrapar primero lo específico y dejar lo general al final.

La regla práctica: lo que el usuario ve dice qué hacer; el registro dice qué pasó. «No se pudo cargar el catálogo, probá más tarde» es un buen mensaje en pantalla. El detalle —la ruta, la línea, el error del sistema— va al archivo de registro.

Errores frecuentes

Lo que pasa La causa Cómo se arregla
Failed to open stream: No such file or directory La ruta está mal, o es relativa y el directorio de trabajo no es el que se cree. Armar la ruta con __DIR__ y comprobarla con var_dump() antes de abrir.
Permission denied El usuario del servidor web no puede escribir en la carpeta. chown al usuario de Apache, sin chmod 777.
El CSV trae un carácter raro al final de cada campo El archivo viene de Windows y termina las líneas en \r\n. trim() a cada campo, o abrir el archivo en modo texto.
La importación importa la primera fila como producto No se salteó el encabezado. Contar las líneas y saltear la primera.
$_FILES está vacío Falta enctype="multipart/form-data" en el formulario. Agregarlo, y que el método sea POST.
La imagen sube y no se ve Se movió a una carpeta que no es accesible desde la web, o el nombre no coincide. Comprobar la ruta de destino y los permisos de la carpeta pública.
Undefined index o Undefined array key Se lee una clave de $_FILES que no existe, porque no se subió nada. Comprobar UPLOAD_ERR_NO_FILE antes de leer el resto.
El error aparece en pantalla en producción display_errors está encendido. Apagarlo y activar log_errors con una ruta de registro.
Un catch no atrapa nada Lo que falló fue un warning, no una excepción. Comprobar el valor de retorno de la función, o convertirlo con un manejador de errores.

Para diagnosticar cualquiera de estos, lo primero es mirar el registro y no la pantalla: en desarrollo, el mensaje completo está en storage/logs o en el archivo que diga error_log. Y lo segundo es comprobar la ruta con var_dump(realpath($ruta)): realpath() devuelve la ruta absoluta resuelta, o false si no existe.

Ejercicios

Diez ejercicios sobre archivos y errores, con el sistema de la tienda como caso. Los primeros son de lectura y escritura; los últimos agregan subidas y excepciones.

1. Contar líneas

Escribir una función que reciba una ruta y devuelva cuántas líneas no vacías tiene el archivo. Si el archivo no existe, tiene que devolver null y no lanzar ningún aviso.

2. Leer un JSON

Leer config.json, que tiene {"iva": 0.21, "stock_minimo": 5}, y mostrar el IVA. Considerar qué pasa si el archivo tiene un error de sintaxis.

3. El registro

Escribir una función registrar() que agregue una línea con fecha y mensaje a registro.log, sin que dos peticiones simultáneas se pisen.

4. Importar tres campos

Leer un CSV con código, nombre y precio, saltear el encabezado y devolver un array de productos válidos, descartando los que tengan precio inválido.

5. El error de la línea

Modificar el ejercicio anterior para que, al terminar, informe cuántas líneas se importaron y en cuáles hubo problemas.

6. Validar la subida

Escribir la validación de una foto de producto: que se haya subido, que no pase de 1 MB, que sea JPEG o PNG de verdad y que el destino se arme con el código del producto.

7. Una excepción propia

Crear ProductoInvalidoException que lleve el código del producto, y lanzarla cuando el precio sea negativo.

8. Atrapar bien

Escribir el try/catch de una importación: si el archivo falta, avisar en pantalla y registrar el detalle; si el error es de otro tipo, registrar y mostrar un mensaje genérico.

9. Exportar el catálogo

Escribir el catálogo de un array a un CSV con encabezados, usando fputcsv.

10. Buscar los errores

Este código intenta leer el catálogo y tiene cuatro problemas. Encontrarlos y corregirlos:

<?php

$lineas = file('catalogo.csv');

foreach ($lineas as $linea) {
    [$codigo, $nombre, $precio] = explode(';', $linea);

    if ($precio < 0) {
        echo "Error en $codigo";
    }
}

echo "Total: " . count($lineas) . " productos";

Respuestas sugeridas

1.

function contarLineas(string $ruta): ?int
{
    if (!is_readable($ruta)) {
        return null;
    }

    $lineas = file($ruta, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

    return $lineas === false ? null : count($lineas);
}

is_readable() evita que file() emita un warning cuando el archivo no está. Y se comprueba el retorno de file() igual: entre la comprobación y la lectura el archivo puede desaparecer.

2.

$texto = file_get_contents(__DIR__ . '/config.json');

if ($texto === false) {
    exit('No se encontró la configuración.');
}

$config = json_decode($texto, true);

if (json_last_error() !== JSON_ERROR_NONE) {
    exit('La configuración tiene un error de formato: ' . json_last_error_msg());
}

echo 'IVA: ' . (float) $config['iva'];

json_decode() devuelve null si el texto no es JSON válido, y null también es un valor posible de un JSON que dice null. Por eso se comprueba json_last_error() y no el resultado.

3.

function registrar(string $mensaje): void
{
    $linea = date('Y-m-d H:i:s') . ' ' . $mensaje . PHP_EOL;

    file_put_contents(
        __DIR__ . '/../datos/registro.log',
        $linea,
        FILE_APPEND | LOCK_EX
    );
}

FILE_APPEND para no borrar lo anterior y LOCK_EX para que dos escrituras simultáneas no se mezclen.

4.

function productosValidos(string $ruta): array
{
    $manejador = fopen($ruta, 'r');

    if ($manejador === false) {
        return [];
    }

    $productos = [];
    $numero = 0;

    while (($campos = fgetcsv($manejador, 0, ';')) !== false) {
        $numero++;

        if ($numero === 1) {
            continue;
        }

        if (count($campos) !== 3) {
            continue;
        }

        [$codigo, $nombre, $precio] = array_map('trim', $campos);

        if (!is_numeric($precio) || (float) $precio < 0) {
            continue;
        }

        $productos[] = [
            'codigo' => (int) $codigo,
            'nombre' => $nombre,
            'precio' => (float) $precio,
        ];
    }

    fclose($manejador);

    return $productos;
}

5.

$importados = 0;
$problemas = [];

// ... dentro del bucle ...
if (!is_numeric($precio) || (float) $precio < 0) {
    $problemas[] = "Línea $numero: precio inválido ($precio)";
    continue;
}

$importados++;

// ... al terminar ...
echo "$importados importados.";
echo count($problemas) . ' líneas con problemas.';

foreach ($problemas as $problema) {
    echo $problema . PHP_EOL;
}

El número de línea es lo único que le permite a quien tiene el archivo encontrar el problema. Sin eso, el informe no sirve.

6.

const MAX_BYTES = 1024 * 1024;   // 1 MB

$archivo = $_FILES['foto'] ?? null;
$codigo = (int) ($_POST['codigo'] ?? 0);

if ($archivo === null || $archivo['error'] === UPLOAD_ERR_NO_FILE) {
    exit('Elegí una foto.');
}

if ($archivo['error'] !== UPLOAD_ERR_OK || $codigo === 0) {
    exit('La subida no se pudo procesar.');
}

if (!is_uploaded_file($archivo['tmp_name']) || $archivo['size'] > MAX_BYTES) {
    exit('La imagen no es válida o pasa de 1 MB.');
}

$info = getimagesize($archivo['tmp_name']);

if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
    exit('La imagen tiene que ser JPEG o PNG.');
}

$extension = $info[2] === IMAGETYPE_PNG ? 'png' : 'jpg';
$destino = __DIR__ . '/../public/img/productos/' . $codigo . '.' . $extension;

if (!move_uploaded_file($archivo['tmp_name'], $destino)) {
    exit('No se pudo guardar la imagen.');
}

7.

class ProductoInvalidoException extends RuntimeException
{
    public function __construct(
        string $mensaje,
        private int $codigoProducto,
    ) {
        parent::__construct($mensaje);
    }

    public function codigoProducto(): int
    {
        return $this->codigoProducto;
    }
}

function validarPrecio(int $codigo, float $precio): void
{
    if ($precio < 0) {
        throw new ProductoInvalidoException('Precio negativo.', $codigo);
    }
}

8.

try {
    importarCatalogo(__DIR__ . '/../datos/catalogo.csv');
} catch (RuntimeException $e) {
    registrar('Importación: ' . $e->getMessage());
    exit('No se pudo leer el archivo del catálogo.');
} catch (Throwable $e) {
    registrar('Importación, error inesperado: ' . $e->getMessage());
    exit('Ocurrió un error inesperado. Probá más tarde.');
}

El específico primero, el general al final. Y en los dos casos el mensaje que ve el usuario es distinto del que se registra.

9.

$manejador = fopen($ruta, 'w');

if ($manejador === false) {
    exit('No se pudo crear el archivo.');
}

fputcsv($manejador, ['codigo', 'nombre', 'precio'], ';');

foreach ($catalogo as $producto) {
    fputcsv($manejador, [
        $producto['codigo'],
        $producto['nombre'],
        number_format($producto['precio'], 2, ',', ''),
    ], ';');
}

fclose($manejador);

Modo 'w' para crear o reemplazar. Si se quisiera agregar al final sin borrar, sería 'a'. Y fputcsv se encarga de las comillas si un nombre tiene el separador adentro.

10. Los problemas son: la ruta es relativa, así que depende del directorio de trabajo —hay que usar __DIR__—; no se comprueba si file() pudo leer el archivo; explode no respeta las comillas y, si una línea tiene más campos, la desestructuración falla o descarta datos, así que conviene fgetcsv y comprobar count; y $precio llega como texto con un salto de línea al final, así que $precio < 0 compara un texto: hay que aplicar trim() y validar con is_numeric antes de convertir. La versión corregida:

<?php

declare(strict_types=1);

$ruta = __DIR__ . '/catalogo.csv';
$manejador = fopen($ruta, 'r');

if ($manejador === false) {
    exit('No se pudo abrir el catálogo.');
}

$total = 0;
$numero = 0;

while (($campos = fgetcsv($manejador, 0, ';')) !== false) {
    $numero++;

    if ($numero === 1 || count($campos) !== 3) {
        continue;
    }

    [$codigo, $nombre, $precio] = array_map('trim', $campos);

    if (!is_numeric($precio)) {
        echo "Error en el código $codigo: precio inválido." . PHP_EOL;
        continue;
    }

    if ((float) $precio < 0) {
        echo "Error en el código $codigo: precio negativo." . PHP_EOL;
        continue;
    }

    $total++;
}

fclose($manejador);

echo "Total: $total productos";

Para leer más

Resumen

Tema Lo que hay que retener
RutasSiempre con __DIR__: absolutas en ejecución, relativas al archivo al escribirlas.
PermisosEl usuario del servidor necesita escribir en la carpeta. Nunca chmod 777.
Leerfile_get_contents para archivos chicos, fgets para grandes, fgetcsv para CSV.
EscribirFILE_APPEND y LOCK_EX. Comprobar que devolvió algo distinto de false.
ImportarSaltear el encabezado, comprobar la cantidad de campos, seguir después de un error e informar la línea.
Subirenctype en el formulario, is_uploaded_file, getimagesize y nombre generado por el servidor.
ErroresEn desarrollo se muestran; en producción se registran. E_ALL en los dos casos.
Excepcionestry, catch específico y finally. Lo general al final.
RegistroEl detalle al archivo, el mensaje entendible a la pantalla. Nunca datos sensibles.

En la próxima lección el sistema da el salto a los objetos: las clases Producto, Cliente y Venta, con sus datos y sus reglas adentro.

Autoevaluación de la lección 4

Licencia. El material de esta página es elaboración propia de Prof Mauro Bobyk —a partir de las fuentes que detalla el pie del sitio— y se publica bajo una licencia Creative Commons BY-NC-SA 4.0.