Requête AJAX onChange dans PrestaShop : select dynamique en cascade
Implémentez des selects en cascade avec AJAX dans PrestaShop 8.x. Code complet : contrôleur, JavaScript et template Smarty pour un filtrage dynamique.
En bref : Implémentation complète de selects en cascade avec AJAX dans PrestaShop : contrôleur AJAX avec ajaxRender(), exposition de l'URL via Smarty, et gestion JavaScript avec cache client et feedback visuel. Compatible PrestaShop 1.7 et 8.x.
Problématique : rafraîchir dynamiquement un champ selon la sélection utilisateur
Un besoin récurrent dans le développement PrestaShop : afficher un second dont les options dépendent de la valeur choisie dans un premier champ. C'est le pattern classique des selects en cascade — par exemple, sélectionner une catégorie parente puis charger dynamiquement ses sous-catégories.
La difficulté réside dans l'articulation entre le template Smarty (côté serveur), le JavaScript (côté client) et le contrôleur AJAX du module. Voyons comment assembler ces trois briques proprement.
Architecture de la solution
Le flux est le suivant :
- L'utilisateur change la valeur du premier `
- Un événement `change` déclenche une requête AJAX vers le contrôleur du module
- Le contrôleur interroge la base de données et retourne les données en JSON
- Le JavaScript met à jour le second `
- Toujours caster les entrées utilisateur avec `(int)` pour prévenir les injections SQL
- Utiliser `$this->ajaxRender()` plutôt que `die(json_encode(...))` — c'est la méthode officielle PrestaShop
- Retourner une structure JSON cohérente avec un flag `success`
- Définir `public $ajax = true` pour désactiver le rendu du layout complet
- **jQuery est toujours présent** — pas besoin de l'inclure
- Utilisez `AdminController` au lieu de `ModuleFrontController`
- Le token de sécurité est obligatoire : ajoutez `token: adminToken` dans vos données AJAX
- Préférez `AdminModuleAjaxController` pour les routes AJAX back-office
Étape 1 : déclarer les assets JavaScript dans le contrôleur
La méthode setMedia() du contrôleur front est le point d'entrée pour charger vos scripts. C'est la bonne pratique PrestaShop — elle garantit que jQuery est déjà disponible et que vos fichiers sont correctement mis en file d'attente.
<?php
// modules/monmodule/controllers/front/display.php
class MonModuleDisplayModuleFrontController extends ModuleFrontController
{
public function setMedia()
{
parent::setMedia();
// jQuery est déjà chargé par PrestaShop en front — inutile de le ré-inclure
// En back-office également, jQuery est natif
$this->addJS($this->module->getPathUri() . 'views/js/cascade-select.js');
$this->addCSS($this->module->getPathUri() . 'views/css/cascade-select.css');
}
}
Point important : jQuery est inclus nativement par PrestaShop, tant en front-office qu'en back-office. N'incluez jamais une seconde copie de jQuery — cela provoquerait des conflits et des comportements erratiques.
Sur PrestaShop 8.x, _MODULE_DIR_ reste fonctionnel, mais $this->module->getPathUri() est préférable car il est contextualisé à l'instance du module.
Étape 2 : exposer l'URL AJAX dans le template Smarty
Le JavaScript côté client a besoin de connaître l'URL du contrôleur AJAX. La bonne pratique consiste à la générer côté serveur via Smarty et à la transmettre au JS :
{* views/templates/front/javascript.tpl *}
<script type="text/javascript">
var ajaxEndpoint = "{$link->getModuleLink('monmodule', 'ajax', [], true)|escape:'javascript':'UTF-8'}";
</script>
Le quatrième paramètre true de getModuleLink() force le protocole SSL. L'échappement escape:'javascript' prévient les injections XSS.
Sur PrestaShop 8.x, vous pouvez aussi utiliser l'approche par data-attribute sur un élément HTML, plus propre et compatible CSP :
<div id="cascade-config"
data-ajax-url="{$link->getModuleLink('monmodule', 'ajax', [], true)|escape:'htmlall':'UTF-8'}"
style="display:none;"></div>
const ajaxEndpoint = document.getElementById('cascade-config').dataset.ajaxUrl;
Étape 3 : créer le contrôleur AJAX du module
Le contrôleur AJAX reçoit la requête POST, interroge la base et retourne du JSON :
<?php
// modules/monmodule/controllers/front/ajax.php
class MonModuleAjaxModuleFrontController extends ModuleFrontController
{
/** @var bool Désactive le rendu de la page complète */
public $ajax = true;
public function initContent()
{
parent::initContent();
$action = Tools::getValue('action');
if ($action === 'getSubcategories') {
$this->getSubcategories();
}
}
private function getSubcategories(): void
{
$idCategory = (int) Tools::getValue('id_category');
$idLang = (int) $this->context->language->id;
if ($idCategory <= 0) {
$this->ajaxRender(json_encode([
'success' => false,
'message' => 'Catégorie invalide',
]));
return;
}
// Récupérer les sous-catégories
$subcategories = Category::getChildren(
$idCategory,
$idLang,
true // active uniquement
);
$result = array_map(function ($cat) {
return [
'id' => (int) $cat['id_category'],
'name' => $cat['name'],
];
}, $subcategories);
$this->ajaxRender(json_encode([
'success' => true,
'subcategories' => $result,
]));
}
}
Bonnes pratiques :
Étape 4 : le JavaScript — événement onChange et requête AJAX
Voici le script complet qui orchestre les selects en cascade :
// views/js/cascade-select.js
$(document).ready(function () {
// Sélection de la catégorie parente
$(document).on('change', '.select-category-parent', function (e) {
e.preventDefault();
var $parentSelect = $(this);
var idCategory = $parentSelect.val();
var productId = $parentSelect.data('product-id');
// Référence vers le select enfant
var $childSelect = $('#select-subcategory-' + productId);
var $childLabel = $('#label-subcategory-' + productId);
// Masquer et réinitialiser le select enfant
$childSelect.hide().empty();
$childLabel.hide();
if (!idCategory || idCategory === '0') {
return;
}
$.ajax({
type: 'POST',
url: ajaxEndpoint,
dataType: 'json',
cache: false,
data: {
action: 'getSubcategories',
id_category: idCategory,
ajax: 1
},
beforeSend: function () {
$parentSelect.prop('disabled', true);
// Optionnel : afficher un indicateur de chargement
$childSelect.html('<option>Chargement...</option>').show();
},
success: function (response) {
if (response.success && response.subcategories.length > 0) {
$childSelect.empty();
$childSelect.append(
'<option value="">-- Choisir une sous-catégorie --</option>'
);
$.each(response.subcategories, function (index, cat) {
$childSelect.append(
'<option value="' + cat.id + '">' + cat.name + '</option>'
);
});
$childSelect.show();
$childLabel.show();
} else {
$childSelect.hide();
$childLabel.hide();
}
},
error: function (xhr, status, error) {
console.error('Erreur AJAX:', status, error);
$childSelect.hide();
},
complete: function () {
$parentSelect.prop('disabled', false);
}
});
});
});
Étape 5 : le template Smarty
Regroupez vos selects dans un template dédié pour maintenir une séparation claire entre logique et présentation :
{* views/templates/front/cascade-select.tpl *}
<div class="cascade-select-wrapper">
<div class="form-group">
<label for="select-cat-parent-{$id_product}">
{l s='Catégorie principale' mod='monmodule'}
</label>
<select class="form-control select-category-parent"
id="select-cat-parent-{$id_product}"
data-product-id="{$id_product}">
<option value="">{l s='-- Sélectionner --' mod='monmodule'}</option>
{foreach from=$categories item=category}
<option value="{$category.id_category}">
{$category.name|escape:'html':'UTF-8'}
</option>
{/foreach}
</select>
</div>
<div class="form-group">
<label id="label-subcategory-{$id_product}"
for="select-subcategory-{$id_product}"
style="display:none;">
{l s='Sous-catégorie' mod='monmodule'}
</label>
<select class="form-control"
id="select-subcategory-{$id_product}"
style="display:none;">
</select>
</div>
</div>
Gestion du back-office : différences à connaître
Si vous implémentez des selects en cascade dans un module back-office, quelques points changent :
// En back-office, ajoutez le token dans votre template
var adminToken = '{$token|escape:"javascript":"UTF-8"}';
Sécurité et performance
Validation côté serveur
Ne faites jamais confiance aux données reçues en AJAX. Validez systématiquement :
// Validation stricte dans le contrôleur
$idCategory = (int) Tools::getValue('id_category');
if ($idCategory <= 0 || !Validate::isUnsignedId($idCategory)) {
$this->ajaxRender(json_encode(['success' => false]));
return;
}
// Vérifier que la catégorie existe et est active
$category = new Category($idCategory, $this->context->language->id);
if (!Validate::isLoadedObject($category) || !$category->active) {
$this->ajaxRender(json_encode(['success' => false]));
return;
}
Mise en cache côté client
Pour éviter des requêtes répétitives (l'utilisateur navigue entre les catégories), implémentez un cache JavaScript simple :
var subcategoryCache = {};
// Dans le callback change, avant l'appel AJAX :
if (subcategoryCache[idCategory]) {
renderSubcategories(subcategoryCache[idCategory]);
return;
}
// Dans le success AJAX :
subcategoryCache[idCategory] = response.subcategories;
Migration vers PrestaShop 8.x
Sur PrestaShop 8.x, l'architecture Symfony est plus présente. Pour les modules modernes, vous pouvez utiliser les routes Symfony au lieu des ModuleFrontController classiques :
# modules/monmodule/config/routes.yml
monmodule_ajax_subcategories:
path: /module/monmodule/subcategories
methods: [POST]
defaults:
_controller: 'PrestaShop\Module\MonModule\Controller\SubcategoryController::getSubcategories'
Cependant, l'approche classique avec ModuleFrontController reste parfaitement fonctionnelle et rétrocompatible sur PrestaShop 8.x. C'est souvent le choix le plus pragmatique si votre module doit aussi tourner sur 1.7.
Questions fréquentes
Tout ce que vous devez savoir sur ce sujet.
Un projet PrestaShop ?
Discutons-en directement.
193 projets livrés
Lire sur le blog

Alexandre Carette
Expert PrestaShop & Architecture E-commerce
Développeur PrestaShop depuis 2014, 193 projets livrés. Je conçois des architectures headless Nuxt + PrestaShop et des outils d'automatisation IA pour les e-commerçants.