How to Use Symfony HTTP Client in PrestaShop 9
PrestaShop 9 marks a shift in architecture by officially removing Guzzle from its core dependencies.
For years, Guzzle was the standard for HTTP communication in the PHP ecosystem. However, as PrestaShop moves on to Symfony 6.4 LTS, the platform now standardizes on the native Symfony HTTP Client.
While shifting away from a familiar tool might feel disruptive at first.
It is actually a strategic opportunity to modernize your module codebase, making it leaner, faster, and deeply integrated with Symfony’s service container.
In this blog, we will build a complete, installable module from scratch that demonstrates exactly how to integrate external APIs the “PrestaShop 9 way” using Dependency Injection and Services.
Architecture Shift: Procedural → Service-Oriented
The most important conceptual change in PrestaShop 9 is moving from tightly coupled, procedural code inside hooks to clean service-oriented architecture powered by Dependency Injection.
Before PrestaShop 9:
In previous versions, it was common to instantiate HTTP clients directly inside hook methods or controllers. This made the code hard to test, reuse, or maintain:
// Old approach - Business logic, HTTP calls, and error handling all mixed together
public function hookActionValidateOrder(array $params): void
{
$client = new GuzzleHttp\Client([
'base_uri' => 'https://api.shipping.com',
'timeout' => 10,
]);
$response = $client->post('/shipments', [
'json' => ['order_id' => $params['id_order']],
]);
$data = json_decode($response->getBody(), true);
// ... process $data
}
PrestaShop 9:
PrestaShop 9 embraces the Service Container pattern. Your hook method or controllers should do one thing: delegate to a service. HTTP logic lives in a dedicated, injectable, testable service class.
// Good: The hook delegates to a clean service
public function hookActionValidateOrder(array $params): void
{
$container = SymfonyContainer::getInstance();
$shippingService = $container->get(ShippingApiService::class);
try {
$shipment = $shippingService->createShipment($params['id_order']);
// Clean business logic only
} catch (ShippingException $e) {
$this->handleShippingError($e);
}
}
Building the Module Step by Step
Let’s build wkdailyquote a complete, installable module that fetches a random quote from an external API and displays it on the PrestaShop dashboard.
Following the full PS 9 architecture: DI, services, DTOs, caching, and error handling.
Module Location: Create a folder named wkdailyquote inside your PrestaShop /modules/ directory before starting.
Step 1: Define Composer Autoloading
{
"name": "wk/wkdailyquote",
"type": "prestashop-module",
"require": {
"php": ">=8.1"
},
"autoload": {
"psr-4": {
"WkDailyQuote\\": "src/"
}
},
"config": {
"prepend-autoloader": false
}
}
Now, navigate to /modules/wkdailyquote/ in your terminal and run:
composer dump-autoload
Step 2: Create the Data Transfer Object (DTO)
A DTO provides a type-safe, validated data structure for the API response. Instead of working with raw arrays throughout your codebase, you pass strongly-typed objects.
File: /modules/wkdailyquote/src/DTO/Quote.php
<?php
namespace WkDailyQuote\DTO;
class Quote
{
public function __construct(
private int $id,
private string $quote,
private string $author
) {
if (empty($quote)) {
throw new \InvalidArgumentException('Quote cannot be empty');
}
if (empty($author)) {
throw new \InvalidArgumentException('Author cannot be empty');
}
}
public static function fromArray(array $data): self
{
if (!isset($data['id'], $data['quote'], $data['author'])) {
throw new \InvalidArgumentException('Invalid API response structure');
}
return new self((int)$data['id'], $data['quote'], $data['author']);
}
public function getId(): int
{
return $this->id;
}
public function getQuote(): string
{
return $this->quote;
}
public function getAuthor(): string
{
return $this->author;
}
public function toArray(): array
{
return ['id' => $this->id, 'quote' => $this->quote, 'author' => $this->author];
}
}
Step 3: Create Custom Exception Classes
Instead of catching a generic \Exception everywhere, create your own API-specific exception class (or small exception hierarchy).
Using named factory methods (like ApiException::networkError() or ApiException::invalidResponse()) also makes your throw statements clearer and easier to understand.
File: /modules/wkdailyquote/src/Exception/ApiException.php
<?php
namespace WkDailyQuote\Exception;
class ApiException extends \Exception
{
public static function networkError(string $message, ?\Throwable $prev = null): self
{
return new self("Network error: $message", 0, $prev);
}
public static function httpError(int $statusCode, string $url): self
{
return new self("HTTP $statusCode error when accessing $url");
}
public static function invalidResponse(string $message): self
{
return new self("Invalid API response: $message");
}
}
Step 4: Build the Cache Manager Service
Calling an external API on every page load is slow and wastes rate limit quota. The QuoteCacheManager wraps PrestaShop’s built-in cache system with a clean interface.
File: /modules/wkdailyquote/src/Service/QuoteCacheManager.php
<?php
namespace WkDailyQuote\Service;
use WkDailyQuote\DTO\Quote;
class QuoteCacheManager
{
private const CACHE_KEY = 'wkdailyquote_current';
private const CACHE_TTL = 3600;
public function get(): ?Quote
{
$cached = \Cache::getInstance()->get(self::CACHE_KEY);
return $cached ? Quote::fromArray($cached) : null;
}
public function set(Quote $quote): void
{
\Cache::getInstance()->set(self::CACHE_KEY, $quote->toArray(), self::CACHE_TTL);
}
public function clear(): void
{
\Cache::getInstance()->delete(self::CACHE_KEY);
}
}
Step 5: Build the API Service (The Heart of the Module)
Here Symfony HTTP Client is actually used. The QuoteFetcher service receives the client via constructor injection, it never instantiates it directly. This is the key difference from the old approach.
Notice how Symfony’s $response->toArray() handles JSON parsing automatically, and how each error type maps cleanly to a specific exception class via Symfony’s exception hierarchy.
File: /modules/wkdailyquote/src/Service/QuoteFetcher.php
<?php
namespace WkDailyQuote\Service;
use WkDailyQuote\DTO\Quote;
use WkDailyQuote\Exception\ApiException;
use WkDailyQuote\Service\QuoteCacheManager;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class QuoteFetcher
{
private const API_URL = 'https://dummyjson.com/quotes/random';
private const TIMEOUT = 5;
private const MAX_RETRIES = 2;
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly QuoteCacheManager $cacheManager
) {
}
public function getRandomQuote(): ?Quote
{
if ($cached = $this->cacheManager->get()) {
return $cached;
}
try {
$quote = $this->fetchWithRetry();
$this->cacheManager->set($quote);
return $quote;
} catch (ApiException $e) {
\PrestaShopLogger::addLog("[WkDailyQuote] {$e->getMessage()}", 3);
return null;
}
}
private function fetchWithRetry(): Quote
{
for ($attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++) {
try {
return $this->makeRequest();
} catch (ApiException $e) {
if ($attempt < self::MAX_RETRIES) {
usleep(pow(2, $attempt) * 100000);
}
$last = $e;
}
}
throw $last;
}
private function makeRequest(): Quote
{
try {
$response = $this->httpClient->request('GET', self::API_URL, [
'timeout' => self::TIMEOUT,
'headers' => ['Accept' => 'application/json'],
]);
if ($response->getStatusCode() !== 200) {
throw ApiException::httpError($response->getStatusCode(), self::API_URL);
}
return Quote::fromArray($response->toArray());
} catch (TransportExceptionInterface $e) {
throw ApiException::networkError($e->getMessage(), $e);
} catch (\InvalidArgumentException $e) {
throw ApiException::invalidResponse($e->getMessage());
}
}
}
Step 6: Configure Dependency Injection
This YAML file is where the magic happens. It tells Symfony’s container how to wire everything together.
The container sees that QuoteFetcher needs an HttpClientInterface, so it automatically injects the built-in @http_client service — you never instantiate it yourself.
File: /modules/wkdailyquote/config/services.yml
services:
_defaults:
autowire: true
autoconfigure: true
public: false
WkDailyQuote\:
resource: '../src/*'
exclude: '../src/{index.php,DTO}'
WkDailyQuote\Service\QuoteCacheManager:
public: true
WkDailyQuote\Service\QuoteFetcher:
public: true
arguments:
$httpClient: '@http_client'
$cacheManager: '@WkDailyQuote\Service\QuoteCacheManager'
Step 7: Create the Main Module File
The main wkdailyquote.php file registers hooks, resolves services from the container, and delegates all real work to them.
Notice there is no HTTP code here whatsoever, that separation of concerns is exactly the goal.
File: /modules/wkdailyquote/wkdailyquote.php
<?php
require_once __DIR__ . '/vendor/autoload.php';
use PrestaShop\PrestaShop\Adapter\SymfonyContainer;
use WkDailyQuote\Service\QuoteFetcher;
use WkDailyQuote\Service\QuoteCacheManager;
class WkDailyQuote extends Module
{
public function __construct()
{
$this->name = 'wkdailyquote';
$this->tab = 'administration';
$this->version = '1.0.0';
$this->author = 'wk';
$this->need_instance = 0;
$this->ps_versions_compliancy = [
'min' => '9.0.0',
'max' => _PS_VERSION_
];
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->l('Daily Quote');
$this->description = $this->l('Displays inspirational quotes on your dashboard using Symfony HTTP Client with caching.');
$this->confirmUninstall = $this->l('Are you sure you want to uninstall this module?');
}
public function install(): bool
{
return parent::install() && $this->registerHook('displayDashboardTop');
}
public function uninstall(): bool
{
$this->clearModuleCache();
return parent::uninstall();
}
public function hookDisplayDashboardTop(array $params)
{
if ($this->context->controller->controller_name != 'AdminDashboard') return;
$container = SymfonyContainer::getInstance();
if (!$container->has(QuoteFetcher::class)) {
return '';
}
$quote = $container->get(QuoteFetcher::class)->getRandomQuote();
if (!$quote) {
return $this->renderErrorMessage();
}
$this->context->smarty->assign([
'quote_text' => $quote->getQuote(),
'quote_author' => $quote->getAuthor(),
'quote_id' => $quote->getId(),
]);
return $this->display(__FILE__, 'views/templates/hook/dashboard_quote.tpl');
}
private function clearModuleCache(): void
{
$container = SymfonyContainer::getInstance();
if ($container->has(QuoteCacheManager::class)) {
/** @var QuoteCacheManager $cacheManager */
$cacheManager = $container->get(QuoteCacheManager::class);
$cacheManager->clear();
}
}
}
Step 8: Create the View Templates
File: /modules/wkdailyquote/views/templates/hook/dashboard_quote.tpl
<section id="wkdailyquote" class="panel widget wkdailyquote-container">
<div class="panel-heading">
<i class="icon-quote-left"></i>
{l s='Quote of the Moment' mod='wkdailyquote'}
</div>
<div class="panel-body">
<blockquote class="wkdailyquote-quote">
<p>{$quote_text|escape:'html':'UTF-8'}</p>
<footer><cite>{$quote_author|escape:'html':'UTF-8'}</cite></footer>
</blockquote>
</div>
</section>
In this example, we are displaying a quote on dashboard using the PrestaShop hook.
While simple, this module will shows the exact architecture required for complex integrations like ERPs, shipping carriers, or payment gateways.
Here is the result below:
The architecture you’ve built is designed to scale. Here are four powerful ways to extend its capabilities once the foundation is in place:
- Authentication for API Requests: Secure your connections by implementing Bearer tokens, API keys, or OAuth2 headers directly within a scoped client configuration.
- Handling POST Requests with JSON Payloads: Move beyond simple GET requests. The client makes it easy to send data; simply use the
jsonkey in your request options to automatically encode arrays and set theContent-Typeheader. - Implementing Rate Limiting: Prevent API bans by using the Rate Limiter component. This ensures your module stays within the provider’s usage limits, even during high-traffic periods.
- Asynchronous and Parallel Requests: Drastically improve performance when fetching data from multiple sources. The Symfony HTTP Client is non-blocking, allowing you to trigger multiple requests simultaneously and only wait when you actually need the data.
That’s all about this blog.
If any issues or doubts, please feel free to mention them in the comment section.
Or contact us at support@webkul.com
I would be happy to help.
Also, you can explore our PrestaShop Development Services and a large range of quality PrestaShop Modules.