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.
Importar el catálogo
El proveedor manda un CSV. La importación tiene que leerlo, validar línea por línea y decir con precisión qué quedó afuera y por qué. Un archivo de 200 productos no se puede importar «a medias» sin explicación.
<?php
declare(strict_types=1);
const CATEGORIAS = ['almacen', 'bebidas', 'limpieza'];
$ruta = __DIR__ . '/../datos/catalogo.csv';
$manejador = fopen($ruta, 'r');
if ($manejador === false) {
exit('No se pudo abrir el archivo del catálogo.');
}
$importados = [];
$rechazados = [];
$numero = 0;
while (($campos = fgetcsv($manejador, 0, ';')) !== false) {
$numero++;
// Primera línea: encabezados
if ($numero === 1) {
continue;
}
if (count($campos) !== 4) {
$rechazados[] = "Línea $numero: se esperaban 4 campos.";
continue;
}
[$codigo, $nombre, $categoria, $precio] = array_map('trim', $campos);
$errores = [];
if (!ctype_digit($codigo)) {
$errores[] = 'código inválido';
}
if ($nombre === '') {
$errores[] = 'falta el nombre';
}
if (!in_array($categoria, CATEGORIAS, true)) {
$errores[] = 'categoría desconocida';
}
if (!is_numeric($precio) || (float) $precio < 0) {
$errores[] = 'precio inválido';
}
if ($errores !== []) {
$rechazados[] = "Línea $numero: " . implode(', ', $errores);
continue;
}
$importados[] = [
'codigo' => (int) $codigo,
'nombre' => $nombre,
'categoria' => $categoria,
'precio' => (float) $precio,
];
}
fclose($manejador);
echo count($importados) . ' importados, ' . count($rechazados) . ' rechazados.';
Cuatro decisiones que hacen la diferencia entre una importación usable y una que hay que repetir a mano:
- Se saltea el encabezado con una sola condición, en lugar de asumir que no viene.
- Se comprueba la cantidad de campos antes de desestructurar. Si una línea tiene tres,
[$a, $b, $c, $d]avisa; y si tiene cinco, el quinto se pierde sin que nadie lo note. - Se sigue después de un error, con
continue. Una línea mala no invalida las otras 199. - Se informa el número de línea, que es lo único que le sirve a quien tiene que corregir el archivo.
Y la conversión de tipos va después de validar, no antes: si $precio es "abc" y se convierte primero, se vuelve 0.0 y el error desaparece de la vista.
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 |
|---|---|
name | El nombre original, escrito por el cliente. No es confiable. |
type | El tipo MIME declarado por el navegador. Tampoco es confiable. |
tmp_name | Dónde quedó el archivo temporal en el servidor. Es la ruta que hay que usar. |
error | 0 si salió bien; un número si no, como UPLOAD_ERR_NO_FILE o UPLOAD_ERR_INI_SIZE. |
size | Tamañ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 afoto.jpgno 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_WARNING | Algo salió mal pero se puede seguir: un archivo que no existe, una variable sin definir. | No. |
E_NOTICE | Un detalle sospechoso: una clave que no está. | No. |
E_DEPRECATED | Algo que va a dejar de funcionar en una versión futura. | No. |
E_ERROR | Un error que impide continuar: llamar a una función que no existe. | Sí. |
E_PARSE | Error 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 |
|---|---|
try | El código que puede fallar. |
catch | Qué hacer si falló. Se puede repetir para distintos tipos de excepción. |
finally | Lo que se ejecuta siempre, haya error o no. Sirve para cerrar un archivo o liberar algo. |
throw | Lanza 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
- Sistema de archivos: todas las funciones, con los modos de apertura.
- Subida de archivos: la guía oficial, con los códigos de error.
- Excepciones: la referencia del lenguaje.
- Configuración de errores:
error_reporting,display_errorsylog_errors.
Resumen
| Tema | Lo que hay que retener |
|---|---|
| Rutas | Siempre con __DIR__: absolutas en ejecución, relativas al archivo al escribirlas. |
| Permisos | El usuario del servidor necesita escribir en la carpeta. Nunca chmod 777. |
| Leer | file_get_contents para archivos chicos, fgets para grandes, fgetcsv para CSV. |
| Escribir | FILE_APPEND y LOCK_EX. Comprobar que devolvió algo distinto de false. |
| Importar | Saltear el encabezado, comprobar la cantidad de campos, seguir después de un error e informar la línea. |
| Subir | enctype en el formulario, is_uploaded_file, getimagesize y nombre generado por el servidor. |
| Errores | En desarrollo se muestran; en producción se registran. E_ALL en los dos casos. |
| Excepciones | try, catch específico y finally. Lo general al final. |
| Registro | El 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.
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.