Architecture · Salesforce · Symfony

Symfony en middleware Salesforce : une API façade devant l’org

Un seul accès à l’org, un contrat stable pour les applications : client Salesforce partagé, DTO, échappement SOQL, erreurs traduites et quotas d’API protégés.

Pourquoi un middleware

Quand plusieurs applications doivent lire ou écrire dans Salesforce (un portail client, une application mobile, un outil interne), la tentation est de leur donner à chacune un accès direct à l’API. Chaque application stocke alors un secret, connaît le modèle de données Salesforce et consomme le quota d’API sans coordination.

Une API Symfony placée entre les deux, en middleware, inverse la situation :

  • Un seul accès : un utilisateur d’intégration et un jeton OAuth, gérés en un point.
  • Un contrat stable : les applications voient /api/clients/{ref}, pas Contact.Ref_Externe__c. Renommer un champ Salesforce ne casse plus trois applications.
  • Des quotas maîtrisés : limitation de débit et cache, au même endroit.
  • Un point d’audit : chaque écriture passe par un journal unique.

Le coût : un service de plus à héberger et à surveiller. Pour une seule application qui appelle Salesforce, un client direct suffit souvent.

L’architecture

Fil rouge : Acme Assurances, compagnie fictive, expose ses fiches clients (des Contact dans l’org acme-demo) à ses applications.

Portail client ─┐
Appli mobile  ──┼──►  API Symfony (middleware)  ──►  Salesforce acme-demo
Outil interne ──┘     · authentifie l'appelant         · un utilisateur d'intégration
                      · valide et traduit (DTO)        · un seul jeton OAuth
                      · limite le débit, met en cache  · API REST / Composite / Bulk

Trois couches dans l’application Symfony : un client Salesforce technique, des dépôts qui parlent le langage métier, et des contrôleurs qui n’exposent que des DTO. Le jeton vient du fournisseur décrit dans l’article Authentifier une intégration Salesforce avec OAuth 2.0.

Un client Salesforce unique

Toutes les requêtes passent par une seule classe : elle ajoute le jeton, fixe la version d’API, applique un délai maximal et rejoue une fois sur session expirée. Elle transforme surtout les erreurs en exception typée, au lieu de laisser remonter un code HTTP brut.

<?php

namespace App\Salesforce;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class SalesforceClient
{
    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly SalesforceTokenProvider $tokens,
        private readonly string $apiVersion = 'v62.0',
    ) {
    }

    /** Requête REST relative à /services/data/vXX.X, avec reprise unique sur session expirée. */
    public function request(string $method, string $path, array $options = [], bool $retry = true): ?array
    {
        $token = $this->tokens->token();
        $response = $this->http->request($method, $token['instance_url'] . '/services/data/' . $this->apiVersion . $path, $options + [
            'auth_bearer' => $token['access_token'],
            'timeout' => 10,
        ]);

        if ($response->getStatusCode() === 401 && $retry) {
            $this->tokens->invalidate();
            return $this->request($method, $path, $options, false);
        }

        return $this->decode($response);
    }

    private function decode(ResponseInterface $response): ?array
    {
        $status = $response->getStatusCode();
        if ($status === 204) {
            return null; // PATCH réussi : pas de corps
        }
        // false : ne pas lever l'exception générique de HttpClient, on veut lire le corps d'erreur.
        $body = $response->toArray(false);
        if ($status >= 400) {
            // Salesforce renvoie une liste : [{"errorCode": "...", "message": "...", "fields": [...]}]
            throw SalesforceException::fromResponse($status, $body);
        }

        return $body;
    }
}
<?php

namespace App\Salesforce;

final class SalesforceException extends \RuntimeException
{
    public function __construct(
        public readonly int $httpStatus,
        public readonly string $errorCode,
        string $message,
        public readonly array $fields = [],
    ) {
        parent::__construct($message);
    }

    public static function fromResponse(int $status, array $body): self
    {
        $first = $body[0] ?? [];

        return new self($status, $first['errorCode'] ?? 'UNKNOWN', $first['message'] ?? 'Erreur Salesforce', $first['fields'] ?? []);
    }
}

La façade : DTO et SOQL sûre

Le dépôt traduit le métier en appels Salesforce. L’écriture est un upsert sur l’identifiant externe : rejoué après une coupure réseau, il met à jour au lieu de créer un doublon.

<?php

namespace App\Salesforce;

use App\Dto\ClientView;
use App\Dto\ClientInput;

final class ClientRepository
{
    public function __construct(private readonly SalesforceClient $salesforce)
    {
    }

    public function findByRef(string $ref): ?ClientView
    {
        $soql = sprintf(
            'SELECT Ref_Externe__c, FirstName, LastName, Email, Account.Name FROM Contact WHERE Ref_Externe__c = %s LIMIT 1',
            self::quote($ref),
        );
        $record = $this->salesforce->request('GET', '/query', ['query' => ['q' => $soql]])['records'][0] ?? null;

        return $record ? ClientView::fromRecord($record) : null;
    }

    /** Upsert par External ID : rejouable sans créer de doublon. */
    public function save(string $ref, ClientInput $input): void
    {
        $this->salesforce->request('PATCH', '/sobjects/Contact/Ref_Externe__c/' . rawurlencode($ref), [
            'json' => [
                'FirstName' => $input->firstName,
                'LastName' => $input->lastName,
                'Email' => $input->email,
            ],
        ]);
    }

    // SOQL n'a pas de requêtes préparées : échapper soi-même chaque valeur (injection SOQL).
    private static function quote(string $value): string
    {
        return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'";
    }
}

SOQL ne connaît pas les paramètres liés côté API REST : toute valeur venue d’un appelant s’échappe avant d’entrer dans la requête, sinon CLI-1' OR LastName != ' élargit la recherche à toute la base. La contrainte de route CLI-\d{6} ajoute une seconde barrière.

<?php

namespace App\Controller;

use App\Dto\ClientInput;
use App\Salesforce\ClientRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/clients/{ref}', requirements: ['ref' => 'CLI-\d{6}'])]
final class ClientController extends AbstractController
{
    public function __construct(private readonly ClientRepository $clients)
    {
    }

    #[Route(methods: ['GET'])]
    public function show(string $ref): JsonResponse
    {
        $client = $this->clients->findByRef($ref) ?? throw $this->createNotFoundException();

        return $this->json($client);
    }

    #[Route(methods: ['PUT'])]
    public function save(string $ref, #[MapRequestPayload] ClientInput $input): Response
    {
        $this->clients->save($ref, $input);

        return new Response(null, Response::HTTP_NO_CONTENT);
    }
}

#[MapRequestPayload] désérialise et valide le corps JSON avant d’appeler Salesforce : une donnée mal formée est refusée en 422 sans consommer d’appel d’API.

Traduire les erreurs Salesforce

Un appelant n’a pas à connaître FIELD_CUSTOM_VALIDATION_EXCEPTION. Un écouteur d’exceptions convertit chaque code Salesforce en réponse HTTP au format application/problem+json :

Code SalesforceRéponse du middlewareQue fait l’appelant
NOT_FOUND404Rien à reprendre
Validation, champ requis, e-mail invalide422 avec les champsCorriger la saisie
UNABLE_TO_LOCK_ROW, REQUEST_LIMIT_EXCEEDED503 + Retry-AfterRéessayer plus tard
INVALID_FIELD, MALFORMED_QUERY…502Rien : c’est un défaut du middleware
<?php

namespace App\EventListener;

use App\Salesforce\SalesforceException;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;

#[AsEventListener]
final class SalesforceExceptionListener
{
    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    public function __invoke(ExceptionEvent $event): void
    {
        $e = $event->getThrowable();
        if (!$e instanceof SalesforceException) {
            return;
        }

        [$status, $title] = match ($e->errorCode) {
            'NOT_FOUND' => [404, 'Enregistrement introuvable'],
            'REQUIRED_FIELD_MISSING', 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
            'INVALID_EMAIL_ADDRESS', 'STRING_TOO_LONG' => [422, 'Données refusées'],
            'UNABLE_TO_LOCK_ROW', 'REQUEST_LIMIT_EXCEEDED' => [503, 'Service momentanément indisponible'],
            default => [502, 'Erreur du système amont'],
        };

        // Le détail technique va au journal ; l'appelant ne voit ni nom d'objet ni requête.
        $this->logger->error('Salesforce {code} : {message}', ['code' => $e->errorCode, 'message' => $e->getMessage()]);

        $response = new JsonResponse(
            ['title' => $title, 'status' => $status] + ($status === 422 ? ['fields' => $e->fields] : []),
            $status,
            ['Content-Type' => 'application/problem+json'] + ($status === 503 ? ['Retry-After' => '30'] : []),
        );
        $event->setResponse($response);
    }
}

Protéger les quotas d’API

L’org dispose d’un nombre d’appels d’API par période de 24 heures, partagé par toutes les intégrations. Une application qui boucle par erreur peut l’épuiser et bloquer les autres. Le composant RateLimiter de Symfony attribue un budget à chaque appelant :

# config/packages/rate_limiter.yaml — un seau de jetons par application appelante
framework:
    rate_limiter:
        salesforce_api:
            policy: token_bucket
            limit: 100
            rate: { interval: '1 minute', amount: 50 }
// Dans un listener kernel.request sur /api, avec l'identifiant de l'application authentifiée.
$limit = $this->salesforceApiLimiter->create($appId)->consume();
if (!$limit->isAccepted()) {
    throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
}

Les lectures fréquentes se mettent en cache quelques dizaines de secondes, invalidées à chaque écriture :

// Lecture mise en cache brièvement : une fiche consultée en rafale ne coûte qu'un appel.
return $this->cache->get('client.' . $ref, function (ItemInterface $item) use ($ref): ?ClientView {
    $item->expiresAfter(60);
    return $this->clients->findByRef($ref);
});

// À chaque écriture : $this->cache->delete('client.' . $ref);

Chaque réponse REST de Salesforce indique la consommation de l’org dans un en-tête. Le journaliser, ou l’exposer à la supervision, donne l’alerte avant la coupure :

Sforce-Limit-Info: api-usage=48210/100000

Bonnes pratiques

  • Regrouper : plusieurs opérations liées tiennent dans un seul appel Composite ou sObject Collections ; au-delà de quelques milliers d’enregistrements, passer à la Bulk API.
  • Découpler l’écriture : si l’appelant n’a pas besoin d’une confirmation immédiate, publier un message et répondre 202, comme dans l’article Synchroniser vers Salesforce avec Symfony Messenger.
  • Version d’API figée : une version explicite (v62.0) dans la configuration, montée volontairement après test.
  • Identifiant de corrélation : un en-tête propagé de l’appelant au journal relie une erreur Salesforce à la requête d’origine.
  • Tests sans réseau : MockHttpClient rejoue des réponses Salesforce enregistrées, erreurs comprises.
  • Droits minimaux : l’utilisateur d’intégration n’a accès qu’aux objets et champs que la façade expose.
🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.