Créer framework PHP : Modèles

Les modèles représentent le coeur des données manipulées par notre framework. C'est ici que les interactions avec la BDD entrent en jeu.

Icône de calendrier MAJ en
Avancé
10 chapitres

Qu’est-ce qu’un modèle ?

Les modèles (en anglais « models ») constituent le « M » de MVC.

Les modèles représentent à la fois les données de notre application mais aussi les traitements qui seront effectués dessus.

Les frameworks reconnus comme Symfony délèguent la gestion des modèles à des bibliothèques logicielles comme par exemple Doctrine. Ces bibliothèques sont à la fois qualifiées d’ORM et de DBAL.

  • ORM (Object Relational Mapper) L’ORM est chargé de faire en sorte que nous manipulons nos données sous forme d’objets (POO). En se basant sur des classes PHP, l’ORM gère le mapping de table (en BDD) à objet et inversement.
  • DBAL (DataBase Abstraction Layer) Le DBAL est chargé d’interagir avec la base de données pour y écrire et en lire des données. En gérant les traitements à effectuer sur ces données (requêtes SQL d’insertion, modification, suppression, récupération), c’est une forme de surcouche du module PHP PDO.

Connexion à la base de données

Comme pour le fichier de routes, l’utilisateur aura ici la possibilité de renseigner les identifiants pour se connecter à sa base de données.

database.php
copié !
const DB_INFOS = [
	'host'     => '127.0.0.1',
	'port'     => '3306',
	'dbname'   => 'nom_bdd',
	'username' => 'root',
	'password' => ''
];
  • host : l’adresse de connexion à la base de données
  • port : le port du serveur de base de données
  • dbname : le nom de la base de données
  • username : le nom d’utilisateur du SGBD
  • password : le mot de passe d’utilisateur du SGBD

Entités

Qu’est-ce qu’une entité ?

Les entités représentent la structure des données manipulées au sein de l’application.

Elles se modélisent par des classes PHP.

Il peut, en fonction de votre application, s’agir de :

  • Produits (e-commerce)
  • Publications (réseau social)
  • Articles (blog)
  • Utilisateurs (intranet/extranet, réseau social, e-commerce…)
  • Etc.

L’objectif d’une entité est de nous permettre de manipuler plus aisément (via des objets), les données de notre application. Travailler avec des objets nous permet aussi de bénéficier de données très structurées sur lesquelles il est plus simple de garder le contrôle.

Ces classes appelées « entités », se situeront dans le répertoire 📁 src/Entity.

Si notre application est un blog, nous aurions donc une entité Article.

Article.php
copié !
namespace App\Entity;

class Article {

}

Une entité est déclarée avec des attributs, ainsi que ses accesseurs (getters) et mutateurs (setters) associés.

Attributs

Au sein d’une entité, les attributs seront généralement mappés sur les colonnes d’une table en base de données.

Article.php
copié !
namespace App\Entity;

class Article {

	private ?int $id;
	private ?string $title;
	private ?string $description;
	private ?string $content;

}

Selon le cas d’utilisation de nos objets, certains attributs peuvent ne pas être initialisés. On spécifiera alors toujours ces attributs comme optionnels avec le caractère ?.

Accesseurs / mutateurs

Au sein d’une entité, les accesseurs et mutateurs vont être utilisés pour manipuler les attributs privés de la classe en question (cf. principe d’encapsulation).

Article.php
copié !
namespace App\Entity;

class Article {

	private ?int $id;
	private ?string $title;
	private ?string $description;
	private ?string $content;

	public function getId(): ?int {
		return $this->id;
	}

	public function getTitle(): ?string {
		return $this->title;
	}
	public function setTitle(?string $title): void {
		$this->title = $title;
	}

	public function getDescription(): ?string {
		return $this->description;
	}
	public function setDescription(?string $description): void {
		$this->description = $description;
	}

	public function getContent(): ?string {
		return $this->content;
	}
	public function setContent(?string $content): void {
		$this->content = $content;
	}

}

Constante

Vous l’avez compris, une entité est mappée à une table en base de données. Il peut donc être intéressant de créer une constante permettant de définir pour chaque entité, le nom de la table y étant rattaché.

Article.php
copié !
namespace App\Entity;

class Article {

	private ?int $id;
	private ?string $title;
	private ?string $description;
	private ?string $content;

	const TABLE_NAME = 'articles';

}

Bien que le nom de cette table puisse être déduit du nom de l’entité (par exemple une table article pour une entité Article), cette constante confère entre autres davantage de personnalisation (lui ajouter un préfixe, mettre le terme au pluriel, etc.).

Gestionnaires (managers)

Qu’est-ce qu’un gestionnaire ?

Les gestionnaires (en anglais « managers ») constituent les services permettant de manipuler les données. Ce sont eux qui vont jouer le rôle d’interface entre nos contrôleurs et notre BDD. Ils implémenteront donc les opérations d’écriture / de lecture (CRUD) de nos entités.

On distingue généralement deux types de gestionnaires : le gestionnaire global et les gestionnaires d’entités.

Gestionnaire global

Lors de la mise en place de la mécanique des contrôleurs nous avions créé un contrôleur global puis en avons hérité depuis les contrôleurs applicatifs. Et bien, pour la gestion de notre base de données, il en sera de même.

Ce gestionnaire global implémentera des méthodes de référence qui seront ensuite partagées via une relation d’héritage. Il s’agit de l’unique classe qui interagira directement avec la BDD. Elle comportera, entre autres, le code en charge de la connexion à la base de données ainsi que le code permettant d’y exécuter des requêtes.

AbstractManager.php
copié !
namespace Plugo\Manager;

abstract class AbstractManager {

}

AsbtractManager jouant le rôle de gestionnaire global pour les modèles de notre application, il se situe donc dans le namespace Plugo\Manager.

Cette classe est déclarée avec le mot-clé abstract, cela signifie que nous ne pourrons pas l’instancier. Son rôle sera de partager des propriétés / méthodes aux classes qui en hériteront.

Méthodes internes

Méthode 1 : connexion à la BDD

Chargeons dans un premier temps cette classe d’établir la connexion à notre base de données via une méthode dédiée : connect().

AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	private function connect(): \PDO {
		$db = new \PDO(
			"mysql:host=" . DB_INFOS['host'] . ";port=" . DB_INFOS['port'] . ";dbname=" . DB_INFOS['dbname'],
			DB_INFOS['username'],
			DB_INFOS['password']
		);
		$db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
		$db->exec("SET NAMES utf8");
		return $db;
	}

}

D’abord, on va inclure le fichier de configuration de la base de données préalablement créé, afin que notre classe ait connaissance des informations de connexion contenus dans la constante DB_INFOS.

Ensuite, créons une méthode privée connect(), dont le but est de retourner la connexion à la base de données, établie via la classe PDO.

Méthode 2 : Exécution d’une requête SQL

Implémentons désormais une méthode globale executeQuery(), dont le rôle sera d’exécuter nos requêtes SQL.

AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	// ...

	private function executeQuery(string $query, array $params = []): \PDOStatement {
		$db = $this->connect();
		$stmt = $db->prepare($query);
		foreach ($params as $key => $param) $stmt->bindValue($key, $param);
		$stmt->execute();
		return $stmt;
	}

}

La méthode executeQuery() comporte 2 paramètres :

  • $query (string) : la requête SQL à exécuter (SELECT, INSERT, UPDATE, DELETE…)
  • $params (array) : un tableau de paramètres à binder si la requête contient des marqueurs :.
Méthode 3 : conversion namespace → table

Ajoutons maintenant une méthode utilitaire getTableName() dont le rôle sera de récupérer le nom de la table associé à l’entité. Cette méthode possèdera 2 scénarios d’exécution :

  • Une constante TABLE_NAME est définie dans la classe, on retourne sa valeur.
  • Aucune constante TABLE_NAME n’est définie dans la classe, on retourne le nom de la classe, en minuscules.
AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	// ...

	private function getTableName(string $class): string {
		if (defined($class . '::TABLE_NAME')) {
			$table = $class::TABLE_NAME;
		} else {
			$tmp = explode('\\', $class);
			$table = strtolower(end($tmp));
		}
		return $table;
	}

}

La méthode getTableName() comporte 1 paramètre :

  • $class (string) : le namespace d’une entité.

Si TABLE_NAME est définie dans la classe, on récupère sa valeur.

Article.php
copié !
namespace App\Entity;

class Article {

	const TABLE_NAME = 'articles';

	// ...

}

Si TABLE_NAME n’est pas définie dans la classe :

  1. D’abord, explode() éclate le namespace de la classe sur le caractère \.
  2. Ensuite, end() retourne la dernière valeur du tableau $tmp.
  3. Enfin, strtolower() retourne cette chaîne en minuscule.

Par exemple, le namespace App\Entity\Article serait converti en article.

Cette méthode s’avérera très utile par la suite.

Méthodes génériques (CRUD)

Il ne reste plus qu’à implémenter des méthodes génériques pour exécuter les différentes opérations de CRUD sur la BDD.

Si les 3 méthodes précédentes connect(), executeQuery() et getTableName() ont été déclarées avec le mot-clé private, c’est parce qu’elles ne seront utilisées que par les autres méthodes de cette classe (voir ci-après).

Les méthodes génériques de type CRUD seront quant à elles protected, afin de pouvoir être héritées.

Lecture
Lire une ressource avec readOne()

La méthode readOne() est dédiée à la récupération d’une seule ressource.

AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	// ...

	protected function readOne(string $class, array $filters): mixed {
		$query = 'SELECT * FROM ' . $this->getTableName($class) . ' WHERE ';
		foreach (array_keys($filters) as $filter) {
			$query .= $filter . " = :" . $filter;
			if ($filter != array_key_last($filters)) $query .= ' AND ';
		}
		$stmt = $this->executeQuery($query, $filters);
		$stmt->setFetchMode(\PDO::FETCH_CLASS, $class);
		return $stmt->fetch();
	}

}

setFetchMode(\PDO::FETCH_CLASS, $class) nous permet de spécifier que nous souhaitons mapper les données récupérées au sein de l’entité spécifiée par le paramètre $class.

La méthode readOne() comporte 2 paramètres :

  • $class (string) : le namespace d’une entité.
  • $filters (array) : un tableau de critères de filtre de la ressource.

Cette méthode retournera :

  • En cas de succès : un objet
  • En cas d’échec : false
Lire plusieurs ressources avec readMany()

La méthode readMany() est dédiée à la récupération de plusieurs ressources.

AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	// ...

	protected function readMany(string $class, array $filters = [], array $order = [], int $limit = null, int $offset = null): mixed {
		$query = 'SELECT * FROM ' . $this->getTableName($class);
		if (!empty($filters)) {
			$query .= ' WHERE ';
			foreach (array_keys($filters) as $filter) {
				$query .= $filter . " = :" . $filter;
				if ($filter != array_key_last($filters)) $query .= ' AND ';
			}
		}
		if (!empty($order)) {
			$query .= ' ORDER BY ';
			foreach ($order as $key => $val) {
				$query .= $key . ' ' . $val;
				if ($key != array_key_last($order)) $query .= ', ';
			}
		}
		if (isset($limit)) {
			$query .= ' LIMIT ' . $limit;
			if (isset($offset)) {
				$query .= ' OFFSET ' . $offset;
			}
		}
		$stmt = $this->executeQuery($query, $filters);
		$stmt->setFetchMode(\PDO::FETCH_CLASS, $class);
		return $stmt->fetchAll();
	}

}

setFetchMode(\PDO::FETCH_CLASS, $class) nous permet de spécifier que nous souhaitons mapper les données récupérées au sein de l’entité spécifiée par le paramètre $class.

La méthode readMany() comporte 5 paramètres :

  • $class (string) : le namespace d’une entité.
  • (optionnel) $filters (array) : un tableau de critères de filtre des ressources. Exemples : ['slug' => 'recette-gateau-chocolat'], ['draft' => true]
  • (optionnel) $order (array) : un tableau de critères de tri des ressources. Exemples : ['price' => 'ASC'], ['views' => 'DESC']
  • (optionnel) $limit (int) : un nombre limitant la quantité de ressources à récupérer.
  • (optionnel) $offset (int) : un nombre spécifiant un décalage pour la récupération de ressources (“à partir de telle ligne”).

Cette méthode retournera :

  • En cas de succès : un tableau d’objets. Un tableau d’objets issus de la même classe est aussi appelé « collection ».
  • En cas d’échec : false
Ecriture
Création d'une ressource avec create()

La méthode create() est dédiée à l’enregistrement d’une ressource au sein d’une table.

AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	// ...

	protected function create(string $class, array $fields): \PDOStatement {
		$query = "INSERT INTO " . $this->getTableName($class) . " (";
		foreach (array_keys($fields) as $field) {
			$query .= $field;
			if ($field != array_key_last($fields)) $query .= ', ';
		}
		$query .= ') VALUES (';
		foreach (array_keys($fields) as $field) {
			$query .= ':' . $field;
			if ($field != array_key_last($fields)) $query .= ', ';
		}
		$query .= ')';
		return $this->executeQuery($query, $fields);
	}

}

La méthode create() comporte 2 paramètres :

  • $class (string) : le namespace d’une entité.
  • $fields (array) : les champs à enregistrer en BD (clé-valeur). Le tableau associatif reçu dans cette variable va permettre de construire, à partir de ses clés, la requête préparée en y précisant tous les champs concernés par l’insertion.

Cette méthode retournera :

  • En cas de succès : une instance de PDOStatement
  • En cas d’échec : false
Modification d'une ressource avec update()

La méthode update() est dédiée à la modification d’une ressource au sein d’une table.

AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	// ...

	protected function update(string $class, array $fields, int $id): \PDOStatement {
		$query = "UPDATE " . $this->getTableName($class) . " SET ";
		foreach (array_keys($fields) as $field) {
			$query .= $field . " = :" . $field;
			if ($field != array_key_last($fields)) $query .= ', ';
		}
		$query .= ' WHERE id = :id';
		$fields['id'] = $id;
		return $this->executeQuery($query, $fields);
	}

}

La méthode update() comporte 3 paramètres :

  • $class (string) : le namespace d’une entité.
  • $fields (array) : les champs à modifier en BD (clé-valeur). Le tableau associatif reçu dans cette variable va permettre de construire, à partir de ses clés, la requête préparée en y précisant tous les champs concernés par l’édition.
  • $id (string) : l’identifiant de la ressource à éditer.

Cette méthode retournera :

  • En cas de succès : une instance de PDOStatement
  • En cas d’échec : false
Suppression d'une ressource avec remove()

La méthode remove() est dédiée à la suppression d’une ressource au sein d’une table.

AbstractManager.php
copié !
namespace Plugo\Manager;

require dirname(__DIR__, 2) . '/config/database.php';

abstract class AbstractManager {

	// ...

	protected function remove(string $class, int $id): \PDOStatement {
		$query = "DELETE FROM " . $this->getTableName($class) . " WHERE id = :id";
		return $this->executeQuery($query, [ 'id' => $id ]);
	}

}

La méthode remove() comporte 2 paramètres :

  • $class (string) : le namespace d’une entité
  • $id (int) : l’identifiant de la ressource à supprimer

Cette méthode retournera :

  • En cas de succès : une instance de PDOStatement
  • En cas d’échec : false

Gestionnaires d’entités

Les mécaniques globales de notre CRUD étant en place, il est temps de les rendre exploitables pour nos gestionnaires d’entités.

Chaque entité créée se verra accompagnée d’un gestionnaire, nommé EntityManager. Ces gestionnaires d’entité seront en charge d’implémenter les méthodes permettant de manipuler l’entité à laquelle ils sont rattachés.

Ce sont eux qui vont être appelés par les contrôleurs de notre application, et non directement le gestionnaire global. Ils vont constituer une couche d’abstraction qui va jouer le rôle d’intermédiaire, dans le but de simplifier les opérations effectuées sur les données, pour les utilisateurs du framework.

Ces classes appelées « managers », se situeront dans le répertoire 📁 src/Manager.

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

}

ArticleManager jouant le rôle de gestionnaire propre à une entité, il se situe donc dans le namespace App\Manager.

Il est intéressant de noter ici que notre classe hérite du gestionnaire global AbstractManager précédemment créé. De plus, via le mot-clé use, elle annonce exploiter notre entité Article.

Méthode find()

Pour une entité spécifiée, la méthode find() simplifie la récupération d’une ressource spécifique par son identifiant. Elle exploite en arrière-plan notre méthode parente readOne().

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

	public function find(int $id) {
		return $this->readOne(Article::class, [ 'id' => $id ]);
	}

}

Méthode findOneBy()

Pour une entité spécifiée, la méthode findOneBy() simplifie la récupération d’une ressource spécifique répondant à un ou plusieurs critères. Elle exploite en arrière-plan notre méthode parente readOne().

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

	public function findOneBy(array $filters) {
		return $this->readOne(Article::class, $filters);
	}

}

Article::class sera ici automatiquement traduit par la chaîne de caractère App\Entity\Article.

Méthode findAll()

Pour une entité spécifiée, la méthode findAll() simplifie la récupération de toutes les ressources. Elle exploite en arrière plan notre méthode parente readMany().

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

	public function findAll() {
		return $this->readMany(Article::class);
	}

}

Méthode findBy()

Pour une entité spécifiée, la méthode findBy() simplifie la récupération de toutes les ressources répondant à un ou plusieurs critères, de les ordonner, limiter leur nombre et décaler le curseur de sélection. Elle exploite en arrière-plan notre méthode parente readMany().

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

	public function findBy(array $filters, array $order = [], int $limit = null, int $offset = null) {
		return $this->readMany(Article::class, $filters, $order, $limit, $offset);
	}

}

Méthode add()

La méthode add() simplifie l’insertion d’une ressource pour une entité donnée. Elle exploite en arrière-plan notre méthode parente create().

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

	public function add(Article $article) {
		return $this->create(Article::class, [
				'title' => $article->getTitle(),
				'description' => $article->getDescription(),
				'content' => $article->getContent()
			]
		);
	}

}

Méthode edit()

La méthode edit() simplifie l’insertion d’une ressource pour une entité donnée. Elle exploite en arrière-plan notre méthode parente update().

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

	public function edit(Article $article) {
		return $this->update(Article::class, [
				'title' => $article->getTitle(),
				'description' => $article->getDescription(),
				'content' => $article->getContent()
			],
			$article->getId()
		);
	}

}

Méthode delete()

La méthode delete() simplifie la suppression d’une ressource pour une entité donnée. Elle exploite en arrière-plan notre méthode parente remove().

ArticleManager.php
copié !
namespace App\Manager;

use Plugo\Manager\AbstractManager;
use App\Entity\Article;

class ArticleManager extends AbstractManager {

	public function delete(Article $article) {
		return $this->remove(Article::class, $article->getId());
	}

}

Quelques exemples

Il est désormais possible d’exploiter ces entités et managers depuis nos contrôleurs, en n’oubliant bien-sûr pas de préciser dans quels namespaces ils se trouvent (avec le mot-clé use).

Voici un exemple de méthode de contrôleur dans laquelle je récupère et transmets à une vue tous les articles du blog via ArticleManager.

ArticleController.php
copié !
namespace App\Controller;

use App\Manager\ArticleManager;
use Plugo\Controller\AbstractController;

class ArticleController extends AbstractController {

	public function index() {
		$articleManager = new ArticleManager();
		return $this->renderView('article/index.php', [
			'articles' => $articleManager->findAll()
		]);
	}

}

Voici un exemple de méthode de contrôleur dans laquelle j’insère en BDD un nouvel article via ArticleManager et l’entité Article.

ArticleController.php
copié !
namespace App\Controller;

use App\Manager\ArticleManager;
use Plugo\Controller\AbstractController;

class ArticleController extends AbstractController {

	public function add() {
		if (!empty($_POST)) {
			$article = new Article();
			$articleManager = new ArticleManager();
			$article->setTitle($_POST['title']);
			$article->setDescription($_POST['description']);
			$article->setContent($_POST['content']);
			$articleManager->add($article);
			return $this->redirectToRoute('/blog');
		}
		return $this->renderView('article/add.php');
	}

}