PHP
Appelez Formerie à partir de n’importe quel site PHP avec ext-curl, présent dans la plupart des installations de PHP, sans Composer. Vous n’avez besoin ni d’un paquet Formerie ni d’une clé d’API : l’adresse publique d’un formulaire suffit.
Soumettre un formulaire
Enregistrez ce code sous submit.php, puis lancez php submit.php.
<?php
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'fields' => [
'name' => 'Jane Example',
'email' => 'jane@example.com',
'message' => 'Hello from my server',
],
], JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
exit('Could not reach Formerie: ' . curl_error($curl) . "\n");
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$result = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
echo $status, ' ', $result['ok'] ? $result['submissionUid'] : $result['error']['code'], "\n";Remplacez acme/contact, sur la ligne indiquée, par vos propres identifiants. Une réussite donne l’état HTTP 202, avec un submissionUid comme sub_p6dr8wq2j7tkan : les réponses sont stockées et leur courriel est en file d’attente. Chaque exemple lit deux réglages facultatifs dans l’environnement : FORMERIE_RUNTIME_URL pour un nom d’hôte que vous avez branché, et FORMERIE_FORM pour les identifiants.
Avant de commencer
- Publiez un formulaire, et prenez ses identifiants d’espace de travail et de formulaire dans l’onglet Publication. Votre serveur n’a pas besoin de figurer dans les sites autorisés du formulaire.
- Pour faire des essais sans envoyer de courriel, créez une clé de test dans l’onglet Tests du formulaire, et mettez-la dans une variable d’environnement appelée
FORMERIE_TEST_KEY:
<?php
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
// Keep the key out of your code. A test is stored apart and emails nobody.
'X-Formerie-Test-Key: ' . getenv('FORMERIE_TEST_KEY'),
],
CURLOPT_POSTFIELDS => json_encode([
'fields' => ['name' => 'Jane Example', 'email' => 'jane@example.com', 'message' => 'Just a test'],
], JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
exit('Could not reach Formerie: ' . curl_error($curl) . "\n");
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$result = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
echo $status, ' ', $result['ok'] ? $result['submissionUid'] : $result['error']['code'], "\n";Un test revient sous la forme tst_..., est stocké à part et n’envoie de courriel à personne. Une mauvaise clé reçoit TEST_KEY_INVALID, état HTTP 401, et rien n’est stocké. Retirez l’en-tête avant la mise en ligne, car une clé laissée en place classe tout comme un test. Consultez Tester depuis un serveur ou l’intégration continue.
Vérifier des réponses sans soumettre
validate vérifie les réponses et ne stocke rien, ce qui vous permet de signaler un problème tôt. Il répond 200 quand elles passent, et 422 avec les mêmes erreurs qu’une soumission quand elles ne passent pas. La soumission finale vérifie toujours tout le formulaire de nouveau.
<?php
function formerieUrl(string $action): string
{
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
return "$runtimeUrl/$form/$action";
}
/** Checks answers and stores nothing. With no step, only the fields you send are checked. */
function checkAnswers(array $fields, ?string $step = null): array
{
$payload = $step === null
? ['mode' => 'partial', 'fields' => $fields]
: ['mode' => 'step', 'step' => $step, 'fields' => $fields];
$curl = curl_init(formerieUrl('validate'));
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
throw new RuntimeException(curl_error($curl));
}
$result = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if (!$result['ok'] && $result['error']['code'] !== 'VALIDATION_FAILED') {
throw new RuntimeException($result['error']['code']);
}
return $result;
}Par défaut, seuls les champs que vous envoyez sont vérifiés. Passez une étape pour vérifier plutôt tous ses champs, sur un formulaire à étapes. Une étape que le formulaire n’a pas reçoit BAD_REQUEST.
Afficher les erreurs par champ
Les réponses refusées reviennent avec VALIDATION_FAILED, état HTTP 422, et rien n’est stocké. error.fields contient les problèmes par champ, et error.issues les mêmes, dans l’ordre. Chacun a un path, un code sur lequel vous fier et un message que vous pouvez afficher, dans la langue du formulaire.
<?php
function submitAnswers(array $fields): array
{
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['fields' => $fields], JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
throw new RuntimeException(curl_error($curl));
}
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
/** Finds the visitor's answer for a path such as contact.email, so the form can show it again. */
function answerAt(array $answers, string $path): string
{
$value = $answers;
foreach (explode('.', $path) as $part) {
$value = is_array($value) ? ($value[$part] ?? '') : '';
}
return is_scalar($value) ? (string) $value : '';
}
/** One input with its answer kept and its first message under it. */
function renderField(string $path, array $answers, array $result): string
{
$name = htmlspecialchars($path);
$value = htmlspecialchars(answerAt($answers, $path));
$message = $result['error']['fields'][$path][0]['message'] ?? null;
$error = $message === null ? '' : '<p class="error">' . htmlspecialchars($message) . '</p>';
return "<input name=\"$name\" value=\"$value\">$error";
}Un champ dans un groupe a un chemin comme contact.email, et un champ dans une liste, guests[0].email : séparez le chemin pour trouver sa réponse. Consultez Chemins de champs. Réaffichez les réponses du visiteur avec les messages, échappées, pour qu’il n’ait qu’à corriger ce qui ne va pas.
Réessayer en toute sécurité
Une panne de réseau ne vous dit pas si la soumission est arrivée. Envoyez donc une seule metadata.idempotencyKey par soumission, la même à chaque nouvel essai. Formerie la compte et la livre alors une seule fois, et signale une répétition avec DUPLICATE_SUBMISSION, état HTTP 409, qui veut dire que le premier envoi a fonctionné.
<?php
const MAX_ATTEMPTS = 3;
const LONGEST_WAIT = 30;
const TEMPORARY_CODES = ['FORM_MAINTENANCE', 'QUEUE_FAILED', 'INTERNAL_ERROR'];
/** One attempt. Says what happened, and whether another attempt could help. */
function sendOnce(array $fields, string $key): array
{
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
$after = null;
$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'fields' => $fields,
'metadata' => ['idempotencyKey' => $key],
], JSON_THROW_ON_ERROR),
CURLOPT_HEADERFUNCTION => function ($curl, string $header) use (&$after): int {
if (preg_match('/^retry-after:\s*(\d+)\s*$/i', $header, $match)) {
$after = (int) $match[1];
}
return strlen($header);
},
]);
$body = curl_exec($curl);
if ($body === false) {
return ['outcome' => 'retry-later', 'reason' => 'network', 'again' => true];
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
// Only the short-term limit sends Retry-After. A form's own limit can last an hour, so don't wait for it.
if ($status === 429) {
return [
'outcome' => 'retry-later',
'reason' => 'rate-limited',
'after' => $after,
'again' => $after !== null && $after <= LONGEST_WAIT,
];
}
// Something in front of Formerie may answer without JSON, so look at the status first.
$result = json_decode($body, true);
if (!is_array($result)) {
return ['outcome' => 'retry-later', 'reason' => 'bad-response', 'again' => in_array($status, [502, 503, 504], true)];
}
if ($result['ok']) {
return ['outcome' => 'accepted', 'id' => $result['submissionUid']];
}
$error = $result['error'];
if ($error['code'] === 'DUPLICATE_SUBMISSION') {
// The first send worked, and only its answer was lost.
return ['outcome' => 'accepted', 'duplicate' => true];
}
if (in_array($error['code'], TEMPORARY_CODES, true)) {
return ['outcome' => 'retry-later', 'reason' => $error['code'], 'again' => true];
}
if ($error['code'] === 'VALIDATION_FAILED') {
return ['outcome' => 'invalid', 'code' => $error['code'], 'issues' => $error['issues']];
}
return ['outcome' => 'rejected', 'code' => $error['code'], 'message' => $error['message']];
}
/** One key per submission, the same on every retry. Store it with the answers if you retry later. */
function submitWithRetries(array $fields, ?string $key = null): array
{
$key ??= bin2hex(random_bytes(16));
for ($attempt = 1; ; $attempt++) {
$answer = sendOnce($fields, $key);
$again = $answer['again'] ?? false;
unset($answer['again']);
if (!$again || $attempt === MAX_ATTEMPTS) {
return $answer + ['key' => $key, 'attempts' => $attempt];
}
$backoff = random_int(0, min(250 * 2 ** $attempt, 4000)) / 1000;
usleep((int) (max($answer['after'] ?? 0, $backoff) * 1_000_000));
}
}| Réponse | Réessayer? |
|---|---|
| Pas de connexion, ou délai dépassé | Oui, avec la même clé |
429 avec Retry-After | Oui, après cette attente, si elle est courte. La limite à court terme répond avec Retry-After: 60 |
429 sans Retry-After | Non. La limite d’un formulaire peut durer une heure : demandez au visiteur de réessayer plus tard |
FORM_MAINTENANCE, QUEUE_FAILED, INTERNAL_ERROR | Oui, après une courte attente |
502, 503 ou 504 sans JSON | Oui. Quelque chose devant Formerie a répondu |
| Toute autre réponse sans JSON | Non. Gardez la clé, et voyez ce qui s’est passé |
DUPLICATE_SUBMISSION | Non. Traitez-le comme un succès |
VALIDATION_FAILED et tout autre code | Non. La réponse ne changera pas |
- La clé doit être une chaîne non vide. Tout le reste est ignoré, et il n’y a alors aucune vérification des répétitions.
- Une répétition est repérée pendant 300 secondes par défaut, selon la période d’envois répétés du formulaire. Consultez Ce que couvre la période d’envois répétés.
- Cette vérification passe en premier : une clé déjà acceptée reçoit
DUPLICATE_SUBMISSION, même avec d’autres réponses. Utilisez une nouvelle clé pour chaque nouvelle soumission. Une clé refusée à cause de ses réponses n’a jamais été acceptée : elle peut repartir une fois celles-ci corrigées. - Un nouvel essai après
QUEUE_FAILEDpeut stocker une deuxième copie, que vous verriez sous Soumissions. C’est rare.
Vérification humaine
Si votre formulaire exige une vérification humaine, utilisez vos propres clés, avec votre site dans la liste de domaines du fournisseur et dans les sites autorisés du formulaire. Le widget ajoute son jeton à votre formulaire. Transmettez deux choses :
- Le jeton, comme
verificationToken, retiré des champs. - L’
Originde la requête du visiteur, parce que Formerie compare le domaine du jeton avec celui-ci. Un serveur n’en envoie pas de lui-même, et la vérification échoue alors avecTURNSTILE_FAILED.
<?php
/** $posted is what your form sent, such as $_POST. $origin is $_SERVER['HTTP_ORIGIN'] ?? ''. */
function submitVerified(array $posted, string $origin): array
{
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
$token = $posted['cf-turnstile-response'] ?? '';
unset($posted['cf-turnstile-response']);
$headers = ['Content-Type: application/json'];
// Without the visitor's Origin, the provider's hostname can't match, and the check fails.
if ($origin !== '') {
$headers[] = "Origin: $origin";
}
$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => json_encode(['fields' => $posted, 'verificationToken' => $token], JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
throw new RuntimeException(curl_error($curl));
}
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}Une Origin absente des sites autorisés du formulaire reçoit FORBIDDEN_ORIGIN. Ne la transmettez que pour ça : sur tout autre formulaire, elle active une vérification dont votre serveur n’a autrement pas besoin. Le widget a besoin de JavaScript dans le navigateur du visiteur. Consultez La vérification avec un serveur.
Vérifications antipourriel
Les pages hébergées et les intégrations envoient ces métadonnées pour vous. À partir de votre serveur, envoyez-les vous-même, sinon ces vérifications sont sautées.
<?php
function submitWithMetadata(array $fields, DateTimeImmutable $startedAt, string $visitorId): array
{
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
$curl = curl_init("$runtimeUrl/$form/submit");
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'fields' => $fields,
'metadata' => [
'idempotencyKey' => bin2hex(random_bytes(16)),
// When the visitor opened the form, for the minimum submit time check.
'startedAt' => $startedAt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'),
'pageUrl' => 'https://www.example.com/contact',
// Your own visitor ID, for a limit rule with the deviceToken scope.
'deviceToken' => $visitorId,
],
], JSON_THROW_ON_ERROR),
]);
$body = curl_exec($curl);
if ($body === false) {
throw new RuntimeException(curl_error($curl));
}
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}| Champ | À quoi il sert |
|---|---|
idempotencyKey | La vérification des répétitions, comme ci-dessus |
startedAt | Le moment où le visiteur a ouvert le formulaire, en heure ISO 8601. Envoyé avant le minimum du formulaire, 1 seconde par défaut, il reçoit SPAM_REJECTED. Gardez la vraie heure à laquelle vous avez affiché le formulaire, pas celle de cet appel. Une valeur illisible saute la vérification |
pageUrl | La page d’où le formulaire a été envoyé. Stockée seulement s’il s’agit d’une adresse http ou https |
deviceToken | Votre propre identifiant de visiteur, pour une limite par visiteur, ci-dessous |
La vérification du champ caché s’applique quand votre formulaire envoie ce champ, et les liens et la vérification humaine sont vérifiés sur le serveur dans tous les cas. Consultez Pourriel et limites.
Les limites à partir de votre serveur
Formerie compte les visiteurs par adresse réseau, et d’ici, c’est celle de votre serveur. Chaque visiteur compte comme votre serveur.
| Limite | Par défaut | À partir de votre serveur |
|---|---|---|
| Par visiteur | 30 soumissions toutes les 10 minutes | Partagée par tous vos visiteurs. Augmentez-la avant le lancement, ou laissez-la vide pour n’avoir aucune limite, sous Paramètres, puis Limites de soumission |
| Tout le formulaire | 1 000 soumissions à l’heure | La même. Avec le forfait gratuit, c’est aussi le maximum qu’un formulaire peut accepter |
| Court terme | 120 requêtes à la minute pour chaque formulaire à partir d’une même adresse | Ne peut pas être changée. C’est la seule qui envoie Retry-After. validate y compte aussi |
Au-delà de l’une d’elles, vous recevez RATE_LIMITED, état HTTP 429. Pour garder une limite par visiteur, une règle avec la portée deviceToken compte selon l’identifiant que vous envoyez, comme un hachage de l’adresse du visiteur. Les paramètres de l’application n’offrent pas encore cette règle, alors écrivez-nous pour en mettre une en place, et gardez la limite pour tout le formulaire, car quiconque appelle directement le formulaire peut changer son identifiant. Consultez Les limites avec un serveur pour le raisonnement, et Forfaits et limites pour les compteurs mensuels.
Laravel
Ça fonctionne par l’API : la route lit le formulaire du visiteur et appelle Formerie avec le client Http de Laravel. Le code a été exécuté avec un backend Formerie local et Laravel 13, dans routes/web.php. Il répond 202 avec l’identifiant de la soumission, le même état que Formerie avec les messages par champ, ou 503 s’il ne peut pas joindre Formerie. Gardez @csrf dans votre formulaire. Http ne lance pas d’exception sur un 422, donc ses champs peuvent être lus.
<?php
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
Route::post('/contact', function (Request $request) {
$runtimeUrl = env('FORMERIE_RUNTIME_URL', 'https://forms.formerie.com');
// Your form. This is the only line that names it.
$form = env('FORMERIE_FORM', 'acme/contact');
try {
$answer = Http::timeout(10)->post("$runtimeUrl/$form/submit", [
// Name the fields you send, so a visitor can't add others.
'fields' => $request->only(['name', 'email', 'message']),
]);
} catch (ConnectionException) {
// No connection or a timeout. Keep the visitor's answers on screen.
return response()->json(['code' => 'TRY_AGAIN'], 503);
}
$result = $answer->json();
if (!is_array($result)) {
// Something in front of Formerie answered without JSON.
return response()->json(['code' => 'TRY_AGAIN'], 503);
}
if ($result['ok']) {
return response()->json(['id' => $result['submissionUid']], 202);
}
$errors = array_map(fn (array $issues) => array_column($issues, 'message'), $result['error']['fields'] ?? []);
return response()->json(['code' => $result['error']['code'], 'errors' => $errors], $answer->status());
});Symfony
Ça fonctionne aussi par l’API, avec le HttpClient de Symfony, et le code a été exécuté de la même façon avec Symfony 7.4. Dirigez-y POST /contact. Il répond de la même façon que la route Laravel.
composer require symfony/http-client<?php
namespace App;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ContactController
{
public function __construct(private HttpClientInterface $client)
{
}
public function __invoke(Request $request): JsonResponse
{
$runtimeUrl = getenv('FORMERIE_RUNTIME_URL') ?: 'https://forms.formerie.com';
// Your form. This is the only line that names it.
$form = getenv('FORMERIE_FORM') ?: 'acme/contact';
try {
$answer = $this->client->request('POST', "$runtimeUrl/$form/submit", [
'timeout' => 10,
'max_duration' => 10,
// Name the fields you send, so a visitor can't add others.
'json' => ['fields' => [
'name' => $request->request->getString('name'),
'email' => $request->request->getString('email'),
'message' => $request->request->getString('message'),
]],
]);
// false, so a 422 is read and doesn't throw.
$result = $answer->toArray(false);
} catch (ExceptionInterface) {
// No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
return new JsonResponse(['code' => 'TRY_AGAIN'], 503);
}
if ($result['ok']) {
return new JsonResponse(['id' => $result['submissionUid']], 202);
}
$errors = array_map(fn (array $issues) => array_column($issues, 'message'), $result['error']['fields'] ?? []);
return new JsonResponse(['code' => $result['error']['code'], 'errors' => $errors], $answer->getStatusCode());
}
}Bon à savoir
- cURL n’a aucune limite totale par défaut : gardez
CURLOPT_TIMEOUTsur chaque appel. - Gardez les clés de test et les réponses hors de vos journaux. Le
requestIdd’une erreur, ou l’en-têteX-Request-Id, est ce qu’il faut journaliser et citer quand vous demandez de l’aide. - Fiez-vous à
error.code, jamais au message, car un formulaire peut reformuler ses messages et un même état peut couvrir plusieurs codes. Codes d’erreur les énumère tous.
| Avec | Note |
|---|---|
| PHP simple | Les réponses sont dans $_POST, et l’Origin du visiteur dans $_SERVER['HTTP_ORIGIN']. Nommez les champs que vous envoyez plutôt que de transmettre $_POST tel quel. |
| Guzzle | Passez 'http_errors' => false, sinon un 422 lance une exception avant que vous puissiez lire ses champs. Utilisez 'json' pour le corps et 'timeout' pour la limite. |
| PHP 8.5 | curl_close() est désuète et ne fait plus rien depuis PHP 8.0. Ces exemples ne l’appellent pas. |
Les routes, les champs et toutes les erreurs se trouvent dans la référence de l’API.