UD4 (EXTENSIÓ): PORTAFOLIS - ACCÉS A DADES I OBJECTES DEL NAVEGADOR¶

Duració de la unitat: 17 sessions.
Encara que aquesta unitat no es desenvoluparà durant el curs 2025/2026, es deixa ací com a exemple il·lustratiu de com utilitzar mysql amb PHP, sense framework.
INTRODUCCIÓ¶
Estem en la recta final del projecte del portafolis. Al llarg de les unitats anteriors ens ha sorgit la necessitat d'anar emmagatzemant diferents dades de l'aplicació, i ho hem anat solucionant temporalment mitjançant diferents arxius, definint variables o estructures JSON.
Això ha suposat l'avantatge de tindre immediatesa en l'accés a les dades, però ha complicat altres aspectes (referència entre categories de projectes i array de projectes, emmagatzematge de l'estat login/logout, etc).
En aquesta unitat introduirem l'ús de la base de dades relacional MySQL en el nostre projecte, substituint totes les estructures de dades per les seues corresponents taules en base de dades. Per a això, utilitzarem Docker, que ens permetrà alçar un contenidor de la base de dades MySQL i comunicar-lo amb la nostra aplicació, mitjançant PDO.
AVALUACIÓ¶
El present document, juntament amb el seu corresponent butlletí d'activitats (publicat addicionalment), cobreix els següents criteris d'avaluació:
| RESULTATS D'APRENENTATGE | CRITERIS D'AVALUACIÓ |
|---|---|
| RA6. Desenvolupa aplicacions d'accés a magatzems de dades, aplicant mesures per a mantindre la seguretat i la integritat de la informació. | a) S'han analitzat les tecnologies que permeten l'accés mitjançant programació a la informació disponible en magatzems de dades. b) S'han creat aplicacions que establisquen connexions amb bases de dades. c) S'ha recuperat informació emmagatzemada en bases de dades. d) S'ha publicat en aplicacions web la informació recuperada. e) S'han utilitzat conjunts de dades per a emmagatzemar la informació. f) S'han creat aplicacions web que permeten l'actualització i l'eliminació d'informació disponible en una base de dades. g) S'han provat i documentat les aplicacions. |
PREPARACIÓ DE L'ENTORN¶
Abans de realitzar cap canvi en el projecte, fes una còpia de seguretat. Per a introduir MySQL en el nostre projecte hem de modificar el fitxer docker-compose.yml. Primer introduïm un nou servei anomenat "mysql" que utilitzarà la imatge mysql:5.7 (respecta les indentacions):
mysql:
image: mysql:5.7
container_name: docker-mysql
environment:
MYSQL_DATABASE: portfolio_db
MYSQL_USER: admin
MYSQL_PASSWORD: admin
MYSQL_ROOT_PASSWORD: admin
ports:
- "3306:3306"
restart: always
A través de les directives "environment" estem configurant els paràmetres que necessitem en la nostra base de dades. A més, estem exposant el port 3306 del contenidor al mateix port del host, i amb restart:always li indiquem a Docker que reinicie el contenidor si en algun moment deixa d'executar-se.
A més, per a fer la nostra vida més fàcil, utilitzarem phpMyAdmin per a administrar la nostra base de dades d'una forma més senzilla. Per això, introduïm el següent servei en el fitxer docker-compose.yml:
phpmyadmin:
image: phpmyadmin/phpmyadmin
ports:
- "8080:80"
restart: always
environment:
PMA_HOST: mysql
depends_on:
- mysql
Fins ací tot perfecte. Anem a alçar els contenidors mitjançant docker-compose up:

Primer anem a veure com parar aquests serveis: el que venim fent és ctrl+c, perquè es detinga l'execució interactiva, però ara, després de fer ctrl+c anem a executar un altre comandament:
docker-compose down
Aquesta és la forma correcta de parar els serveis. D'aquesta manera s'eliminen els contenidors que s'han creat amb docker-compose up.
Ara que ja sabem alçar i "baixar" els serveis de Docker Compose, anem a crear la nostra primera taula amb phpMyAdmin. Executem docker-compose up i anem a localhost:8080 (o al port on hàgem exposat aquest servei):

Introduïm les credencials que hem configurat en docker-compose.yml (admin/admin), i creem la nostra primera taula "categories":

Aparentment ha anat tot bé, hem configurat la nostra primera taula on definirem les categories dels nostres projectes.
Eixim del panell d'administració, i parem els contenidors amb docker-compose down.

Ara volem tornar a entrar i definir alguns registres per a la nostra nova taula, volem començar a connectar el nostre codi PHP amb la nova base de dades. Per a això, alcem els serveis amb docker-compose up, i accedim a phpMyAdmin:

Busquem la taula de categories, sense èxit. Què ha passat? Com és possible si en la captura de la pàgina anterior veiem que sí hem creat esta taula?
No, no ho hem somiat. Alguna cosa ha passat, i té a veure amb la mateixa naturalesa dels contenidors. Els contenidors tenen naturalesa efímera. Això vol dir que el podem arribar a destruir/reemplaçar en qualsevol moment, i la nostra aplicació no hauria de veure's afectada per això.
Però què ha passat realment per haver eliminat la configuració de la nostra taula? En configurar la nova taula, aquesta informació s'ha guardat dins del contenidor de MySQL, però en executar docker-compose down, s'ha eliminat el contenidor, i amb ell tota la informació que havíem generat.
Llavors, la següent pregunta lògica seria: existeix alguna forma de poder retenir la informació del contenidor, encara que el contenidor desaparega? La resposta és: sí, mitjançant el que anomenem volums de Docker. En realitat ja hem utilitzat un volum anteriorment, per a poder establir una relació entre la nostra carpeta src, i la ruta /var/www/html del contenidor. Aquest tipus de volum en Docker seria directoris enllaçats.
Per a MySQL utilitzarem un volum anomenat, que implica que es cree un objecte Docker nou, de tipus volum, sobre el qual es poden realitzar operacions addicionals (veure el manual de Docker bàsic per a més detalls). Aquest sol ser el tipus de volum utilitzat per a recolzar les dades d'un contenidor de base de dades. Amb tot això, modifiquem el docker-compose.yml per a acomodar els canvis:
mysql:
image: mysql:5.7
container_name: docker-mysql
environment:
MYSQL_DATABASE: portfolio_db
MYSQL_USER: admin
MYSQL_PASSWORD: admin
MYSQL_ROOT_PASSWORD: admin
ports:
- "3306:3306"
restart: always
volumes:
- dbdata:/var/lib/mysql
volumes:
dbdata:
Alcem els serveis. Podem veure en la consola que s'ha creat el volum dbdata:

Una vegada alçats, obrim una altra terminal i introduïm:
docker volume ls
Anem a realitzar els següents passos:
- Tornem a phpMyAdmin, definim la taula de categories de nou.
- Executem docker-compose down.
- Executem docker-compose up.
- Entrem de nou a phpMyAdmin, i comprovem que la taula no s'ha esborrat aquesta vegada.
Ja sembla que ho tenim tot. Anem a organitzar tot el que concerneix a la base de dades en una nova carpeta anomenada mysql. En primer lloc creem el fitxer db_credenciales.php amb el següent contingut:
<?php
$servername = "mysql:3306";
$username = "admin";
$password = "admin";
$db = "portfolio_db";
?>
Ara anem a modificar index.php, que quedarà amb el següent contingut (inicial):
<?php include("templates/header.php"); ?>
<?php include("mysql/db_credenciales.php"); ?>
<?php
try {
$conn = new PDO("mysql:host=$servername;dbname=$db", $username, $password);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "Connexió exitosa";
} catch(PDOException $e) {
echo "La connexió ha fallat: " . $e->getMessage();
}
?>
<div class="container mb-5">
<div class="row">
</div>
</div>
<?php include("templates/footer.php"); ?>
L'explicació d'aquesta connexió a la base de dades, la trobem en este enllaç (exemple PDO).
En visitar la pàgina principal del portafolis, veiem el següent error:

La prova no ha funcionat. L'error ens avisa que no es troba el driver a MySQL. Com és possible això? La imatge que estem utilitzant "php:7.4-apache", ¿no conté ja tot el necessari? Aparentment no. Necessitem que el nostre contenidor tinga habilitada l'extensió PDO de PHP, com per exemple es contempla en aquesta consulta. Però, com ho farem en el contenidor si hem dit que té caràcter efímer i en qualsevol moment es pot perdre qualsevol canvi que fem?
Això té fàcil solució en el món Docker: només hem de crear una nova imatge a partir de la imatge "php:7.4-apache". Però, com? a través del que s'anomena Dockerfile.
Un fitxer Dockerfile defineix una imatge, el que vol dir que conté les instruccions per a construir (en anglès build) un contenidor. Una imatge es pot construir a partir d'una altra imatge, anem a fer-ho. Crea un fitxer anomenat Dockerfile a la mateixa altura que el docker-compose.yml, amb el següent contingut:
FROM php:7.4-apache
RUN docker-php-ext-install pdo pdo_mysql
web:
#image: php:7.4-apache
build: .
ports:
- "80:80"
volumes:
- ./src:/var/www/html
Fem docker-compose down, i després "docker-compose up --build" (amb el paràmetre build es reconstrueix la imatge), i veiem que s'executa la nova instrucció que habilita l'extensió PDO:

Anem a tornar a provar l'aplicació:

Ja funciona l'accés a la base de dades, podem començar a utilitzar-la en la nostra aplicació. Pel camí hem aprés moltes coses sobre Docker:
- Volums anomenats i persistència de dades.
- DNS entre els contenidors Docker.
- Dependències entre contenidors, i reiniciat.
- Configuració d'un Docker file.
Planteja't aquestes últimes qüestions:
- Els serveis "web" i "phpmyadmin" necessiten comunicar-se amb mysql, però: ¿el servei web necessita comunicar-se amb phpmyadmin per a realitzar la seua funció, o viceversa? La resposta és que no. Per tant, hauríem de tallar la comunicació entre ells per a aïllar-los entre si i evitar qualsevol problema de seguretat. Això, en Docker, s'aconsegueix mitjançant la definició de xarxes entre els propis serveis.
- Estem utilitzant un mateix contenidor basat en la imatge php:7.4-apache, que conté PHP 7.4 i Apache. ¿No seria més convenient separar aquests dos components en diferents contenidors? ¿quins avantatges obtindríem a canvi?
ACCÉS A DADES¶
Teoria¶
Per a la implementació de l'accés a dades del nostre projecte de portafolis ens basarem en la secció MySQL Database del manual de PHP de W3CSchools. En concret, utilitzarem PDO (PHP Data Objects), que és l'opció més utilitzada actualment, i que utilitza orientació a objectes. Les diferents opcions per a gestionar l'accés a una base de dades des de PHP es discuteixen en aquest enllaç.
Per a tindre una referència de la sintaxi bàsica d'orientació a objectes en PHP, pots basar-te en aquest enllaç, encara que no aprofundirem tant en detall.
Projecte¶
En aquesta última fase del projecte anem a centralitzar totes les nostres dades en una base de dades i alimentar la nostra aplicació amb les taules i operacions corresponents. Per a això, primer definirem les taules que necessitem en la nostra base de dades, les relacions entre elles, per a passar a continuació a manejar els projectes de la aplicació fent ús de la base de dades.
Disseny de base de dades¶
Aquesta és una proposta de les taules que podríem necessitar, segons les dades que hem anat manejant fins ara:
| Entitat | Camps |
|---|---|
| usuari | id: clau primària, requerit e-mail: tipus text, requerit, valor únic contrasenya: text, requerit Nom i cognoms: text, requerit DNI: text, requerit, segons expressió regular Actiu: booleà, vertader per defecte Admin: booleà, fals per defecte |
| sessio | id: clau primària, requerit usuari: identificador d'usuari, valor únic Claus foranes: Usuari |
| projecte | id: clau primària, requerit Títol: text, requerit, valor únic Data: tipus data, requerit Descripció: text, requerit Imatge: text, opcional Claus foranes: No té |
| categoria | id: clau primària, requerit Nom: text, requerit Claus foranes: No té |
| categoria_projecte | id: clau primària, requerit Projecte: identificador, requerit Categoria: identificador, requerit Claus foranes: Projecte, Categoria |
| contacte | id: clau primària, requerit Nom i cognoms: text, requerit e-mail: tipus e-mail, requerit Telèfon: format telèfon, requerit Particular/empresa: text, requerit Missatge: text, requerit Arxiu: text, opcional Claus foranes: No té |
ACTIVITAT: analitzem i dissenyem les taules en la BBDD, en classe. És necessari guardar tots els scripts de creació i inserció, per si necessitàrem re-crear la BBDD en un altre entorn.
Llistat de projectes¶
Abans de res, anem a pensar quina és la millor forma d'estructurar el codi que generarem quan interactuem amb la base de dades.
Per a això, definirem un fitxer PHP dins de la carpeta mysql, per cada una de les taules sobre la qual necessitem realitzar operacions. Per exemple, per a la taula projectes, el nom del fitxer serà "projecte_sql.php", i dins definirem tantes variables com sentències preparades necessitem.
Serà convenient seguir una nomenclatura per a no confondre'ns amb les variables. Per exemple:
- Una variable que recupere tots els projectes la podríem anomenar $projecte_select_all
- Una altra variable que ens servisca per a recuperar un determinat projecte la podríem anomenar $projecte_detail
- Per a actualitzar un projecte podríem utilitzar $projecte_update.
I així successivament, per a poder identificar cada operació amb una variable.
Anirem afegint aquestes variables conforme les anem a necessitar.
En aquest exemple guiat anem a implementar una versió simplificada del llistat de projectes. Dit això, anem a crear un fitxer de consultes per a la taula de projectes, amb el següent contingut:

L'arxiu db_credenciales.php, ja l'hem creat amb anterioritat.
Creem la taula en la base de dades:

I creem alguns exemples manualment:

Fem una còpia de la base de dades, per si hem d' exportar el projecte a un altre entorn.
Abans de començar a modificar el codi de l'aplicació, recorda fer una còpia de seguretat de la versió anterior. Després d'algunes modificacions, el codi bàsic de index.php queda de la següent manera:
<?php include("templates/header.php"); ?>
<?php include("mysql/db_credenciales.php"); ?>
<?php include("mysql/projecte_sql.php"); ?>
<?php
try {
$conn = new PDO("mysql:host=$servername;dbname=$db", $username, $password);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch(PDOException $e) {
echo "La connexió ha fallat: " . $e->getMessage();
}
$consulta = $conn->prepare($projecte_select_all);
$resultat = $consulta->setFetchMode(PDO::FETCH_ASSOC);
$consulta->execute();
$projectes = $consulta->fetchAll();
?>
<div class="container mb-5">
<div class="row">
<?php foreach($projectes as $projecte): ?>
<div class="col-sm-3">
<a href="#" class="p-5">
<div class="card">
<img class="card-img-top" src="<?php echo $projecte['imatge']?>" alt="<?php echo utf8_encode($projecte['titol'])?>">
<div class="card-body">
<h5 class="card-title"><?php echo utf8_encode($projecte['titol']) ?></h5>
<p class="card-text"><?php echo utf8_encode($projecte['descripcio'])?></p>
</div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php include("templates/footer.php"); ?>
<?php $conn = null; ?>
L'explicació de les línies canviades la trobem en aquest enllaç, on es detalla tot el referent a la recuperació de registres amb PDO. Ho revisem en classe, i revisem també el propòsit del mètode setFetchMode.
En assignar valors a l'array de projectes, utilitzem el mètode fetchAll de l'objecte emmagatzemat en $consulta. Podríem operar directament amb l'objecte consulta si utilitzàrem el seu mètode fetch, però hauríem d'envoltar-lo en un bucle per a poder recórrer tots els registres. Els diferents exemples de l'enllaç anterior utilitzen fetch o fetchAll depenent de si necessitem emmagatzemar els registres en un array intermedi, o els imprimim directament per pantalla.
Fixa't que, en acabar l'accés a la BBDD, tanquem la connexió igualant la variable $conn a null.
Llistat de categories per projecte¶
En les activitats d'unitats anteriors hem categoritzat els nostres projectes i hem anat visualitzant en les diferents parts de l'aplicació les categories de cada un d'ells. En particular en: el llistat de projectes de la pàgina principal, i en la fitxa del propi projecte.
En aquest apartat anem a substituir la lectura que fèiem de dades.php on es trobaven les dades, per la lectura en la base de dades, des de index.php.
Per a això, primer ens assegurem que tenim creades les taules "categoria" i "categoria_projecte":


I els donem valors de prova a les dues taules:


Ja hem cobert una part. Ara anem a passar a implementar el codi que ens torne un array amb totes les categories associades a un determinat projecte. Per a això, necessitem implementar una lògica que, a partir de l'identificador del projecte, ens torne un array amb tots els noms de les seues categories, però encara no sabem parametritzar les consultes a la BBDD.
Com podem fer per a afegir una clàusula WHERE a una SQL en funció del valor d'una variable? Per a això, PHP ens proporciona l'ús de les sentències preparades. Revisem aquest enllaç, i veiem que es tracta d'unir (bind, en anglès) una part de la sentència SQL amb una o més variables, mitjançant el signe d'interrogació.
Bé, anem a començar a utilitzar les sentències preparades en el nostre codi. Però volem fer-ho de forma modular, per a reutilitzar codi i que la seua lectura siga més clara. Donem els següents passos:
- Creem un nou fitxer categoria_sql.php, on registrarem totes les sentències SQL i la interacció amb la BBDD:

-
Dins d'aquest fitxer, creem una variable que contindrà la sentència preparada, quedant de la forma:

-
Ara podríem utilitzar aquesta variable des de index.php, com vam fer per als projectes (des de index.php), però podem reutilitzar més codi creant una funció on s'executen els mètodes prepare, bind, execute, i deixar només la crida a aquesta funció des de index.php. Per tant, inserim la funció get_categories_per_projecte en categoria_sql.php, quedant així:

En aquesta lògica no hem utilitzat el mètode bind perquè PHP pren l'ordre en l'array que es passa a execute, per a substituir els interrogants de la sentència SQL. Si, per alguna raó, volem un codi més llegible i ordenat, podem realitzar els següents canvis:

- Ara només hem de modificar index.php i afegir un bucle anidat que imprima les categories de cada projecte com a enllaços (veurem el per què en les activitats), quedant de la següent manera:
<?php include("templates/header.php"); ?>
<?php include("mysql/db_credenciales.php"); ?>
<?php include("mysql/projecte_sql.php"); ?>
<?php include("mysql/categoria_sql.php"); ?>
<?php
try {
$conn = new PDO("mysql:host=$servername;dbname=$db", $username, $password);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch(PDOException $e) {
echo "La connexió ha fallat: " . $e->getMessage();
}
$consulta = $conn->prepare($projecte_select_all);
$resultat = $consulta->setFetchMode(PDO::FETCH_ASSOC);
$consulta->execute();
$projectes = $consulta->fetchAll();
?>
<div class="container mb-5">
<div class="row">
<?php foreach($projectes as $projecte): ?>
<div class="col-sm-3">
<a href="#" class="p-5">
<div class="card">
<img class="card-img-top" src="<?php echo $projecte['imatge']?>" alt="<?php echo utf8_encode($projecte['titol'])?>">
<div class="card-body">
<h5 class="card-title"><?php echo utf8_encode($projecte['titol']) ?></h5>
<p class="card-text"><?php echo utf8_encode($projecte['descripcio'])?></p>
</div>
</div>
</a>
<?php foreach(get_categories_per_projecte($conn, $projecte['id']) as $categoria): ?>
<a href="#" class="badge bg-secondary"><?php echo utf8_encode($categoria['nom']) ?></a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php include("templates/footer.php"); ?>
<?php $conn = null; ?>
La nostra pàgina principal queda de la següent manera:

ACTIVITAT DE CLASSE: refactoritzem projecte_sql.php i index.php per a fer el mateix que hem fet amb les categories.
En aquest apartat anterior ens queda un petit detall, i és el pas previ a poder navegar, des de index.php a la fitxa del projecte. Es tracta de l'atribut href de index.php. Anem a canviar-lo per a passar per paràmetre l'id del projecte a projecte.php, de la forma:
<a href="projecte.php?id=<?php echo $projecte['id']?>" class="p-5">
Fitxa de projecte¶
Una vegada tenim llest index.php i hem configurat correctament els enllaços a la fitxa del projecte. Ara hem d'acomodar projecte.php a la nova font de dades, i prendre el paràmetre id de la URL per a recuperar les dades del projecte consultat. El codi és el següent:
projecte_sql.php
Afegim la següent funció, que recupera les dades d'un projecte, i a més comprova que es recupera exactament un sol registre, i si no és així es llança una excepció:
function get_projecte_detail($conn, $projecte_id){
$projecte_select_detail = "SELECT * FROM projecte WHERE id = :proy_id";
$consulta = $conn->prepare($projecte_select_detail);
$consulta->setFetchMode(PDO::FETCH_ASSOC);
$consulta->bindParam(":proy_id", $projecte_id);
$isOk = $consulta->execute();
if ($consulta -> rowCount() == 0){
trigger_error("No s'ha trobat l'ID de projecte");
}
if ($consulta -> rowCount() > 1){
trigger_error("S'ha recuperat més d'un registre");
}
return $consulta->fetch();
}
Si la clau primària està ben configurada en la BBDD, no seria necessària la segona comprovació perquè la SQL filtra per ID, encara que s'ha inclòs a mode il·lustratiu.
Els missatges d'error que es llancen no són amigables, seria més correcte mostrar missatges d'error significatius per a l'usuari, en forma de alertes i/o redireccionant a una altra pàgina.
Cal fer notar que ací hem utilitzat fetch per a bolcar un sol registre, havent abans comprovat que només s'ha recuperat un de la BBDD.
MAI MAI MAI utilitzes LIMIT 1 en una sentència SQL, si se suposa que la consulta només t'ha de tornar un registre. Utilitzar LIMIT 1 quan la consulta ha de recuperar només 1 registre pot enmascarar errors en el codi que, moltes vegades, són difícils de depurar. Millor sempre comprova quants registres ha tornat la consulta, i tracta qualsevol error des del punt de vista d'usabilitat.
LIMIT 1 hauries d'utilitzar-lo NOMÉS quan vulgues el primer dels possibles registres en una consulta en la qual saps prèviament que pot tornar múltiples registres.
projecte.php
<?php
$projecte_id = $_GET['id'];
if (is_null($projecte_id)){
header("Location: index.php");
exit();
}
include("templates/header.php");
include("mysql/db_credenciales.php");
include("mysql/projecte_sql.php");
include("mysql/categoria_sql.php");
try {
$conn = new PDO("mysql:host=$servername;dbname=$db", $username, $password);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch(PDOException $e) {
echo "La connexió ha fallat: " . $e->getMessage();
}
$projecte = get_projecte_detail($conn, $projecte_id);
?>
<div class="container">
<h2><?php echo utf8_encode($projecte['titol']) ?></h2>
<span>Categories: </span>
<?php foreach(get_categories_per_projecte($conn, $projecte['id']) as $categoria): ?>
<a href="#" class="badge bg-secondary"><?php echo utf8_encode($categoria['nom'])?></a>
<?php endforeach; ?>
<br> <br>
<div class="row">
<div class="col-sm">
<img src="<?php echo $projecte['imatge'] ?>" alt="<?php echo utf8_encode($projecte['titol']) ?>" class="img-fluid rounded">
<br>
</div>
<div class="col-sm">
<?php echo utf8_encode($projecte['descripcio']) ?>
</div>
</div>
</div>
<?php include("templates/footer.php"); ?>
<?php $conn = null; ?>
Comencem per recuperar el paràmetre, cosa que ja sabem fer per la UD3. Si no existeix paràmetre en la URL (manipulació de l'usuari), hauríem de mostrar un missatge d'error, o redireccionar cap a una altra pàgina. En aquest cas s'ha redireccionat a index.php. S'utilitza per això el mètode header, que ha d'invocar-se abans que es carregue cap altre element de la pàgina, per això posem els include després.
En les activitats d'aquesta unitat acabarem de completar el projecte de portafolis.
Revisió de l'exemple guiat¶
El criteri d'avaluació R6.a) es donarà per superat en fer l'exercici guiat. Com a activitat, es revisarà en classe el treball realitzat durant les sessions del present document.